diff --git a/Cargo.lock b/Cargo.lock index a5041d634..70b695b6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1911,6 +1911,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "doublezero-serviceability-instruction" +version = "0.30.0" +dependencies = [ + "doublezero-serviceability", + "solana-compute-budget-interface", + "solana-program", + "solana-system-interface 3.2.0", +] + [[package]] name = "doublezero-telemetry" version = "0.30.0" diff --git a/Cargo.toml b/Cargo.toml index 1238b7e3d..5bcd23854 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "smartcontract/programs/common", "e2e/docker/ledger/fork-accounts", "crates/doublezero-cli-core", + "crates/doublezero-serviceability-instruction", "crates/doublezero-daemon-cli", "crates/doublezero-geolocation-cli", "crates/sentinel", diff --git a/crates/doublezero-serviceability-instruction/Cargo.toml b/crates/doublezero-serviceability-instruction/Cargo.toml new file mode 100644 index 000000000..9138c6b23 --- /dev/null +++ b/crates/doublezero-serviceability-instruction/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "doublezero-serviceability-instruction" + +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "doublezero_serviceability_instruction" + +# Pure, RPC-free instruction builders for the doublezero-serviceability program +# (RFC-26). Dependencies are deliberately limited to the onchain program crate +# and lightweight instruction/compute-budget helpers — NO solana-client, tokio, +# or backon. This acyclic graph is what enforces purity: the crate cannot reach +# RPC because it never depends on the RPC tree. +[dependencies] +doublezero-serviceability.workspace = true +solana-program.workspace = true +solana-system-interface.workspace = true +solana-compute-budget-interface.workspace = true diff --git a/crates/doublezero-serviceability-instruction/src/common.rs b/crates/doublezero-serviceability-instruction/src/common.rs new file mode 100644 index 000000000..b74a1b70f --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/common.rs @@ -0,0 +1,104 @@ +//! The anti-drift keystone. +//! +//! [`build`] is the single place that appends the trailing accounts every +//! serviceability instruction shares, so the payer/system convention lives in +//! exactly one reviewable location instead of being re-encoded per builder. + +use doublezero_serviceability::instructions::DoubleZeroInstruction; +use solana_compute_budget_interface::ComputeBudgetInstruction; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +/// Protocol-max compute-unit limit for a serviceability transaction. Mirrors +/// `sdk/rs/src/client.rs::MAX_COMPUTE_UNIT_LIMIT`. +pub const MAX_COMPUTE_UNIT_LIMIT: u32 = 1_400_000; +/// Protocol-max heap-frame request (256 KiB). Mirrors +/// `sdk/rs/src/client.rs::MAX_HEAP_FRAME_BYTES`. +pub const MAX_HEAP_FRAME_BYTES: u32 = 256 * 1024; + +/// Assemble a single serviceability [`Instruction`] from its instruction-specific +/// account metas (in processor order, WITHOUT payer/system) plus the shared +/// trailing `[payer, system]`. +/// +/// This is the permanent **no-permission** path: instructions that never route +/// through `authorize()` (e.g. `CreateUser`) call this. Instructions that do +/// route through `authorize()` call [`build_with_permission`] instead. +/// +/// The wire encoding is `1 tag byte + borsh(args)`, obtained for free from +/// [`DoubleZeroInstruction::pack`] — builders never hand-write tag bytes. +pub(crate) fn build( + program_id: &Pubkey, + instruction: DoubleZeroInstruction, + mut accounts: Vec, + payer: &Pubkey, +) -> Instruction { + accounts.push(AccountMeta::new(*payer, true)); + // The system program is marked **writable** for byte-parity with today's + // `client.rs::assemble_instructions` (`AccountMeta::new`); the runtime demotes + // reserved keys, so this is harmless. Byte-parity is deliberate — the golden + // fixtures freeze this flag, and diverging from the SDK would defeat the + // drop-in-replacement goal. The RFC glossary is aligned to match. + accounts.push(AccountMeta::new( + solana_system_interface::program::ID, + false, + )); + + Instruction::new_with_bytes(*program_id, &instruction.pack(), accounts) +} + +/// Assemble an instruction whose processor routes through `authorize()` and will +/// therefore carry a trailing, read-only Permission PDA (derived from the payer, +/// never a caller-supplied argument) once the permission model is rolled out. +/// +/// Builders are **assigned** to this method per-instruction as they are +/// implemented; the append itself is **deferred** (RFC-26 "Permission account"). +/// Today it simply delegates to [`build`], so assigning a builder here is +/// behavior-preserving. At the permission rollout, enable the append **here, in +/// this one place** — it then activates for every builder already assigned to +/// this method at once, while builders on [`build`] stay untouched. +/// +/// The Permission account MUST be appended **last**: `authorize()` reads it as +/// the final account and identifies it by PDA match (`get_permission_pda(payer)`) +/// — so the variable-length / length-detected families tolerate it (the feed / +/// tenant slots sit ahead of the payer and are never confused with it). To +/// activate, assemble here instead of delegating: +/// +/// ```ignore +/// accounts.push(AccountMeta::new(*payer, true)); +/// accounts.push(AccountMeta::new(solana_system_interface::program::ID, false)); +/// let (permission, _) = +/// doublezero_serviceability::pda::get_permission_pda(program_id, payer); +/// accounts.push(AccountMeta::new_readonly(permission, false)); // MUST be last +/// Instruction::new_with_bytes(*program_id, &instruction.pack(), accounts) +/// ``` +/// +/// **Activation precondition.** This append is a pure, offline operation: it +/// cannot check whether the payer's Permission PDA actually exists onchain. When +/// the account does not exist, `authorize()` fails **hard** with +/// `InvalidAccountData` — the program-ownership check runs before the +/// foundation-recovery/legacy fallback (see `authorize.rs`), so a missing +/// Permission account is never routed to the legacy allowlist path. Today's SDK +/// sidesteps this by appending the PDA only after an RPC existence check, which a +/// pure builder cannot replicate. The append MUST therefore stay deferred until +/// every payer is guaranteed a Permission account (e.g. `RequirePermissionAccounts` +/// enforced), or activating it would break every payer without one. +pub(crate) fn build_with_permission( + program_id: &Pubkey, + instruction: DoubleZeroInstruction, + accounts: Vec, + payer: &Pubkey, +) -> Instruction { + build(program_id, instruction, accounts, payer) +} + +/// The transaction-level compute-budget prelude: set the compute-unit limit and +/// request the heap frame. This is NOT inside each builder — the caller prepends +/// it once per transaction over the built instruction(s). +pub fn compute_budget_prelude() -> [Instruction; 2] { + [ + ComputeBudgetInstruction::set_compute_unit_limit(MAX_COMPUTE_UNIT_LIMIT), + ComputeBudgetInstruction::request_heap_frame(MAX_HEAP_FRAME_BYTES), + ] +} diff --git a/crates/doublezero-serviceability-instruction/src/device.rs b/crates/doublezero-serviceability-instruction/src/device.rs new file mode 100644 index 000000000..3abeb437f --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/device.rs @@ -0,0 +1,380 @@ +//! Device-domain instruction builders. + +use crate::common; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_device_pda, get_globalconfig_pda, get_globalstate_pda, get_resource_extension_pda}, + processors::device::{create::DeviceCreateArgs, delete::DeviceDeleteArgs}, + resource::ResourceType, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +/// `CreateDevice` (variant 20). +/// +/// Account layout (processor `next_account_info` order), before the trailing +/// `[payer, system]` appended by `common::build_with_permission`: +/// +/// ```text +/// device (writable) — PDA get_device_pda(device_index) +/// contributor (writable) +/// location (writable) +/// exchange (writable) +/// globalstate (writable) +/// globalconfig (writable) +/// tunnel_ids resource (writable) — ResourceType::TunnelIds(device, 0) +/// dz_prefix_block[i] (writable) — one per args.dz_prefixes entry +/// ``` +/// +/// The writable flags mirror the existing SDK command exactly (e.g. `globalconfig` +/// is only read by the processor but is sent writable there too). Byte-parity is +/// deliberate — the golden fixtures freeze this layout — so the flags are kept as +/// the SDK emits them rather than tightened. +/// +/// The `dz_prefix` blocks and `args.resource_count` are produced from the same +/// loop, so the declared count can never disagree with the account list. +/// +/// `device_index` is the **new** device's index: the caller passes +/// `globalstate.account_index + 1`, not the raw current value. +pub fn create_device( + program_id: &Pubkey, + payer: &Pubkey, + contributor: &Pubkey, + location: &Pubkey, + exchange: &Pubkey, + device_index: u128, + mut args: DeviceCreateArgs, +) -> Instruction { + let (device, _) = get_device_pda(program_id, device_index); + let (globalstate, _) = get_globalstate_pda(program_id); + let (globalconfig, _) = get_globalconfig_pda(program_id); + let (tunnel_ids, _, _) = + get_resource_extension_pda(program_id, ResourceType::TunnelIds(device, 0)); + + let mut accounts = vec![ + AccountMeta::new(device, false), + AccountMeta::new(*contributor, false), + AccountMeta::new(*location, false), + AccountMeta::new(*exchange, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(globalconfig, false), + AccountMeta::new(tunnel_ids, false), + ]; + + let dz_prefix_count = args.dz_prefixes.len(); + for idx in 0..dz_prefix_count { + let (dz_prefix, _, _) = + get_resource_extension_pda(program_id, ResourceType::DzPrefixBlock(device, idx)); + accounts.push(AccountMeta::new(dz_prefix, false)); + } + + // One TunnelIds account plus one DzPrefixBlock per advertised prefix, derived + // from the same loop that produced the accounts above. The count is bounded + // by the transaction's account budget, so overflow is unreachable in practice; + // panicking is strictly better than emitting a `resource_count` that disagrees + // with the account list — the exact invariant this crate exists to protect. + let resource_total = 1 + dz_prefix_count; + args.resource_count = + u8::try_from(resource_total).expect("device resource_count exceeds u8::MAX"); + + common::build_with_permission( + program_id, + DoubleZeroInstruction::CreateDevice(args), + accounts, + payer, + ) +} + +/// How `delete_device` closes the device's resource accounts. Selecting the +/// path explicitly (rather than inferring it from an empty slice) prevents an +/// activated device from silently getting the legacy layout. +pub enum DeviceDeleteResources<'a> { + /// Never-activated device with no live resource accounts. + Legacy, + /// Activated device: atomically close its resource accounts. `owners[i]` is + /// the onchain-read owner of resource `i` (idx 0 = `TunnelIds(device, 0)`, + /// idx 1.. = `DzPrefixBlock(device, idx - 1)`), not offline-derivable. + Atomic { + location: &'a Pubkey, + exchange: &'a Pubkey, + owners: &'a [Pubkey], + device_owner: &'a Pubkey, + }, +} + +/// `DeleteDevice` (variant 26). +/// +/// Two layouts, selected by [`DeviceDeleteResources`]: +/// +/// - [`DeviceDeleteResources::Legacy`]: +/// ```text +/// device (writable) +/// contributor (writable) +/// globalstate (writable) +/// ``` +/// - [`DeviceDeleteResources::Atomic`]: +/// ```text +/// device (writable) +/// contributor (writable) +/// globalstate (writable) +/// location (writable) +/// exchange (writable) +/// resource[i] (writable) — idx 0: TunnelIds(device, 0); +/// idx 1..: DzPrefixBlock(device, idx-1) +/// resource_owner[i] (writable) — the onchain owner of resource[i] +/// device_owner (writable) +/// ``` +/// +/// `owners.len()` drives both the resource-PDA loop and `args.resource_count`. +/// +/// The writable flags mirror the existing SDK command (e.g. `globalstate` is sent +/// writable there too, even though the processor validates it `writable = false`). +/// Byte-parity is deliberate and frozen by the fixtures. +/// +/// `process_delete_device` routes through `authorize()` (NETWORK_ADMIN, for the +/// non-contributor override), so this builder is assigned to +/// `common::build_with_permission` and will carry a trailing Permission PDA once +/// the permission model is activated (deferred today, like every other assigned +/// builder). +pub fn delete_device( + program_id: &Pubkey, + payer: &Pubkey, + device: &Pubkey, + contributor: &Pubkey, + resources: DeviceDeleteResources, +) -> Instruction { + let (globalstate, _) = get_globalstate_pda(program_id); + + let mut accounts = vec![ + AccountMeta::new(*device, false), + AccountMeta::new(*contributor, false), + AccountMeta::new(globalstate, false), + ]; + + let (location, exchange, owners, device_owner) = match resources { + DeviceDeleteResources::Legacy => { + return common::build_with_permission( + program_id, + DoubleZeroInstruction::DeleteDevice(DeviceDeleteArgs::default()), + accounts, + payer, + ); + } + DeviceDeleteResources::Atomic { + location, + exchange, + owners, + device_owner, + } => (location, exchange, owners, device_owner), + }; + + accounts.push(AccountMeta::new(*location, false)); + accounts.push(AccountMeta::new(*exchange, false)); + // Resource PDAs, in the order the processor consumes them. + for idx in 0..owners.len() { + let resource_type = if idx == 0 { + ResourceType::TunnelIds(*device, 0) + } else { + ResourceType::DzPrefixBlock(*device, idx - 1) + }; + let (pda, _, _) = get_resource_extension_pda(program_id, resource_type); + accounts.push(AccountMeta::new(pda, false)); + } + // Then the owner of each resource account, in the same order. + for owner in owners { + accounts.push(AccountMeta::new(*owner, false)); + } + accounts.push(AccountMeta::new(*device_owner, false)); + + // Bounded by the transaction's account budget, so overflow is unreachable; + // panicking is strictly better than emitting a `resource_count` that disagrees + // with the resource account list. + let resource_count = + u8::try_from(owners.len()).expect("device delete resource_count exceeds u8::MAX"); + common::build_with_permission( + program_id, + DoubleZeroInstruction::DeleteDevice(DeviceDeleteArgs { resource_count }), + accounts, + payer, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use doublezero_serviceability::state::device::DeviceType; + use solana_system_interface::program as system_program; + + fn program_id() -> Pubkey { + Pubkey::new_unique() + } + + #[test] + fn test_create_device_accounts_and_tag() { + let pid = program_id(); + let payer = Pubkey::new_unique(); + let contributor = Pubkey::new_unique(); + let location = Pubkey::new_unique(); + let exchange = Pubkey::new_unique(); + + let args = DeviceCreateArgs { + code: "dev1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [10, 0, 0, 1].into(), + dz_prefixes: "10.0.0.0/8".parse().unwrap(), + metrics_publisher_pk: Pubkey::new_unique(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + resource_count: 0, + }; + + let ix = create_device(&pid, &payer, &contributor, &location, &exchange, 1, args); + + // Tag byte for CreateDevice. + assert_eq!(ix.data[0], 20); + assert_eq!(ix.program_id, pid); + + let (device, _) = get_device_pda(&pid, 1); + let (globalstate, _) = get_globalstate_pda(&pid); + let (globalconfig, _) = get_globalconfig_pda(&pid); + let (tunnel_ids, _, _) = + get_resource_extension_pda(&pid, ResourceType::TunnelIds(device, 0)); + let (dz_prefix0, _, _) = + get_resource_extension_pda(&pid, ResourceType::DzPrefixBlock(device, 0)); + + assert_eq!( + ix.accounts, + vec![ + AccountMeta::new(device, false), + AccountMeta::new(contributor, false), + AccountMeta::new(location, false), + AccountMeta::new(exchange, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(globalconfig, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix0, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + } + + #[test] + fn test_create_device_writes_resource_count() { + let pid = program_id(); + let payer = Pubkey::new_unique(); + // Two prefixes -> resource_count = 1 (TunnelIds) + 2 = 3. + let args = DeviceCreateArgs { + code: "dev1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [10, 0, 0, 1].into(), + dz_prefixes: "10.0.0.0/8,11.0.0.0/8".parse().unwrap(), + metrics_publisher_pk: Pubkey::new_unique(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + resource_count: 0, + }; + let ix = create_device( + &pid, + &payer, + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 1, + args, + ); + let decoded = DoubleZeroInstruction::unpack(&ix.data).unwrap(); + match decoded { + DoubleZeroInstruction::CreateDevice(a) => assert_eq!(a.resource_count, 3), + other => panic!("unexpected variant: {other:?}"), + } + // account list: 7 fixed + 2 dz_prefix + payer + system = 11 + assert_eq!(ix.accounts.len(), 11); + } + + #[test] + fn test_delete_device_legacy() { + let pid = program_id(); + let payer = Pubkey::new_unique(); + let device = Pubkey::new_unique(); + let contributor = Pubkey::new_unique(); + + let ix = delete_device( + &pid, + &payer, + &device, + &contributor, + DeviceDeleteResources::Legacy, + ); + + assert_eq!(ix.data[0], 26); + let (globalstate, _) = get_globalstate_pda(&pid); + assert_eq!( + ix.accounts, + vec![ + AccountMeta::new(device, false), + AccountMeta::new(contributor, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + } + + #[test] + fn test_delete_device_atomic() { + let pid = program_id(); + let payer = Pubkey::new_unique(); + let device = Pubkey::new_unique(); + let contributor = Pubkey::new_unique(); + let location = Pubkey::new_unique(); + let exchange = Pubkey::new_unique(); + let res_owner = Pubkey::new_unique(); + let device_owner = Pubkey::new_unique(); + + // One TunnelIds + one DzPrefixBlock -> two resource owners. + let ix = delete_device( + &pid, + &payer, + &device, + &contributor, + DeviceDeleteResources::Atomic { + location: &location, + exchange: &exchange, + owners: &[res_owner, res_owner], + device_owner: &device_owner, + }, + ); + + let decoded = DoubleZeroInstruction::unpack(&ix.data).unwrap(); + match decoded { + DoubleZeroInstruction::DeleteDevice(a) => assert_eq!(a.resource_count, 2), + other => panic!("unexpected variant: {other:?}"), + } + + let (globalstate, _) = get_globalstate_pda(&pid); + let (tunnel_ids, _, _) = + get_resource_extension_pda(&pid, ResourceType::TunnelIds(device, 0)); + let (dz_prefix0, _, _) = + get_resource_extension_pda(&pid, ResourceType::DzPrefixBlock(device, 0)); + assert_eq!( + ix.accounts, + vec![ + AccountMeta::new(device, false), + AccountMeta::new(contributor, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(location, false), + AccountMeta::new(exchange, false), + AccountMeta::new(tunnel_ids, false), + AccountMeta::new(dz_prefix0, false), + AccountMeta::new(res_owner, false), + AccountMeta::new(res_owner, false), + AccountMeta::new(device_owner, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + } +} diff --git a/crates/doublezero-serviceability-instruction/src/lib.rs b/crates/doublezero-serviceability-instruction/src/lib.rs new file mode 100644 index 000000000..9b9d67461 --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/lib.rs @@ -0,0 +1,47 @@ +//! Pure, RPC-free instruction builders for the `doublezero-serviceability` +//! program (RFC-26). +//! +//! Each builder is a pure function `build_xxx(...) -> Instruction` that takes +//! already-resolved arguments and returns a single unsigned +//! [`solana_program::instruction::Instruction`], with no network I/O — the +//! classic SPL pattern (`spl-token::instruction::transfer(...)`). The caller +//! composes the `Transaction`, prepends the [`compute_budget_prelude`], adds the +//! blockhash, and signs. +//! +//! Chain-derived values (globalstate `account_index`, `dz_prefix` counts, +//! RPC-read owner pubkeys) are passed in as explicit parameters; offline-derivable +//! PDAs are derived inside the builder via `doublezero_serviceability::pda`. +//! +//! # Excluded variants +//! +//! The `DoubleZeroInstruction` enum has 116 variants (tags 0–115, contiguous). +//! Builders cover only the buildable variants; the following have **no builder** +//! (no `Err`/panic stubs are emitted — builders are infallible): +//! +//! - **Explicit placeholders** kept only for discriminant stability: +//! `Deprecated95`, `Deprecated96`, `Deprecated102`, `Deprecated103`, +//! `Deprecated111`. +//! - **Deprecated handlers** that return `DoubleZeroError::Deprecated`: +//! `ActivateDevice`, `RejectDevice`, `SuspendDevice`, `ResumeDevice`, +//! `CloseAccountDevice`; the corresponding link lifecycle variants +//! (`ActivateLink`, `RejectLink`, `SuspendLink`, `ResumeLink`, +//! `CloseAccountLink`); the corresponding user lifecycle variants +//! (`ActivateUser`, `RejectUser`, `SuspendUser`, `ResumeUser`, +//! `CloseAccountUser`, `BanUser`); the multicast lifecycle variants +//! (`ActivateMulticastGroup`, `RejectMulticastGroup`, +//! `DeactivateMulticastGroup`); the device-allowlist / user-allowlist toggles +//! (`AddDeviceAllowlist`, `RemoveDeviceAllowlist`, `AddUserAllowlist`, +//! `RemoveUserAllowlist`); and the deprecated `*DeviceInterface` variants +//! (`ActivateDeviceInterface`, `RemoveDeviceInterface`, +//! `UnlinkDeviceInterface`, `RejectDeviceInterface`). +//! +//! Later rollout PRs (RFC-26 R1–R9) add the remaining domains; R0 ships the +//! scaffold plus four exemplar builders that establish the pattern. + +mod common; + +pub mod device; +pub mod link; +pub mod user; + +pub use common::{compute_budget_prelude, MAX_COMPUTE_UNIT_LIMIT, MAX_HEAP_FRAME_BYTES}; diff --git a/crates/doublezero-serviceability-instruction/src/link.rs b/crates/doublezero-serviceability-instruction/src/link.rs new file mode 100644 index 000000000..f695f7d9e --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/link.rs @@ -0,0 +1,123 @@ +//! Link-domain instruction builders. + +use crate::common; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{ + get_globalstate_pda, get_link_pda, get_resource_extension_pda, get_topology_pda, + UNICAST_DEFAULT_TOPOLOGY_NAME, + }, + processors::link::create::LinkCreateArgs, + resource::ResourceType, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +/// `CreateLink` (variant 28). +/// +/// Account layout (processor `next_account_info` order), before the trailing +/// `[payer, system]` appended by `common::build_with_permission`: +/// +/// ```text +/// link (writable) — PDA get_link_pda(link_index) +/// contributor (writable) +/// side_a (writable) +/// side_z (writable) +/// globalstate (writable) +/// unicast_default_topology (writable) — get_topology_pda("unicast-default") +/// device_tunnel_block (writable) — ResourceType::DeviceTunnelBlock +/// link_ids (writable) — ResourceType::LinkIds +/// ``` +pub fn create_link( + program_id: &Pubkey, + payer: &Pubkey, + contributor: &Pubkey, + side_a: &Pubkey, + side_z: &Pubkey, + link_index: u128, + args: LinkCreateArgs, +) -> Instruction { + let (link, _) = get_link_pda(program_id, link_index); + let (globalstate, _) = get_globalstate_pda(program_id); + let (device_tunnel_block, _, _) = + get_resource_extension_pda(program_id, ResourceType::DeviceTunnelBlock); + let (link_ids, _, _) = get_resource_extension_pda(program_id, ResourceType::LinkIds); + let (unicast_default_topology, _) = get_topology_pda(program_id, UNICAST_DEFAULT_TOPOLOGY_NAME); + + let accounts = vec![ + AccountMeta::new(link, false), + AccountMeta::new(*contributor, false), + AccountMeta::new(*side_a, false), + AccountMeta::new(*side_z, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(unicast_default_topology, false), + AccountMeta::new(device_tunnel_block, false), + AccountMeta::new(link_ids, false), + ]; + + common::build_with_permission( + program_id, + DoubleZeroInstruction::CreateLink(args), + accounts, + payer, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use doublezero_serviceability::state::link::LinkLinkType; + use solana_system_interface::program as system_program; + + #[test] + fn test_create_link_accounts_and_tag() { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let contributor = Pubkey::new_unique(); + let side_a = Pubkey::new_unique(); + let side_z = Pubkey::new_unique(); + + let args = LinkCreateArgs { + code: "link1".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 9000, + delay_ns: 1_000_000, + jitter_ns: 100_000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: None, + use_onchain_allocation: true, + }; + + let ix = create_link(&pid, &payer, &contributor, &side_a, &side_z, 1, args); + + assert_eq!(ix.data[0], 28); + assert_eq!(ix.program_id, pid); + + let (link, _) = get_link_pda(&pid, 1); + let (globalstate, _) = get_globalstate_pda(&pid); + let (device_tunnel_block, _, _) = + get_resource_extension_pda(&pid, ResourceType::DeviceTunnelBlock); + let (link_ids, _, _) = get_resource_extension_pda(&pid, ResourceType::LinkIds); + let (unicast_default, _) = get_topology_pda(&pid, UNICAST_DEFAULT_TOPOLOGY_NAME); + + assert_eq!( + ix.accounts, + vec![ + AccountMeta::new(link, false), + AccountMeta::new(contributor, false), + AccountMeta::new(side_a, false), + AccountMeta::new(side_z, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(unicast_default, false), + AccountMeta::new(device_tunnel_block, false), + AccountMeta::new(link_ids, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + } +} diff --git a/crates/doublezero-serviceability-instruction/src/user.rs b/crates/doublezero-serviceability-instruction/src/user.rs new file mode 100644 index 000000000..3b7757dc1 --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/user.rs @@ -0,0 +1,210 @@ +//! User-domain instruction builders. + +use crate::common; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_user_pda}, + processors::user::create_subscribe::UserCreateSubscribeArgs, + resource::ResourceType, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +/// `CreateSubscribeUser` (variant 59). +/// +/// Account layout (processor `next_account_info` order), before the trailing +/// `[payer, system]` appended by `common::build_with_permission`: +/// +/// ```text +/// user (writable) — get_user_pda(client_ip, user_type) +/// device (writable) +/// mgroup (writable) +/// accesspass (writable) +/// globalstate (writable) +/// user_tunnel_block (writable) — ResourceType::UserTunnelBlock +/// multicast_publisher_block (writable) — ResourceType::MulticastPublisherBlock +/// device_tunnel_ids (writable) — ResourceType::TunnelIds(device, 0) +/// dz_prefix_block[i] (writable) — one per dz_prefix_count +/// feed (readonly) — OPTIONAL, appended only when Some +/// ``` +/// +/// `CreateSubscribeUser` is in the **`split_trailing_permission`** family (it +/// routes through `authorize()` for the USER_ADMIN owner-override), NOT the +/// length-detected family. The optional `feed` sits in the leading slice (before +/// `[payer, system]`); a Permission account, once activated, is appended *after* +/// `[payer, system]` and the processor peels it by PDA match — so the two never +/// collide. This builder is therefore assigned to `common::build_with_permission` +/// (permission deferred for now). `dz_prefix_count` is written back into the args +/// so it always matches the number of `dz_prefix_block` accounts produced. +#[allow(clippy::too_many_arguments)] +pub fn create_subscribe_user( + program_id: &Pubkey, + payer: &Pubkey, + device: &Pubkey, + mgroup: &Pubkey, + accesspass: &Pubkey, + dz_prefix_count: u8, + feed: Option<&Pubkey>, + mut args: UserCreateSubscribeArgs, +) -> Instruction { + // The write-back overwrites any caller-set `dz_prefix_count`; assert they agree + // (or the caller left it zero) to catch confusion cheaply in debug builds. + debug_assert!( + args.dz_prefix_count == 0 || args.dz_prefix_count == dz_prefix_count, + "caller-set dz_prefix_count {} disagrees with {dz_prefix_count}", + args.dz_prefix_count + ); + args.dz_prefix_count = dz_prefix_count; + + let (user, _) = get_user_pda(program_id, &args.client_ip, args.user_type); + let (globalstate, _) = get_globalstate_pda(program_id); + let (user_tunnel_block, _, _) = + get_resource_extension_pda(program_id, ResourceType::UserTunnelBlock); + let (multicast_publisher_block, _, _) = + get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + let (device_tunnel_ids, _, _) = + get_resource_extension_pda(program_id, ResourceType::TunnelIds(*device, 0)); + + let mut accounts = vec![ + AccountMeta::new(user, false), + AccountMeta::new(*device, false), + AccountMeta::new(*mgroup, false), + AccountMeta::new(*accesspass, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(user_tunnel_block, false), + AccountMeta::new(multicast_publisher_block, false), + AccountMeta::new(device_tunnel_ids, false), + ]; + + for idx in 0..dz_prefix_count as usize { + let (dz_prefix, _, _) = + get_resource_extension_pda(program_id, ResourceType::DzPrefixBlock(*device, idx)); + accounts.push(AccountMeta::new(dz_prefix, false)); + } + + // Optional trailing Feed account (EdgeSeat metro gate), appended BEFORE + // payer/system. A Permission PDA, once activated, is appended AFTER payer/system + // (not after the feed) and the processor peels it by PDA match, so the feed and + // the Permission account never collide. + if let Some(feed) = feed { + accounts.push(AccountMeta::new_readonly(*feed, false)); + } + + common::build_with_permission( + program_id, + DoubleZeroInstruction::CreateSubscribeUser(args), + accounts, + payer, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use doublezero_serviceability::state::user::{UserCYOA, UserType}; + use solana_system_interface::program as system_program; + use std::net::Ipv4Addr; + + fn base_args(client_ip: Ipv4Addr) -> UserCreateSubscribeArgs { + UserCreateSubscribeArgs { + user_type: UserType::IBRLWithAllocatedIP, + cyoa_type: UserCYOA::GREOverDIA, + client_ip, + publisher: true, + subscriber: false, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 0, + owner: Pubkey::default(), + } + } + + #[test] + fn test_create_subscribe_user_no_feed() { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let device = Pubkey::new_unique(); + let mgroup = Pubkey::new_unique(); + let accesspass = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + let ix = create_subscribe_user( + &pid, + &payer, + &device, + &mgroup, + &accesspass, + 1, + None, + base_args(client_ip), + ); + + assert_eq!(ix.data[0], 59); + // dz_prefix_count is written back into the args. + let decoded = DoubleZeroInstruction::unpack(&ix.data).unwrap(); + match decoded { + DoubleZeroInstruction::CreateSubscribeUser(a) => assert_eq!(a.dz_prefix_count, 1), + other => panic!("unexpected variant: {other:?}"), + } + + let (user, _) = get_user_pda(&pid, &client_ip, UserType::IBRLWithAllocatedIP); + let (globalstate, _) = get_globalstate_pda(&pid); + let (user_tunnel_block, _, _) = + get_resource_extension_pda(&pid, ResourceType::UserTunnelBlock); + let (multicast_publisher_block, _, _) = + get_resource_extension_pda(&pid, ResourceType::MulticastPublisherBlock); + let (device_tunnel_ids, _, _) = + get_resource_extension_pda(&pid, ResourceType::TunnelIds(device, 0)); + let (dz_prefix0, _, _) = + get_resource_extension_pda(&pid, ResourceType::DzPrefixBlock(device, 0)); + + assert_eq!( + ix.accounts, + vec![ + AccountMeta::new(user, false), + AccountMeta::new(device, false), + AccountMeta::new(mgroup, false), + AccountMeta::new(accesspass, false), + AccountMeta::new(globalstate, false), + AccountMeta::new(user_tunnel_block, false), + AccountMeta::new(multicast_publisher_block, false), + AccountMeta::new(device_tunnel_ids, false), + AccountMeta::new(dz_prefix0, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + } + + #[test] + fn test_create_subscribe_user_with_feed_is_readonly_and_last_before_trailing() { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let device = Pubkey::new_unique(); + let feed = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + let ix = create_subscribe_user( + &pid, + &payer, + &device, + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 1, + Some(&feed), + base_args(client_ip), + ); + + // 8 fixed + 1 dz_prefix + feed + payer + system = 12. + assert_eq!(ix.accounts.len(), 12); + // Feed sits right before the payer/system trailing pair, read-only. + let feed_meta = &ix.accounts[ix.accounts.len() - 3]; + assert_eq!(feed_meta.pubkey, feed); + assert!(!feed_meta.is_writable); + assert!(!feed_meta.is_signer); + // Permission append is deferred (build_with_permission delegates to build + // today), so the last account is the system program. + assert_eq!(ix.accounts.last().unwrap().pubkey, system_program::ID); + } +} diff --git a/rfcs/rfc26-rust-instruction-builder-library.md b/rfcs/rfc26-rust-instruction-builder-library.md index 487742fb5..93245bdb7 100644 --- a/rfcs/rfc26-rust-instruction-builder-library.md +++ b/rfcs/rfc26-rust-instruction-builder-library.md @@ -15,7 +15,7 @@ The serviceability program exposes 116 instruction variants. The only supported - **No pure builders.** A caller that wants an unsigned `Instruction` — to batch it, simulate it, inspect it, or sign it with a different signer — cannot get one. It must go through `XxxCommand::execute()`, which owns the RPC client and the send path. - **Heavy dependency to build one instruction.** Any consumer that needs instruction bytes today must depend on `doublezero_sdk`, which pulls `solana-client`, `tokio`, `backon`, and the rest of the RPC tree. Bots, indexers, and the fixture generator pay that cost just to lay out accounts and borsh-pack args. - **Account ordering is duplicated and drift-prone.** The processor (`next_account_info` order) and each command (`AccountMeta` vec) independently encode the same layout; when they disagree the processor rejects the transaction at runtime. The trailing `[payer, system_program, permission?]` convention lives in `client.rs::assemble_instructions`, so every command must line its accounts up with that tail. -- **Dangerous length-detection is implicit.** `CreateUser`, `DeleteUser`, and `CreateSubscribeUser` detect an optional trailing account (tenant/feed) via `accounts.len()`. If a caller also appends a Permission PDA, it corrupts that parsing. Nothing in the current API prevents this. +- **Dangerous length-detection is implicit.** `CreateUser` detects an optional trailing tenant account via `accounts.len()` and never calls `authorize()`. If a caller also appends a Permission PDA, it corrupts that parsing. Nothing in the current API prevents this. (`DeleteUser` detects its optional tenant from onchain state and `CreateSubscribeUser` via `split_trailing_permission`, so both tolerate a trailing Permission account — only `CreateUser` is length-fragile.) The fix is the split SPL uses: a pure, RPC-free instruction library beneath the RPC-bearing SDK, with the account-order convention centralized in one reviewable place and backed by golden fixtures and `solana-program-test` tests. @@ -23,10 +23,10 @@ The fix is the split SPL uses: a pure, RPC-free instruction library beneath the - **Builder** — a pure function `build_xxx(...) -> Instruction` that assembles exactly one serviceability instruction from resolved arguments. Infallible; no RPC. - **Pure / offline** — the builder performs no network I/O. Chain-derived values (globalstate `account_index`, `dz_prefix` count) are passed in as explicit parameters by the caller. -- **Trailing convention** — the fixed tail of every instruction's account list: `[payer (signer, writable), system_program (readonly)]`, followed — for instructions whose `authorize()` migration is activated — by the read-only Permission PDA (derived from the payer) as the last account. +- **Trailing convention** — the fixed tail of every instruction's account list: `[payer (signer, writable), system_program]`, followed — for instructions whose `authorize()` migration is activated — by the read-only Permission PDA (derived from the payer) as the last account. The `system_program` meta is emitted **writable** for byte-parity with today's `client.rs::assemble_instructions` (the runtime demotes reserved keys, so this is harmless); the golden fixtures freeze this flag. - **`authorize()`-gated instruction** — an instruction whose processor calls `authorize()`; once migrated, its builder appends the trailing Permission account (see [Permission account](#permission-account)). -- **Length-detected family** — `CreateUser` (36), `DeleteUser` (42), `CreateSubscribeUser` (59): processors that identify an optional trailing account by `accounts.len()`. These builders never append a Permission account, permanently, so the length count stays unambiguous. -- **`split_trailing_permission` family** — instructions with a variable-length account list followed by payer/system and then, once migrated, the payer-derived Permission PDA, which the processor peels off by PDA match (link delete, user update, interface update, topology assign, multicast allowlists). +- **Length-detected family** — `CreateUser` (36): its processor identifies an optional trailing tenant account by `accounts.len()` and never calls `authorize()`. Its builder never appends a Permission account, permanently, so the length count stays unambiguous. (`DeleteUser` and `CreateSubscribeUser` were originally grouped here but are not length-detected — see below.) +- **`split_trailing_permission` family** — instructions with a variable-length account list followed by payer/system and then, once migrated, the payer-derived Permission PDA, which the processor peels off by PDA match (link delete, user update, `CreateSubscribeUser`, interface update, topology assign, multicast allowlists). `DeleteUser` similarly calls `authorize()` positionally after detecting its optional tenant from onchain state, so it too can carry a trailing Permission account. - **Golden fixture** — a committed `ix_.bin` (wire bytes) and `ix_.json` (variant, `data_hex`, ordered accounts with flags) capturing a builder's deterministic output, guarded in CI. ## Alternatives Considered @@ -73,9 +73,11 @@ crates/doublezero-serviceability-instruction/ ### The anti-drift keystone: `common.rs` -`common::build` is the single place that appends the trailing accounts. Wire encoding is `1 tag byte + borsh(args)`, obtained for free by constructing the `DoubleZeroInstruction` variant and calling `.pack()` (which is `borsh::to_vec`) — no hand-written tag bytes. +`common.rs` is the single place that appends the trailing accounts, exposed as **two methods** so that whether an instruction expects a trailing Permission account is a per-builder assignment rather than a scattered decision. Wire encoding is `1 tag byte + borsh(args)`, obtained for free by constructing the `DoubleZeroInstruction` variant and calling `.pack()` (which is `borsh::to_vec`) — no hand-written tag bytes. ```rust +// The permanent no-permission path: instructions that never call authorize() +// (e.g. CreateUser) are assigned here. pub(crate) fn build( program_id: &Pubkey, instruction: DoubleZeroInstruction, @@ -84,18 +86,26 @@ pub(crate) fn build( payer: &Pubkey, ) -> Instruction { accounts.push(AccountMeta::new(*payer, true)); - accounts.push(AccountMeta::new(solana_system_interface::program::id(), false)); - - // Permission PDA (authorize()) — derived from the payer, not passed in. - // Left commented until the instruction's authorize() migration is activated - // (see "Permission account" below): - // - // let (permission, _) = get_permission_pda(program_id, payer); - // accounts.push(AccountMeta::new_readonly(permission, false)); // must be last + accounts.push(AccountMeta::new(solana_system_interface::program::id(), false)); // writable for byte-parity Instruction::new_with_bytes(*program_id, &instruction.pack(), accounts) } +// Instructions whose processor routes through authorize() are assigned here. +// Today it delegates to `build` (permission append DEFERRED); at rollout the +// append is enabled HERE, in one place, activating every assigned builder at once. +pub(crate) fn build_with_permission( + program_id: &Pubkey, + instruction: DoubleZeroInstruction, + accounts: Vec, + payer: &Pubkey, +) -> Instruction { + build(program_id, instruction, accounts, payer) + // At activation, replace the delegation above with an explicit assembly that, + // after payer/system, appends `get_permission_pda(program_id, payer)` read-only + // as the LAST account. See the "Permission account" activation precondition. +} + // Transaction-level prelude, NOT inside each builder. // CU limit 1_400_000, heap 256 KiB — mirrors client.rs::MAX_COMPUTE_UNIT_LIMIT / // MAX_HEAP_FRAME_BYTES. @@ -108,14 +118,16 @@ This reproduces the payer/system tail that `client.rs::assemble_instructions` bu The Permission PDA is **deterministically derived from the payer** (`get_permission_pda(program_id, payer)`), so it is **never a caller-supplied argument** — no builder takes a `permission: Option`, and no caller can substitute an arbitrary account. Whether an instruction expects the trailing Permission account is not something the offline builder can observe at runtime; it is the per-instruction fact of whether that instruction's `authorize()` migration is activated. -The rollout therefore tracks the program's incremental `authorize()` migration. Initially builders emit no Permission account — exactly what each pre-migration processor expects. As each instruction is migrated, its builder derives and appends the payer's PDA read-only as the last account; those two lines ship **commented out** in `common.rs` (above), enabled per-builder by the activating PR. +The rollout tracks the program's incremental `authorize()` migration via the two-method split above. Each builder is **assigned** at implementation time: `authorize()`-gated instructions to `build_with_permission`, all others to `build`. The append itself is **deferred** — `build_with_permission` delegates to `build` today, so assignment is behaviour-preserving. At the permission rollout the append is enabled **centrally, in `build_with_permission`**, activating every already-assigned builder in one change (builders on `build` stay untouched). This is a single-blast-radius activation, not a per-builder edit. + +**Activation precondition.** The append is pure and offline: it cannot check whether the payer's Permission PDA exists onchain. When the account does not exist, `authorize()` fails **hard** with `InvalidAccountData` (the program-ownership check runs before the foundation-recovery/legacy fallback), so a missing Permission account is never routed to the legacy allowlist path. Today's SDK sidesteps this by appending the PDA only after an RPC existence check — which a pure builder cannot replicate. The central activation MUST therefore wait until every payer is guaranteed a Permission account (e.g. `FeatureFlag::RequirePermissionAccounts` enforced); enabling it earlier would break every payer without one. ### Canonical builder signature The rule for each parameter: - **Offline-derivable PDAs are derived inside the builder** using `pda.rs` helpers (`get_device_pda`, `get_globalconfig_pda`, `get_resource_extension_pda`, etc.); the Permission PDA likewise (see [Permission account](#permission-account)), so none is a parameter. -- **Non-derivable external accounts are passed as `&Pubkey`**: contributor, location, exchange, device, mgroup, accesspass, side_a/side_z, feed, tenant. +- **Non-derivable external accounts are passed as `&Pubkey`**: contributor, location, exchange, device, mgroup, accesspass, side_a/side_z, feed, tenant. Optional accounts follow the same rule as `Option<&Pubkey>` (e.g. `feed`). - **RPC-derived scalars are passed explicitly**: `account_index: u128`, `dz_prefix_count: u8`. - **Returns an infallible `Instruction`.** Infallible normalization that affects the wire (e.g. `code.make_ascii_lowercase()`) may happen in the builder; fallible validation (charset/length) stays in the caller. @@ -124,7 +136,7 @@ The rule for each parameter: pub fn create_device( program_id: &Pubkey, payer: &Pubkey, contributor: &Pubkey, location: &Pubkey, exchange: &Pubkey, - account_index: u128, mut args: DeviceCreateArgs, + device_index: u128, mut args: DeviceCreateArgs, ) -> Instruction; // link.rs — fixed accounts @@ -134,11 +146,11 @@ pub fn create_link( link_index: u128, args: LinkCreateArgs, ) -> Instruction; -// user.rs — length-detected feed appended before payer/system +// user.rs — split_trailing_permission; optional feed appended before payer/system pub fn create_subscribe_user( program_id: &Pubkey, payer: &Pubkey, device: &Pubkey, mgroup: &Pubkey, accesspass: &Pubkey, - dz_prefix_count: u8, feed: Option, + dz_prefix_count: u8, feed: Option<&Pubkey>, args: UserCreateSubscribeArgs, ) -> Instruction; ``` @@ -146,8 +158,8 @@ pub fn create_subscribe_user( ### Variable-account instructions - **`dz_prefix` blocks** (`create_device`, `create_subscribe_user`): the builder loops `0..count` deriving `ResourceType::DzPrefixBlock(entity, idx)` PDAs, then writes the derived count back into the Args `resource_count` field. Count and account list are produced from the same loop, so they can never disagree. -- **Length-detected optional trailing** (tenant on user create/delete, feed on subscribe): the optional account is appended conditionally **before** payer/system. Since no builder appends a Permission account here — permanently, for this family — the hazard of a Permission PDA corrupting `accounts.len()` detection cannot arise; a test pins the account count. -- **`split_trailing_permission` family** (link delete, user update, interface update, topology assign, multicast allowlists): variable list, then payer/system, then — once migrated — the payer-derived Permission PDA last (the same commented-until-activated append; safe because the processor peels it by PDA match). +- **Length-detected optional trailing** (`CreateUser` only — tenant): the optional account is appended conditionally **before** payer/system. Because `CreateUser` never calls `authorize()`, its builder never appends a Permission account — permanently — so the hazard of a Permission PDA corrupting `accounts.len()` detection cannot arise; a test pins the account count. (`DeleteUser` detects its tenant from onchain state and `CreateSubscribeUser` uses `split_trailing_permission`, so neither is length-fragile.) +- **`split_trailing_permission` family** (link delete, user update, `CreateSubscribeUser`, interface update, topology assign, multicast allowlists; and `DeleteUser`): variable list, then payer/system, then — once migrated — the payer-derived Permission PDA last (the same deferred, activate-in-one-place append; safe because the processor peels it by PDA match, so the optional feed/tenant that sits before payer/system is never confused with it). - **Batched instructions** (`clear_topology`, `assign_topology_node_segments`): a single-chunk builder plus a `*_batched(...) -> Vec` convenience. The batch-size consts move into this crate; the 32-account cap math accounts for the trailing accounts the builder now owns. ### Coverage and excluded variants @@ -155,7 +167,7 @@ pub fn create_subscribe_user( The enum has 116 variants (tags 0–115, contiguous). Builders cover all buildable variants (~94). Excluded, documented in `lib.rs`, are: - **Explicit placeholders** kept only for discriminant stability: `Deprecated95`, `Deprecated96`, `Deprecated102`, `Deprecated103`, `Deprecated111`. -- **Deprecated handlers** that return `DoubleZeroError::Deprecated` (e.g. `ActivateDevice`, `RejectDevice`, `CloseAccountDevice`, the corresponding link/user/multicast lifecycle variants, and several `*DeviceInterface` variants). +- **Deprecated handlers** that return `DoubleZeroError::Deprecated`: the device lifecycle variants `ActivateDevice`, `RejectDevice`, `SuspendDevice`, `ResumeDevice`, `CloseAccountDevice`, and the corresponding link/user/multicast lifecycle variants (`Suspend*`/`Resume*` for device/link/user are all deprecated — suspend/resume now go through `UpdateDevice`/`UpdateLink`/`UpdateUser` with `desired_status`), plus several `*DeviceInterface` variants. Because `suspend_device` is deprecated, the R0 exemplar set uses `delete_device` in its place (a variable-account builder with RPC-read owner accounts and a legacy/atomic split). No `Err`/panic stubs are emitted — builders are infallible; excluded variants simply have no builder, with the exclusion list documented. @@ -186,10 +198,10 @@ Respecting the ~500-lines-of-new-code-per-PR norm (tests excluded): | PR | Scope | |----|-------| -| R0 | Scaffold crate + `common` + `compute_budget_prelude` + 4 exemplar builders (`create_device`, `create_link`, `suspend_device`, `create_subscribe_user`) + first fixtures | +| R0 | Scaffold crate + `common` + `compute_budget_prelude` + 4 exemplar builders (`create_device`, `create_link`, `delete_device`, `create_subscribe_user`) with unit tests (tag bytes + account layout). Golden fixtures and `solana-program-test` coverage land in a follow-up PR alongside the fixture generator | | R1 | `device` domain builders | | R2 | `link` domain builders | -| R3 | `user` domain builders (length-detected family) | +| R3 | `user` domain builders (length-detected `CreateUser` + split_trailing_permission `CreateSubscribeUser`) | | R4 | `location` + `exchange` + `contributor` builders | | R5 | `multicastgroup` builders (+ pub/sub allowlists) | | R6 | `tenant` + `permission` builders | @@ -212,7 +224,7 @@ Respecting the ~500-lines-of-new-code-per-PR norm (tests excluded): - **Account-order drift (biggest risk).** A builder whose account order diverges from its processor produces transactions the processor rejects — and, worse, a subtly wrong order could target the wrong account. Mitigations: `common::build` centralizes the trailing convention; verbatim doc-comments; and the `solana-program-test` suite runs every buildable variant against the real program so a wrong order fails a test. - **Trailing-convention regression.** With the layout owned in two layers (each command's account vec and `assemble_instructions`), the two could drift. After migration there is exactly one place (`common::build`) that appends payer/system; `assemble_instructions` no longer does. -- **Permission account.** The Permission PDA is derived from the payer inside the builder, never passed in, so a caller cannot substitute an arbitrary account to spoof the trailing slot. For the length-detected family (`CreateUser`/`DeleteUser`/`CreateSubscribeUser`) it is never appended — appending one would corrupt the `accounts.len()` detection — and a test pins the account count. +- **Permission account.** The Permission PDA is derived from the payer inside the builder, never passed in, so a caller cannot substitute an arbitrary account to spoof the trailing slot. The append is centralized in `common::build_with_permission` (deferred; enabled in one place at rollout); builders that never route through `authorize()` call `common::build` and can never carry one. For `CreateUser` (the length-detected family) it is never appended — appending one would corrupt the `accounts.len()` detection — and a test pins the account count. - **Count/account mismatch.** For `dz_prefix` blocks, the builder derives the count from the same loop that produces the accounts and writes it back into the Args, so the declared count can never disagree with the account list. - **Discriminant coupling.** Builders never hand-write tag bytes; they construct the `DoubleZeroInstruction` variant and call `.pack()`, getting the correct tag and borsh encoding for free. - **No new trust boundary.** The crate is host-side and RPC-free; it introduces no new signing authority and no on-chain code. diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 14ee97aed..2009e6ad0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -113,6 +113,11 @@ pub fn get_feed_pda(program_id: &Pubkey, code: &str, exchange: &Pubkey) -> (Pubk ) } +/// Name of the default unicast topology that `CreateLink` auto-tags every link +/// into. This is the canonical spelling; consumers should reference this constant +/// rather than re-typing the literal. +pub const UNICAST_DEFAULT_TOPOLOGY_NAME: &str = "unicast-default"; + pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { let upper = name.to_ascii_uppercase(); Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, upper.as_bytes()], program_id) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index 75086e71c..4d7b03821 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -1,7 +1,7 @@ use crate::{ authorize::authorize, error::DoubleZeroError, - pda::{get_link_pda, get_topology_pda}, + pda::{get_link_pda, get_topology_pda, UNICAST_DEFAULT_TOPOLOGY_NAME}, processors::validation::validate_program_account, seeds::{SEED_LINK, SEED_PREFIX}, serializer::{try_acc_create, try_acc_write}, @@ -251,7 +251,8 @@ pub fn process_create_link( // Always validate the PDA derivation to prevent callers passing a wrong account. // Tagging is conditional: if the topology hasn't been created yet (e.g. fresh deployment), // creation proceeds without the tag rather than failing. - let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); + let (expected_unicast_default_pda, _) = + get_topology_pda(program_id, UNICAST_DEFAULT_TOPOLOGY_NAME); if unicast_default_topology_account.key != &expected_unicast_default_pda { return Err(DoubleZeroError::InvalidArgument.into()); }