diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b6e59ea8..0c01963b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -571,6 +571,9 @@ All notable changes to this project will be documented in this file. - Onchain Programs - Serviceability: add `Permission` account with `CreatePermission`, `UpdatePermission`, `DeletePermission`, `SuspendPermission`, and `ResumePermission` instructions for managing per-keypair permission bitmasks onchain - Serviceability: add `TOPOLOGY_ADMIN`, `RESOURCE_ADMIN`, and `INDEX_ADMIN` permission flags for delegating management of segment-routing topologies, ResourceExtension accounts, and internal Index accounts (legacy authorization maps each to the foundation allowlist) + - Serviceability: enforce `TOPOLOGY_ADMIN`/`RESOURCE_ADMIN`/`INDEX_ADMIN` via `authorize()` in the topology (create/delete/clear/assign-node-segments), resource (create/allocate/deallocate/close), and index (create/delete) instructions, which were previously gated by the foundation allowlist only + - Serviceability: fix `ClearTopology` account layout — the processor now parses `payer`/`system_program`/`permission` from the tail of the account list (matching what the SDK client appends after the variable-length link list) instead of reading them at fixed front positions, so `doublezero topology clear` no longer reverts when links are passed + - Serviceability: `authorize()` now falls back to the legacy allowlists when a payer's auto-injected Permission account exists but does not grant the requested flag, as long as `RequirePermissionAccounts` is off — so foundation (and other legacy-authorized) keys are not locked out of an instruction merely because they also hold an unrelated, under-privileged Permission account - SDK - Add `execute_authorized_transaction` (and its `_quiet` variant) alongside `execute_transaction`. The authorized variants append the payer's Permission PDA (read-only) as the trailing account when it exists on-chain, so `authorize()` can find it. All variants share the same builder, so the protocol-max compute-budget/heap-frame requests, preflight, and error-reporting behavior are identical to `execute_transaction`; the only difference is the optional trailing Permission account. The Permission PDA lookup is retried on transient RPC errors and memoized per client. - Add `TOPOLOGY_ADMIN`/`RESOURCE_ADMIN`/`INDEX_ADMIN` permission-flag constants to the Go, TypeScript, and Python serviceability SDKs diff --git a/smartcontract/programs/doublezero-serviceability/src/authorize.rs b/smartcontract/programs/doublezero-serviceability/src/authorize.rs index aaa855e30a..eca86d8de0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/authorize.rs +++ b/smartcontract/programs/doublezero-serviceability/src/authorize.rs @@ -10,6 +10,7 @@ use crate::{ use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, + msg, program_error::ProgramError, pubkey::Pubkey, }; @@ -23,8 +24,10 @@ use solana_program::{ /// `any_of_flags` uses OR semantics: the payer is authorized if their Permission account has /// at least one of the specified `permission_flags::*` bits set. /// -/// Legacy fallback mapping (used when no Permission account is provided and -/// `FeatureFlag::RequirePermissionAccounts` is not set): +/// Legacy fallback mapping (used whenever `FeatureFlag::RequirePermissionAccounts` +/// is not set — both when no Permission account is provided and when the provided +/// Permission account exists but does not grant the requested flag, so the SDK +/// auto-injecting the payer's Permission PDA can never lock out a legacy key): /// FOUNDATION → foundation_allowlist /// QA → qa_allowlist /// ACTIVATOR → activator_authority_pk @@ -84,6 +87,21 @@ where if foundation_permission_recovery(globalstate, payer_key, any_of_flags) { return Ok(()); } + // While strict mode is off, the legacy allowlists/authorities remain + // authoritative — exactly as in the None branch. Because the SDK + // auto-appends the payer's Permission PDA whenever one exists on-chain, + // a present-but-insufficient Permission account must not lock out a key + // that legacy authorization would still accept (e.g. a foundation member + // who also holds an unrelated, under-privileged Permission account). + // Once RequirePermissionAccounts is enabled, only the Permission account + // (or the foundation PERMISSION_ADMIN recovery above) authorizes. + if !is_feature_enabled( + globalstate.feature_flags, + FeatureFlag::RequirePermissionAccounts, + ) && check_legacy_any(payer_key, globalstate, any_of_flags) + { + return Ok(()); + } return Err(DoubleZeroError::NotAllowed.into()); } } @@ -229,6 +247,64 @@ fn check_legacy_any(payer: &Pubkey, globalstate: &GlobalState, any_of: u128) -> false } +/// Splits the trailing accounts of a variable-length instruction into its +/// `(payer, system_program, leading, permission)` parts. +/// +/// Variable-length instructions (e.g. topology clear / assign-node-segments) +/// place a caller-controlled list of accounts first; the SDK client then +/// appends `payer`, `system_program`, and — when one exists on-chain — the +/// payer's Permission PDA. `remaining` is everything left after the +/// instruction's own fixed leading accounts have been consumed. +/// +/// With a Permission account present the layout is `[leading.., payer, system, +/// permission]`, so the payer sits at `n - 3` and the last account is the +/// Permission account iff it matches that payer's PDA. The returned +/// `permission` is ready to hand to [`authorize`] (via a single-element +/// iterator); `leading` is the caller's variable-length list. +/// +/// Errors when the two mandatory `payer`/`system_program` accounts are absent. +#[allow(clippy::type_complexity)] +pub fn split_trailing_permission<'a, 'r, 'info>( + program_id: &Pubkey, + remaining: &'a [&'r AccountInfo<'info>], +) -> Result< + ( + &'r AccountInfo<'info>, + &'r AccountInfo<'info>, + &'a [&'r AccountInfo<'info>], + Option<&'r AccountInfo<'info>>, + ), + ProgramError, +> { + let n = remaining.len(); + if n < 2 { + msg!("expected at least payer and system_program accounts"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + let permission = if n >= 3 { + let candidate_payer = remaining[n - 3]; + let (perm_pda, _) = get_permission_pda(program_id, candidate_payer.key); + (remaining[n - 1].key == &perm_pda).then_some(remaining[n - 1]) + } else { + None + }; + Ok(if permission.is_some() { + ( + remaining[n - 3], + remaining[n - 2], + &remaining[..n - 3], + permission, + ) + } else { + ( + remaining[n - 2], + remaining[n - 1], + &remaining[..n - 2], + None, + ) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1225,8 +1301,11 @@ mod tests { #[test] fn test_permission_account_foundation_recovery_only_for_permission_admin() { - // Recovery applies only to PERMISSION_ADMIN. A foundation member with a - // suspended Permission account cannot use it to satisfy USER_ADMIN. + // Recovery applies only to PERMISSION_ADMIN. In strict mode a foundation + // member with a suspended Permission account cannot use it to satisfy + // USER_ADMIN. Strict mode is required to isolate the recovery semantics: + // while RequirePermissionAccounts is off the legacy fallback would accept + // the foundation member (see test_permission_account_insufficient_falls_back_to_legacy_when_flag_off). let program_id = Pubkey::new_unique(); let payer = Pubkey::new_unique(); let (pda, _, mut data) = make_permission_data( @@ -1249,7 +1328,8 @@ mod tests { ); let accounts = [account]; let mut iter = accounts.iter(); - let gs = gs_with_foundation(&payer); + let mut gs = gs_with_foundation(&payer); + gs.feature_flags = FeatureFlag::RequirePermissionAccounts.to_mask(); assert_eq!( authorize( @@ -1264,6 +1344,91 @@ mod tests { ); } + #[test] + fn test_permission_account_insufficient_falls_back_to_legacy_when_flag_off() { + // While RequirePermissionAccounts is off, a present-but-insufficient + // Permission account must not lock out a legacy-authorized key. The SDK + // auto-appends the payer's Permission PDA whenever one exists on-chain, so + // a foundation member who also holds an unrelated, under-privileged + // Permission account must still be authorized for legacy-mapped flags. + let program_id = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + // Permission account grants only QA, but the instruction needs TOPOLOGY_ADMIN. + let (pda, _, mut data) = make_permission_data( + &program_id, + &payer, + PermissionStatus::Activated, + permission_flags::QA, + ); + + let mut lamports = 100_000u64; + let account = AccountInfo::new( + &pda, + false, + false, + &mut lamports, + &mut data, + &program_id, + false, + Epoch::default(), + ); + let accounts = [account]; + let mut iter = accounts.iter(); + // Payer is a foundation member; flag is off (default). + let gs = gs_with_foundation(&payer); + + assert!(authorize( + &program_id, + &mut iter, + &payer, + &gs, + permission_flags::TOPOLOGY_ADMIN + ) + .is_ok()); + } + + #[test] + fn test_permission_account_insufficient_denied_when_flag_on() { + // In strict mode the legacy fallback is disabled: the same foundation + // member with a QA-only Permission account is denied TOPOLOGY_ADMIN. + let program_id = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let (pda, _, mut data) = make_permission_data( + &program_id, + &payer, + PermissionStatus::Activated, + permission_flags::QA, + ); + + let mut lamports = 100_000u64; + let account = AccountInfo::new( + &pda, + false, + false, + &mut lamports, + &mut data, + &program_id, + false, + Epoch::default(), + ); + let accounts = [account]; + let mut iter = accounts.iter(); + let mut gs = gs_with_foundation(&payer); + gs.feature_flags = FeatureFlag::RequirePermissionAccounts.to_mask(); + + assert_eq!( + authorize( + &program_id, + &mut iter, + &payer, + &gs, + permission_flags::TOPOLOGY_ADMIN + ) + .unwrap_err(), + DoubleZeroError::NotAllowed.into() + ); + } + // ── New path overrides feature flag enforcement ─────────────────────────── #[test] diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs index 2026ee3452..22f4aacec5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/index/create.rs @@ -1,9 +1,13 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::get_index_pda, seeds::{SEED_INDEX, SEED_PREFIX}, serializer::try_acc_create, - state::{accounttype::AccountType, globalstate::GlobalState, index::Index}, + state::{ + accounttype::AccountType, globalstate::GlobalState, index::Index, + permission::permission_flags, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -126,11 +130,15 @@ pub fn process_create_index( ); assert!(index_account.is_writable, "Index Account is not writable"); - // Check foundation allowlist + // Authorization: INDEX_ADMIN (Permission account) or foundation (legacy). 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::INDEX_ADMIN, + )?; create_index_account( program_id, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs index 36e5cb97cd..3ecc751f12 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/index/delete.rs @@ -1,8 +1,8 @@ use crate::{ - error::DoubleZeroError, + authorize::authorize, processors::validation::validate_program_account, serializer::try_acc_close, - state::{globalstate::GlobalState, index::Index}, + state::{globalstate::GlobalState, index::Index, permission::permission_flags}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -35,6 +35,9 @@ pub fn process_delete_index( let index_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; + // system_program is appended by the transaction builder; consume it so the + // optional trailing Permission account is what authorize() reads next. + let _system_program = next_account_info(accounts_iter)?; #[cfg(test)] msg!("process_delete_index"); @@ -50,11 +53,15 @@ pub fn process_delete_index( "GlobalState" ); - // Check foundation allowlist + // Authorization: INDEX_ADMIN (Permission account) or foundation (legacy). 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::INDEX_ADMIN, + )?; // Verify it's actually an Index account let _index = Index::try_from(index_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs index e90dc46aa0..49922f2ab6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/allocate.rs @@ -1,8 +1,11 @@ use crate::{ - error::DoubleZeroError, + authorize::authorize, pda::get_resource_extension_pda, resource::IdOrIp, - state::{globalstate::GlobalState, resource_extension::ResourceExtensionBorrowed}, + state::{ + globalstate::GlobalState, permission::permission_flags, + resource_extension::ResourceExtensionBorrowed, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -67,10 +70,15 @@ pub fn process_allocate_resource( // Check if the account is writable assert!(resource_account.is_writable, "PDA Account is not writable"); + // Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy). 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::RESOURCE_ADMIN, + )?; match value.resource_type { crate::resource::ResourceType::DzPrefixBlock(ref associated_pk, _) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs index a4086c5af9..2b68aee8b0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/closeaccount.rs @@ -1,4 +1,8 @@ -use crate::{error::DoubleZeroError, serializer::try_acc_close, state::globalstate::GlobalState}; +use crate::{ + authorize::authorize, + serializer::try_acc_close, + state::{globalstate::GlobalState, permission::permission_flags}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; use core::fmt; @@ -51,10 +55,15 @@ pub fn process_closeaccount_resource_extension( assert!(resource_account.is_writable, "PDA Account is not writable"); assert!(owner_account.is_writable, "Owner Account is not writable"); + // Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy). 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::RESOURCE_ADMIN, + )?; try_acc_close(resource_account, owner_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs index 4f372af7ec..edb2086791 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/create.rs @@ -1,4 +1,7 @@ -use crate::{error::DoubleZeroError, state::globalstate::GlobalState}; +use crate::{ + authorize::authorize, + state::{globalstate::GlobalState, permission::permission_flags}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; #[cfg(test)] @@ -65,10 +68,15 @@ pub fn process_create_resource( "Resource Account must be uninitialized" ); + // Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy). 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::RESOURCE_ADMIN, + )?; super::create_resource( program_id, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs index aa701925cf..06a2ee58be 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/deallocate.rs @@ -1,8 +1,12 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::get_resource_extension_pda, resource::{IdOrIp, ResourceType}, - state::{globalstate::GlobalState, resource_extension::ResourceExtensionBorrowed}, + state::{ + globalstate::GlobalState, permission::permission_flags, + resource_extension::ResourceExtensionBorrowed, + }, }; use borsh::{BorshDeserialize, BorshSerialize}; #[cfg(test)] @@ -75,10 +79,15 @@ pub fn process_deallocate_resource( // Check if the account is writable assert!(resource_account.is_writable, "PDA Account is not writable"); + // Authorization: RESOURCE_ADMIN (Permission account) or foundation (legacy). 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::RESOURCE_ADMIN, + )?; let (expected_resource_pda, _, _) = get_resource_extension_pda(program_id, value.resource_type); assert_eq!( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs index b1218c4720..5e214f1014 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs @@ -1,4 +1,5 @@ use crate::{ + authorize::{authorize, split_trailing_permission}, error::DoubleZeroError, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::{resource::allocate_id, validation::validate_program_account}, @@ -6,7 +7,7 @@ use crate::{ serializer::try_acc_write, state::{ device::Device, globalstate::GlobalState, interface::LoopbackType, - topology::FlexAlgoNodeSegment, + permission::permission_flags, topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -35,11 +36,13 @@ pub type TopologyBackfillArgs = AssignTopologyNodeSegmentsArgs; /// [1] segment_routing_ids (writable, ResourceExtension) /// [2] globalstate (readonly) /// [3..n] Device accounts (writable) -/// [n+1] payer (writable, signer, must be in foundation_allowlist) +/// [n+1] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [n+2] system_program +/// [n+3] permission (readonly, optional — payer's Permission PDA) /// -/// Note: payer and system_program are the last two accounts. The SDK client -/// always appends them after the variable-length device list. +/// Note: payer and system_program are the last two accounts (or the last two +/// before the optional Permission account). The SDK client always appends them +/// after the variable-length device list. pub fn process_assign_topology_node_segments( program_id: &Pubkey, accounts: &[AccountInfo], @@ -51,16 +54,12 @@ pub fn process_assign_topology_node_segments( let segment_routing_ids_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Collect remaining accounts. The SDK client always appends payer and - // system_program at the end, after the variable-length device list. + // The SDK client appends payer and system_program after the variable-length + // device list, plus an optional Permission account when one exists for the + // payer. split_trailing_permission peels those off the tail. let all_remaining: Vec<&AccountInfo> = accounts_iter.collect(); - if all_remaining.len() < 2 { - msg!("AssignTopologyNodeSegments: expected at least payer and system_program accounts"); - return Err(DoubleZeroError::InvalidArgument.into()); - } - let payer_account = all_remaining[all_remaining.len() - 2]; - let _system_program = all_remaining[all_remaining.len() - 1]; - let device_accounts = &all_remaining[..all_remaining.len() - 2]; + let (payer_account, _system_program, device_accounts, permission_account) = + split_trailing_permission(program_id, &all_remaining)?; #[cfg(test)] msg!("process_assign_topology_node_segments(name={})", value.name); @@ -114,11 +113,15 @@ pub fn process_assign_topology_node_segments( "GlobalState" ); + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("AssignTopologyNodeSegments: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + &mut permission_account.into_iter(), + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; let topology_key = topology_account.key; let mut backfilled_count: usize = 0; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index f263fab219..adcda80413 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -1,9 +1,12 @@ use crate::{ + authorize::{authorize, split_trailing_permission}, error::DoubleZeroError, pda::{get_globalstate_pda, get_link_pda, get_topology_pda}, processors::validation::validate_program_account, serializer::try_acc_write, - state::{globalstate::GlobalState, link::Link, topology::TopologyInfo}, + state::{ + globalstate::GlobalState, link::Link, permission::permission_flags, topology::TopologyInfo, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -20,12 +23,17 @@ pub struct TopologyClearArgs { } /// Accounts layout: -/// [0] topology PDA (writable when account still exists; readonly is accepted when -/// the topology has already been closed — clear is tolerant of that) -/// [1] globalstate (readonly) -/// [2] payer (writable, signer, must be in foundation_allowlist) -/// [3] system_program -/// [4+] Link accounts (writable) — remove topology pubkey from link_topologies on each +/// [0] topology PDA (writable when account still exists; readonly is accepted when +/// the topology has already been closed — clear is tolerant of that) +/// [1] globalstate (readonly) +/// [2..n] Link accounts (writable) — remove topology pubkey from link_topologies on each +/// [n+1] payer (writable, signer, must hold TOPOLOGY_ADMIN) +/// [n+2] system_program +/// [n+3] permission (readonly, optional — payer's Permission PDA) +/// +/// Note: payer and system_program are the last two accounts (or the last two +/// before the optional Permission account). The SDK client always appends them +/// after the variable-length link list. pub fn process_topology_clear( program_id: &Pubkey, accounts: &[AccountInfo], @@ -35,12 +43,17 @@ pub fn process_topology_clear( let topology_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - let payer_account = next_account_info(accounts_iter)?; - let _system_program = next_account_info(accounts_iter)?; #[cfg(test)] msg!("process_topology_clear(name={})", value.name); + // The SDK client appends payer and system_program after the variable-length + // Link list, plus an optional Permission account when one exists for the + // payer. split_trailing_permission peels those off the tail. + let all_remaining: Vec<&AccountInfo> = accounts_iter.collect(); + let (payer_account, _system_program, link_accounts, permission_account) = + split_trailing_permission(program_id, &all_remaining)?; + // Payer must be a signer if !payer_account.is_signer { msg!("TopologyClear: payer must be a signer"); @@ -56,12 +69,15 @@ pub fn process_topology_clear( "GlobalState" ); - // Authorization: foundation keys only + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyClear: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + &mut permission_account.into_iter(), + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Validate topology PDA. Clear is tolerant of an already-closed topology, // so we cannot call validate_program_account! (it asserts non-empty). If @@ -83,7 +99,7 @@ pub fn process_topology_clear( let mut cleared_count: usize = 0; // Process remaining Link accounts: remove topology key from link_topologies - for link_account in accounts_iter { + for link_account in link_accounts.iter().copied() { validate_program_account!(link_account, program_id, writable = true, "Link"); let mut link = Link::try_from(link_account)?; assert_eq!( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index 382cffd4bd..0dbc7a980c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -1,4 +1,5 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::{resource::allocate_id, validation::validate_program_account}, @@ -8,6 +9,7 @@ use crate::{ state::{ accounttype::AccountType, globalstate::GlobalState, + permission::permission_flags, topology::{validate_topology_name, TopologyConstraint, TopologyInfo}, }, }; @@ -31,8 +33,9 @@ pub struct TopologyCreateArgs { /// [0] topology PDA (writable, to be created) /// [1] admin_group_bits (writable, ResourceExtension) /// [2] globalstate (readonly) -/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [3] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [4] system_program +/// [5] permission (readonly, optional — payer's Permission PDA) pub fn process_topology_create( program_id: &Pubkey, accounts: &[AccountInfo], @@ -56,12 +59,15 @@ pub fn process_topology_create( "GlobalState" ); - // Authorization: foundation keys only + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(&globalstate_account.data.borrow()[..])?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyCreate: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Normalize name to canonical uppercase form and validate format. let name = value.name.to_ascii_uppercase(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index ec76eb837d..65b33750a6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -1,9 +1,10 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, pda::{get_globalstate_pda, get_topology_pda}, processors::validation::validate_program_account, serializer::try_acc_close, - state::{globalstate::GlobalState, topology::TopologyInfo}, + state::{globalstate::GlobalState, permission::permission_flags, topology::TopologyInfo}, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -22,8 +23,9 @@ pub struct TopologyDeleteArgs { /// Accounts layout: /// [0] topology PDA (writable, to be closed) /// [1] globalstate (readonly) -/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [2] payer (writable, signer, must hold TOPOLOGY_ADMIN) /// [3] system_program +/// [4] permission (readonly, optional — payer's Permission PDA) pub fn process_topology_delete( program_id: &Pubkey, accounts: &[AccountInfo], @@ -60,12 +62,15 @@ pub fn process_topology_delete( "GlobalState" ); - // Authorization: foundation keys only + // Authorization: TOPOLOGY_ADMIN (Permission account) or foundation (legacy). let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyDelete: unauthorized — foundation key required"); - return Err(DoubleZeroError::Unauthorized.into()); - } + authorize( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + permission_flags::TOPOLOGY_ADMIN, + )?; // Guard: topology must have no remaining Link references. let topology = TopologyInfo::try_from(topology_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index e8cdd52386..cd543dad2c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -419,6 +419,42 @@ pub fn create_transaction_with_extra_accounts( ) } +/// Builds a transaction whose account list matches what the production SDK +/// client (`assemble_instructions`) emits: the caller's `accounts`, then the +/// payer and system_program, then an optional Permission account — all appended +/// *after* any variable-length accounts the caller put in `accounts`. +/// +/// Use this (rather than [`create_transaction_with_extra_accounts`], which +/// places payer/system *before* the extras) for the variable-length processors +/// that parse payer/system/permission off the tail of the account list, so the +/// test exercises the same on-wire layout the CLI produces. +#[allow(dead_code)] +pub fn create_authorized_transaction( + program_id: Pubkey, + instruction: &DoubleZeroInstruction, + accounts: &[AccountMeta], + payer: &Keypair, + permission: Option, +) -> Transaction { + let mut metas = accounts.to_vec(); + metas.push(AccountMeta::new(payer.pubkey(), true)); + metas.push(AccountMeta::new( + solana_system_interface::program::ID, + false, + )); + if let Some(permission) = permission { + metas.push(permission); + } + Transaction::new_with_payer( + &[Instruction::new_with_bytes( + program_id, + &to_vec(instruction).unwrap(), + metas, + )], + Some(&payer.pubkey()), + ) +} + #[allow(dead_code)] pub async fn get_resource_extension_data( banks_client: &mut BanksClient, diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 81864e6c60..ff73af12a1 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -4,7 +4,7 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, - get_location_pda, get_resource_extension_pda, get_topology_pda, + get_location_pda, get_permission_pda, get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, @@ -13,6 +13,7 @@ use doublezero_serviceability::{ globalstate::setfeatureflags::SetFeatureFlagsArgs, link::{create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, + permission::create::PermissionCreateArgs, topology::{ assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, @@ -25,6 +26,7 @@ use doublezero_serviceability::{ feature_flags::FeatureFlag, interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, link::{Link, LinkDesiredStatus, LinkLinkType}, + permission::permission_flags, topology::{TopologyConstraint, TopologyInfo}, }, }; @@ -315,18 +317,89 @@ async fn test_topology_create_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_create_non_foundation_rejected"); } +/// A non-foundation key holding a TOPOLOGY_ADMIN Permission account can create +/// a topology — exercises the new Permission-account authorization path end to end. +#[tokio::test] +async fn test_topology_create_with_permission_account_allowed() { + println!("[TEST] test_topology_create_with_permission_account_allowed"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // A keypair that is NOT in the foundation allowlist. + let topology_admin = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &topology_admin.pubkey(), + 10_000_000, + ) + .await; + + // Foundation grants the key a Permission account with TOPOLOGY_ADMIN. + let (permission_pda, _) = get_permission_pda(&program_id, &topology_admin.pubkey()); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: topology_admin.pubkey(), + permissions: permission_flags::TOPOLOGY_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // The TOPOLOGY_ADMIN holder creates a topology, passing its Permission PDA + // as the optional trailing account that authorize() reads. + let (topology_pda, _) = get_topology_pda(&program_id, "permissioned-topology"); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "permissioned-topology".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + &vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &topology_admin, + &[AccountMeta::new_readonly(permission_pda, false)], + ); + tx.try_sign(&[&topology_admin], recent_blockhash).unwrap(); + banks_client + .process_transaction(tx) + .await + .expect("TOPOLOGY_ADMIN permission holder should be able to create a topology"); + + let topology = get_topology(&mut banks_client, topology_pda).await; + assert_eq!(topology.name, "PERMISSIONED-TOPOLOGY"); + + println!("[PASS] test_topology_create_with_permission_account_allowed"); +} + #[tokio::test] async fn test_topology_create_name_too_long_rejected() { println!("[TEST] test_topology_create_name_too_long_rejected"); @@ -507,6 +580,10 @@ async fn delete_topology( } /// Creates a clear topology instruction, passing the given link accounts as writable. +/// +/// Uses the production on-wire layout (`[topology, globalstate, links.., payer, +/// system, permission?]`) so the variable-length link list comes before the +/// payer/system_program that the SDK appends — exactly what the CLI emits. async fn clear_topology( banks_client: &mut BanksClient, program_id: Pubkey, @@ -514,21 +591,45 @@ async fn clear_topology( name: &str, link_accounts: Vec, payer: &Keypair, +) { + clear_topology_with_permission( + banks_client, + program_id, + globalstate_pubkey, + name, + link_accounts, + payer, + None, + ) + .await +} + +/// Like [`clear_topology`], but allows passing the payer's Permission PDA as the +/// optional trailing account that `authorize()` reads. +async fn clear_topology_with_permission( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + link_accounts: Vec, + payer: &Keypair, + permission: Option, ) { let (topology_pda, _) = get_topology_pda(&program_id, name); let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let base_accounts = vec![ + let mut accounts = vec![ AccountMeta::new(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), ]; - let mut tx = create_transaction_with_extra_accounts( + accounts.extend(link_accounts); + let mut tx = create_authorized_transaction( program_id, &DoubleZeroInstruction::ClearTopology(TopologyClearArgs { name: name.to_string(), }), - &base_accounts, + &accounts, payer, - &link_accounts, + permission, ); tx.try_sign(&[&payer], recent_blockhash).unwrap(); banks_client.process_transaction(tx).await.unwrap(); @@ -952,12 +1053,13 @@ async fn test_topology_delete_fails_when_link_references_it() { assert!(link.link_topologies.contains(&topology_pda)); // Attempt to delete — should fail because the link still references it + // (the guard reads topology.reference_count; no link account is passed to delete). let result = delete_topology( &mut banks_client, program_id, globalstate_pubkey, "test-topology", - vec![AccountMeta::new_readonly(link_pubkey, false)], + vec![], &payer, ) .await; @@ -1257,6 +1359,109 @@ async fn test_topology_clear_removes_from_links() { println!("[PASS] test_topology_clear_removes_from_links"); } +/// A non-foundation key holding a TOPOLOGY_ADMIN Permission account can clear a +/// topology from a link. Exercises the Permission-account authorization path for +/// `clear` through the production on-wire account layout (links before the +/// trailing payer/system/permission), which is what the CLI/SDK actually emits. +#[tokio::test] +async fn test_topology_clear_with_permission_account_allowed() { + println!("[TEST] test_topology_clear_with_permission_account_allowed"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // unicast-default topology is required for link activation. + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it (foundation payer). + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // A keypair that is NOT in the foundation allowlist, granted TOPOLOGY_ADMIN. + let topology_admin = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &topology_admin.pubkey(), + 10_000_000, + ) + .await; + let (permission_pda, _) = get_permission_pda(&program_id, &topology_admin.pubkey()); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreatePermission(PermissionCreateArgs { + user_payer: topology_admin.pubkey(), + permissions: permission_flags::TOPOLOGY_ADMIN, + }), + vec![ + AccountMeta::new(permission_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // The TOPOLOGY_ADMIN holder clears the topology from the link, passing its + // Permission PDA as the optional trailing account that authorize() reads. + clear_topology_with_permission( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &topology_admin, + Some(AccountMeta::new_readonly(permission_pda, false)), + ) + .await; + + // Verify the link no longer references the topology. + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + link.link_topologies.is_empty(), + "link_topologies should be empty after clear" + ); + + println!("[PASS] test_topology_clear_with_permission_account_allowed"); +} + #[tokio::test] async fn test_topology_clear_is_idempotent() { println!("[TEST] test_topology_clear_is_idempotent"); @@ -1373,13 +1578,13 @@ async fn test_topology_delete_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_delete_non_foundation_rejected"); @@ -1437,13 +1642,13 @@ async fn test_topology_clear_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_clear_non_foundation_rejected"); @@ -1737,13 +1942,13 @@ async fn test_topology_backfill_non_foundation_rejected() { ) .await; - // DoubleZeroError::Unauthorized = Custom(22) + // DoubleZeroError::NotAllowed = Custom(8) match result { Err(BanksClientError::TransactionError(TransactionError::InstructionError( 0, - InstructionError::Custom(22), + InstructionError::Custom(8), ))) => {} - _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), } println!("[PASS] test_topology_backfill_non_foundation_rejected"); diff --git a/smartcontract/sdk/rs/src/commands/index/create.rs b/smartcontract/sdk/rs/src/commands/index/create.rs index 4f71204d07..0250a9cc2f 100644 --- a/smartcontract/sdk/rs/src/commands/index/create.rs +++ b/smartcontract/sdk/rs/src/commands/index/create.rs @@ -32,7 +32,7 @@ impl CreateIndexCommand { ]; client - .execute_transaction( + .execute_authorized_transaction( DoubleZeroInstruction::CreateIndex(IndexCreateArgs { entity_seed: self.entity_seed.clone(), key, diff --git a/smartcontract/sdk/rs/src/commands/index/delete.rs b/smartcontract/sdk/rs/src/commands/index/delete.rs index bea2160a0b..17e2952d5b 100644 --- a/smartcontract/sdk/rs/src/commands/index/delete.rs +++ b/smartcontract/sdk/rs/src/commands/index/delete.rs @@ -20,7 +20,7 @@ impl DeleteIndexCommand { AccountMeta::new_readonly(globalstate_pubkey, false), ]; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeleteIndex(IndexDeleteArgs {}), accounts, ) diff --git a/smartcontract/sdk/rs/src/commands/resource/allocate.rs b/smartcontract/sdk/rs/src/commands/resource/allocate.rs index c5654b0fc9..196cf7fc99 100644 --- a/smartcontract/sdk/rs/src/commands/resource/allocate.rs +++ b/smartcontract/sdk/rs/src/commands/resource/allocate.rs @@ -32,7 +32,7 @@ impl AllocateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::AllocateResource(resource_allocate_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs b/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs index 551df45ae9..7632b7990b 100644 --- a/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/resource/closeaccount.rs @@ -44,7 +44,7 @@ impl CloseResourceByPubkeyCommand { .execute(client) .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::CloseResource(ResourceExtensionCloseAccountArgs {}), vec![ AccountMeta::new(self.pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/create.rs b/smartcontract/sdk/rs/src/commands/resource/create.rs index f636c4a316..229225a7ac 100644 --- a/smartcontract/sdk/rs/src/commands/resource/create.rs +++ b/smartcontract/sdk/rs/src/commands/resource/create.rs @@ -32,7 +32,7 @@ impl CreateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::CreateResource(resource_create_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/resource/deallocate.rs b/smartcontract/sdk/rs/src/commands/resource/deallocate.rs index 7a5b77b6a4..bf70d58766 100644 --- a/smartcontract/sdk/rs/src/commands/resource/deallocate.rs +++ b/smartcontract/sdk/rs/src/commands/resource/deallocate.rs @@ -32,7 +32,7 @@ impl DeallocateResourceCommand { _ => Pubkey::default(), }; - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeallocateResource(resource_deallocate_args), vec![ AccountMeta::new(resource_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs index 41509817df..c5935611e2 100644 --- a/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs +++ b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs @@ -41,7 +41,7 @@ impl AssignTopologyNodeSegmentsCommand { accounts.push(AccountMeta::new(*device_pk, false)); } - let sig = client.execute_transaction( + let sig = client.execute_authorized_transaction( DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: self.name.clone(), }), @@ -97,7 +97,7 @@ mod tests { let device2 = Pubkey::new_unique(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( AssignTopologyNodeSegmentsArgs { @@ -152,7 +152,7 @@ mod tests { expected_accounts.push(AccountMeta::new(*device_pk, false)); } client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs index 264503f6ef..2bc03d4b6d 100644 --- a/smartcontract/sdk/rs/src/commands/topology/clear.rs +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -6,8 +6,9 @@ use doublezero_serviceability::{ use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; /// Max link accounts per clear transaction. Solana caps transactions at 32 -/// accounts; with 3 fixed accounts (topology PDA, globalstate, payer) we -/// stay well under that limit at 16 (same constant as backfill). +/// accounts; with 2 fixed accounts (topology PDA, globalstate) plus the payer +/// and system_program appended by the client, we stay well under that limit at +/// 16 (same constant as backfill). pub const CLEAR_BATCH_SIZE: usize = 16; #[derive(Debug, PartialEq, Clone)] @@ -24,12 +25,11 @@ impl ClearTopologyCommand { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); - let payer = client.get_payer(); - + // payer and system_program are appended by execute_authorized_transaction + // after the variable-length link list, so they are not listed here. let fixed_accounts = [ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), - AccountMeta::new(payer, true), ]; let mut signatures = Vec::new(); @@ -39,7 +39,7 @@ impl ClearTopologyCommand { accounts.push(AccountMeta::new(*link_pk, false)); } - let sig = client.execute_transaction( + let sig = client.execute_authorized_transaction( DoubleZeroInstruction::ClearTopology(TopologyClearArgs { name: self.name.clone(), }), @@ -86,12 +86,11 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); - let payer = client.get_payer(); let link1 = Pubkey::new_unique(); let link2 = Pubkey::new_unique(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { name: "my-topology".to_string(), @@ -99,7 +98,6 @@ mod tests { predicate::eq(vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), - AccountMeta::new(payer, true), AccountMeta::new(link1, false), AccountMeta::new(link2, false), ]), @@ -121,14 +119,12 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); - let payer = client.get_payer(); let links: Vec = (0..33).map(|_| Pubkey::new_unique()).collect(); let fixed_accounts = vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), - AccountMeta::new(payer, true), ]; let expected_args = DoubleZeroInstruction::ClearTopology(TopologyClearArgs { @@ -142,7 +138,7 @@ mod tests { expected_accounts.push(AccountMeta::new(*link_pk, false)); } client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index e2083ed27f..693f61a3c2 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -46,7 +46,7 @@ impl CreateTopologyCommand { ) })?; - let signature = client.execute_transaction( + let signature = client.execute_authorized_transaction( DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { name: self.name.clone(), constraint: self.constraint, @@ -131,7 +131,7 @@ mod tests { .returning(|_| Ok(Account::default())); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { name: "unicast-default".to_string(), @@ -199,7 +199,7 @@ mod tests { let mut seq = Sequence::new(); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( @@ -226,7 +226,7 @@ mod tests { }); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .times(1) .in_sequence(&mut seq) .with( diff --git a/smartcontract/sdk/rs/src/commands/topology/delete.rs b/smartcontract/sdk/rs/src/commands/topology/delete.rs index df5d0ec6e7..a30f552117 100644 --- a/smartcontract/sdk/rs/src/commands/topology/delete.rs +++ b/smartcontract/sdk/rs/src/commands/topology/delete.rs @@ -18,7 +18,7 @@ impl DeleteTopologyCommand { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); - client.execute_transaction( + client.execute_authorized_transaction( DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { name: self.name.clone(), }), @@ -52,7 +52,7 @@ mod tests { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); client - .expect_execute_transaction() + .expect_execute_authorized_transaction() .with( predicate::eq(DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { name: "unicast-default".to_string(),