From f8990a764eae75d13eccbd5e5861c95659797227 Mon Sep 17 00:00:00 2001 From: Martin Sander Date: Tue, 3 Feb 2026 10:47:20 -0600 Subject: [PATCH] Verify that AccessPass has no active connections Resolves: #2220 --- CHANGELOG.md | 2 + .../doublezero-serviceability/src/error.rs | 4 + .../src/processors/accesspass/close.rs | 14 ++- .../tests/accesspass_test.rs | 99 ++++++++++++++++++- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c10ce0fd5..16c70067b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to this project will be documented in this file. - DeleteUserCommand updated to wait for activator to process multicast user unsubscribe before deleting the user - Device controller - Record successful GetConfig gRPC calls to ClickHouse for device telemetry tracking +- Onchain programs + - Enforce that `CloseAccessPass` only closes AccessPass accounts when `connection_count == 0`, preventing closure while active connections are present. ## [v0.8.6](https://github.com/malbeclabs/doublezero/compare/client/v0.8.5...client/v0.8.6) – 2026-02-04 diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 253f356e7..916457856 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -145,6 +145,8 @@ pub enum DoubleZeroError { CyoaRequiresPhysical, // variant 69 #[error("Device can only be removed if it has no interfaces")] DeviceHasInterfaces, // variant 70 + #[error("Access Pass is in use (non-zero connection_count)")] + AccessPassInUse, // variant 71 } impl From for ProgramError { @@ -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), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/close.rs index 0671428bc..7357bf2ec 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,17 @@ 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, cannot close", + accesspass.connection_count + ); + return Err(DoubleZeroError::AccessPassInUse.into()); + } + + msg!("AccountType is AccessPass and there are no active connections, 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;