diff --git a/CHANGELOG.md b/CHANGELOG.md index ea517548d..a7a5c5839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ All notable changes to this project will be documented in this file. - Restrict DeleteDeviceInterface to interfaces in Activated or Unlinked status; attempting to delete interfaces in other statuses now fails with InvalidStatus. - Updated validation to allow public IP prefixes for CYOA/DIA, removing the restriction imposed by type-based checks. - Transit devices can now be provisioned without a public IP, aligning the requirements with their actual networking model and avoiding unnecessary configuration constraints. + - Enforce that `CloseAccessPass` only closes AccessPass accounts when `connection_count == 0`, preventing closure while active connections are present. - Enforce that ActivateDeviceInterface only activates interfaces in Pending or Unlinked status, returning InvalidStatus for all other interface states - Introduce desired status to Link and Devices - Internet Latency Telemetry diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index a79d5f5e0..5fa181a2c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -137,6 +137,8 @@ pub enum DoubleZeroError { InvalidArgument, // variant 65 #[error("Invalid Foundation Allowlist: cannot be empty")] InvalidFoundationAllowlist, // variant 66 + #[error("Access Pass is in use (non-zero connection_count)")] + AccessPassInUse, // variant 70 #[error("Deprecated error")] Deprecated, // variant 67 #[error("Immutable Field")] @@ -221,6 +223,7 @@ impl From for ProgramError { DoubleZeroError::ImmutableField => ProgramError::Custom(68), DoubleZeroError::CyoaRequiresPhysical => ProgramError::Custom(69), DoubleZeroError::DeviceHasInterfaces => ProgramError::Custom(70), + DoubleZeroError::AccessPassInUse => ProgramError::Custom(71), } } } @@ -298,6 +301,7 @@ impl From for DoubleZeroError { 68 => DoubleZeroError::ImmutableField, 69 => DoubleZeroError::CyoaRequiresPhysical, 70 => DoubleZeroError::DeviceHasInterfaces, + 71 => DoubleZeroError::AccessPassInUse, _ => DoubleZeroError::Custom(e), } } @@ -391,6 +395,7 @@ mod tests { SerializationFailure, InvalidArgument, InvalidFoundationAllowlist, + AccessPassInUse, Deprecated, ImmutableField, CyoaRequiresPhysical, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs index 0671428bc..d6bb78418 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs @@ -1,7 +1,7 @@ use crate::{ error::DoubleZeroError, serializer::try_acc_close, - state::{accounttype::AccountType, globalstate::GlobalState}, + state::{accesspass::AccessPass, accounttype::AccountType, globalstate::GlobalState}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -82,7 +82,18 @@ pub fn process_close_access_pass( msg!("AccountType is not AccessPass, cannot close"); return Err(DoubleZeroError::InvalidAccountType.into()); } - msg!("AccountType is AccessPass, proceeding to close"); + + let accesspass = AccessPass::try_from(accesspass_account)?; + + if accesspass.connection_count != 0 { + msg!( + "AccessPass has active connections (connection_count={}), cannot close", + accesspass.connection_count + ); + return Err(DoubleZeroError::AccessPassInUse.into()); + } + + msg!("AccountType is AccessPass and connection_count == 0, proceeding to close"); } else { msg!("Failed to borrow account data, cannot close"); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs index 793740e09..6c7133952 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_test.rs @@ -4,11 +4,16 @@ use doublezero_serviceability::{ processors::accesspass::{ check_status::CheckStatusAccessPassArgs, close::CloseAccessPassArgs, set::SetAccessPassArgs, }, - state::accesspass::AccessPassType, + state::{ + accesspass::{AccessPass, AccessPassStatus, AccessPassType}, + accounttype::AccountType, + }, }; +use solana_program::rent::Rent; use solana_program_test::*; use solana_sdk::{ - instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, system_program, + account::Account as SolanaAccount, instruction::AccountMeta, pubkey::Pubkey, + signature::Keypair, signer::Signer, system_program, }; use std::net::Ipv4Addr; @@ -265,6 +270,96 @@ async fn test_accesspass() { println!("🟢 End test_accesspass"); } +#[tokio::test] +async fn test_close_accesspass_rejects_nonzero_connection_count() { + // Set up a dedicated ProgramTest so we can pre-seed an AccessPass account + let program_id = Pubkey::new_unique(); + + let (program_config_pubkey, _) = get_program_config_pda(&program_id); + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + + let client_ip = Ipv4Addr::new(101, 0, 0, 1); + let user_payer = Pubkey::new_unique(); + let (accesspass_pubkey, bump_seed) = get_accesspass_pda(&program_id, &client_ip, &user_payer); + + // Build an AccessPass with connection_count > 0 + let seeded_accesspass = AccessPass { + account_type: AccountType::AccessPass, + owner: program_id, + bump_seed, + accesspass_type: AccessPassType::Prepaid, + client_ip, + user_payer, + last_access_epoch: 0, + connection_count: 1, + status: AccessPassStatus::Connected, + mgroup_pub_allowlist: vec![], + mgroup_sub_allowlist: vec![], + flags: 0, + }; + + let accesspass_data = borsh::to_vec(&seeded_accesspass).unwrap(); + let rent = Rent::default(); + let lamports = rent.minimum_balance(accesspass_data.len()); + + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + + // Pre-seed the AccessPass account owned by the program + program_test.add_account( + accesspass_pubkey, + SolanaAccount { + lamports, + data: accesspass_data, + owner: program_id, + executable: false, + rent_epoch: 0, + }, + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + // Initialize global state so that payer is in the foundation_allowlist + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::InitGlobalState(), + vec![ + AccountMeta::new(program_config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Attempt to close the seeded AccessPass; this should fail because connection_count != 0 + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CloseAccessPass(CloseAccessPassArgs {}), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + assert!( + res.is_err(), + "CloseAccessPass should fail when connection_count > 0" + ); + + // The AccessPass account should still exist after the failed close attempt + let account_after = banks_client.get_account(accesspass_pubkey).await.unwrap(); + assert!(account_after.is_some()); +} + #[tokio::test] async fn test_tx_lamports_to_pda_before_creation() { let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await;