Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions smartcontract/programs/doublezero-serviceability/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DoubleZeroError> for ProgramError {
Expand Down Expand Up @@ -221,6 +223,7 @@ impl From<DoubleZeroError> for ProgramError {
DoubleZeroError::ImmutableField => ProgramError::Custom(68),
DoubleZeroError::CyoaRequiresPhysical => ProgramError::Custom(69),
DoubleZeroError::DeviceHasInterfaces => ProgramError::Custom(70),
DoubleZeroError::AccessPassInUse => ProgramError::Custom(71),
}
}
}
Expand Down Expand Up @@ -298,6 +301,7 @@ impl From<u32> for DoubleZeroError {
68 => DoubleZeroError::ImmutableField,
69 => DoubleZeroError::CyoaRequiresPhysical,
70 => DoubleZeroError::DeviceHasInterfaces,
71 => DoubleZeroError::AccessPassInUse,
_ => DoubleZeroError::Custom(e),
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading