Skip to content
Open
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ All notable changes to this project will be documented in this file.
- Add onchain parent DZD discovery to geoprobe-agent: periodically queries the Geolocation program for this probe's parent devices and resolves their metrics publisher keys from Serviceability, replacing the need for static `--parent-dzd` CLI flags. Static parents from CLI are merged with onchain parents, with onchain taking precedence for duplicate keys.
- Optimize inbound probe-measured RTT accuracy: pre-sign both TWAMP probes before network I/O so probe 1 fires immediately after reply 0 with no signing delay, measure Tx-to-Rx interval (reply 0 Tx → probe 1 Rx) instead of Rx-to-Rx to exclude processing overhead on both sides, use kernel `SO_TIMESTAMPNS` receive timestamps on the reflector, and add a 15ms busy-poll window on the sender to avoid scheduler wakeup latency
- Optimize outbound probe RTT accuracy: send a staggered warmup probe on a separate socket 2ms before the measurement probe to wake the reflector's thread, then take the min RTT of both
- Onchain Programs
- Serviceability: add `Permission` account with `CreatePermission`, `UpdatePermission`, `DeletePermission`, `SuspendPermission`, and `ResumePermission` instructions for managing per-keypair permission bitmasks onchain
- SDK
- Split `execute_transaction` into `execute_transaction` (no auth) and `execute_authorized_transaction` (injects Permission PDA) to avoid breaking processors that use `accounts.len()` for optional-account detection
- CLI
- Add `permission get`, `permission list`, and `permission set` commands with table and JSON output; `permission set` supports incremental `--add` / `--remove` flags and creates or updates the account as needed

## [v0.11.0](https://github.com/malbeclabs/doublezero/compare/client/v0.10.0...client/v0.11.0) - 2026-03-12

Expand Down
8 changes: 4 additions & 4 deletions activator/src/process/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1775,7 +1775,7 @@ mod tests {
UserStatus::Deleting,
|user_service, _, seq| {
user_service
.expect_execute_transaction_quiet()
.expect_execute_authorized_transaction_quiet()
.times(1)
.in_sequence(seq)
.with(
Expand All @@ -1799,7 +1799,7 @@ mod tests {
UserStatus::PendingBan,
|user_service, _, seq| {
user_service
.expect_execute_transaction_quiet()
.expect_execute_authorized_transaction_quiet()
.times(1)
.in_sequence(seq)
.with(
Expand Down Expand Up @@ -2853,7 +2853,7 @@ mod tests {

// Stateless mode: use_onchain_deallocation=true
client
.expect_execute_transaction_quiet()
.expect_execute_authorized_transaction_quiet()
.times(1)
.in_sequence(&mut seq)
.with(
Expand Down Expand Up @@ -2959,7 +2959,7 @@ mod tests {
.returning(move |_| Ok(AccountData::User(user2.clone())));

client
.expect_execute_transaction_quiet()
.expect_execute_authorized_transaction_quiet()
.times(1)
.in_sequence(&mut seq)
.with(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
serializer::try_acc_close,
state::{accesspass::AccessPass, accounttype::AccountType, globalstate::GlobalState},
state::{
accesspass::AccessPass, accounttype::AccountType, globalstate::GlobalState,
permission::permission_flags,
},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -70,13 +74,15 @@ pub fn process_close_access_pass(
"Invalid System Program Account Owner"
);

// Parse the global state account & check if the payer is in the allowlist
// Parse the global state account & check authorization
let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key)
&& globalstate.reservation_authority_pk != *payer_account.key
{
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::ACCESS_PASS_ADMIN,
)?;

if let Ok(data) = accesspass_account.try_borrow_data() {
let account_type: AccountType = data[0].into();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
pda::*,
seeds::{SEED_ACCESS_PASS, SEED_PREFIX},
Expand All @@ -7,6 +8,7 @@ use crate::{
accesspass::{AccessPass, AccessPassStatus, AccessPassType, ALLOW_MULTIPLE_IP, IS_DYNAMIC},
accounttype::AccountType,
globalstate::GlobalState,
permission::permission_flags,
tenant::Tenant,
},
};
Expand Down Expand Up @@ -107,21 +109,15 @@ pub fn process_set_access_pass(
"Invalid System Program Account Owner"
);

// Parse the global state account & check if the payer is in the allowlist
// Parse the global state account & check authorization
let globalstate = GlobalState::try_from(globalstate_account)?;
if globalstate.sentinel_authority_pk != *payer_account.key
&& globalstate.reservation_authority_pk != *payer_account.key
&& !globalstate.foundation_allowlist.contains(payer_account.key)
{
msg!(
"sentinel_authority_pk: {} reservation_authority_pk: {} payer: {} foundation_allowlist: {:?}",
globalstate.sentinel_authority_pk,
globalstate.reservation_authority_pk,
payer_account.key,
globalstate.foundation_allowlist
);
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::ACCESS_PASS_ADMIN,
)?;

if let AccessPassType::SolanaValidator(node_id) = value.accesspass_type {
if node_id == Pubkey::default() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
serializer::try_acc_write,
state::{globalstate::GlobalState, user::*},
state::{globalstate::GlobalState, permission::permission_flags, user::*},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -57,9 +58,13 @@ pub fn process_ban_user(
assert!(user_account.is_writable, "PDA Account is not writable");

let globalstate = GlobalState::try_from(globalstate_account)?;
if globalstate.activator_authority_pk != *payer_account.key {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)?;

let mut user: User = User::try_from(user_account)?;
if user.status != UserStatus::PendingBan {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use crate::{
error::DoubleZeroError,
authorize::authorize,
pda::get_resource_extension_pda,
processors::resource::{deallocate_id, deallocate_ip},
resource::ResourceType,
serializer::{try_acc_close, try_acc_write},
state::{device::Device, globalstate::GlobalState, tenant::Tenant, user::*},
state::{
device::Device, globalstate::GlobalState, permission::permission_flags, tenant::Tenant,
user::*,
},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -130,12 +133,13 @@ pub fn process_closeaccount_user(

let globalstate = GlobalState::try_from(globalstate_account)?;

// Authorization: allow activator_authority_pk OR foundation_allowlist (matching ActivateUser)
let is_activator = globalstate.activator_authority_pk == *payer_account.key;
let is_foundation = globalstate.foundation_allowlist.contains(payer_account.key);
if !is_activator && !is_foundation {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)?;

let user = User::try_from(user_account)?;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
pda::get_accesspass_pda,
serializer::{try_acc_close, try_acc_write},
state::{
accesspass::{AccessPass, AccessPassStatus},
device::Device,
globalstate::GlobalState,
permission::permission_flags,
tenant::Tenant,
user::*,
},
Expand Down Expand Up @@ -143,10 +145,15 @@ pub fn process_delete_user(
let user: User = User::try_from(user_account)?;

let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key)
&& user.owner != *payer_account.key
{
return Err(DoubleZeroError::NotAllowed.into());
// The user owner can always delete their own account without a Permission account.
if user.owner != *payer_account.key {
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)?;
}

let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::{
authorize::authorize,
error::DoubleZeroError,
serializer::try_acc_write,
state::{globalstate::GlobalState, user::*},
state::{globalstate::GlobalState, permission::permission_flags, user::*},
};
use borsh::BorshSerialize;
use borsh_incremental::BorshDeserializeIncremental;
Expand Down Expand Up @@ -104,9 +105,13 @@ pub fn process_request_ban_user(
assert!(user_account.is_writable, "PDA Account is not writable");

let globalstate = GlobalState::try_from(globalstate_account)?;
if !globalstate.foundation_allowlist.contains(payer_account.key) {
return Err(DoubleZeroError::NotAllowed.into());
}
authorize(
program_id,
accounts_iter,
payer_account.key,
&globalstate,
permission_flags::USER_ADMIN,
)?;

let mut user: User = User::try_from(user_account)?;
if !can_request_ban(user.status) {
Expand Down
88 changes: 86 additions & 2 deletions smartcontract/sdk/rs/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use std::time::Duration;

use crate::config::default_program_id;
use doublezero_serviceability::{
error::DoubleZeroError, instructions::*, state::accounttype::AccountType,
error::DoubleZeroError, instructions::*, pda::get_permission_pda,
state::accounttype::AccountType,
};
use eyre::{bail, eyre, OptionExt};
use log::debug;
Expand Down Expand Up @@ -374,6 +375,66 @@ impl DZClient {

Ok(errors)
}

fn build_and_send(
&self,
instruction: DoubleZeroInstruction,
accounts: Vec<AccountMeta>,
with_permission: bool,
) -> eyre::Result<Signature> {
let payer = self
.payer
.as_ref()
.ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?;
let data = instruction.pack();

let mut trailing = vec![
AccountMeta::new(payer.pubkey(), true),
AccountMeta::new(program::id(), false),
];
if with_permission {
let (permission_pda, _) = get_permission_pda(&self.program_id, &payer.pubkey());
if self.client.get_account(&permission_pda).is_ok() {
trailing.push(AccountMeta::new_readonly(permission_pda, false));
}
}

let mut transaction = Transaction::new_with_payer(
&[Instruction::new_with_bytes(
self.program_id,
&data,
[accounts, trailing].concat(),
)],
Some(&payer.pubkey()),
);

let blockhash = self.client.get_latest_blockhash().map_err(|e| eyre!(e))?;
transaction.sign(&[&payer], blockhash);

debug!("Simulating transaction: {transaction:?}");

let result = self.client.simulate_transaction(&transaction)?;
if result.value.err.is_some() {
eprintln!("Program Logs:");
if let Some(logs) = result.value.logs {
for log in logs {
eprintln!("{log}");
}
}
}

if let Some(TransactionError::InstructionError(_index, InstructionError::Custom(number))) =
result.value.err
{
return Err(eyre!(DoubleZeroError::from(number)));
} else if let Some(err) = result.value.err {
return Err(eyre!(err));
}

self.client
.send_and_confirm_transaction(&transaction)
.map_err(|e| eyre!(e))
}
}

impl DoubleZeroClient for DZClient {
Expand Down Expand Up @@ -450,7 +511,7 @@ impl DoubleZeroClient for DZClient {
instruction: DoubleZeroInstruction,
accounts: Vec<AccountMeta>,
) -> eyre::Result<Signature> {
self.execute_transaction_inner(instruction, accounts, false)
self.build_and_send(instruction, accounts, false)
}

fn execute_transaction_quiet(
Expand All @@ -461,6 +522,29 @@ impl DoubleZeroClient for DZClient {
self.execute_transaction_inner(instruction, accounts, true)
}

fn execute_authorized_transaction(
&self,
instruction: DoubleZeroInstruction,
accounts: Vec<AccountMeta>,
) -> eyre::Result<Signature> {
self.build_and_send(instruction, accounts, true)
}

fn execute_authorized_transaction_quiet(
&self,
instruction: DoubleZeroInstruction,
accounts: Vec<AccountMeta>,
) -> eyre::Result<Signature> {
let mut accounts = accounts;
if let Some(payer) = self.payer.as_ref() {
let (permission_pda, _) = get_permission_pda(&self.program_id, &payer.pubkey());
if self.client.get_account(&permission_pda).is_ok() {
accounts.push(AccountMeta::new_readonly(permission_pda, false));
}
}
self.execute_transaction_inner(instruction, accounts, true)
}

fn gets(&self, account_type: AccountType) -> eyre::Result<HashMap<Pubkey, AccountData>> {
let account_type = account_type as u8;
let filters = vec![RpcFilterType::Memcmp(Memcmp::new(
Expand Down
4 changes: 2 additions & 2 deletions smartcontract/sdk/rs/src/commands/accesspass/close.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ impl CloseAccessPassCommand {
.execute(client)
.map_err(|_err| eyre::eyre!("Globalstate not initialized"))?;

client.execute_transaction(
client.execute_authorized_transaction(
DoubleZeroInstruction::CloseAccessPass(CloseAccessPassArgs {}),
vec![
AccountMeta::new(self.pubkey, false),
Expand Down Expand Up @@ -50,7 +50,7 @@ mod tests {
let (pda_pubkey, _) = get_accesspass_pda(&client.get_program_id(), &client_ip, &payer);

client
.expect_execute_transaction()
.expect_execute_authorized_transaction()
.with(
predicate::eq(DoubleZeroInstruction::CloseAccessPass(
CloseAccessPassArgs {},
Expand Down
Loading
Loading