From 38d68b138b442849a1cc012656d99347a1a1726f Mon Sep 17 00:00:00 2001 From: nikw9944 Date: Mon, 23 Feb 2026 22:32:41 +0000 Subject: [PATCH 1/3] geolocation: add GeoProbe state and CRUD instructions Add GeoProbe account type with CreateGeoProbe, UpdateGeoProbe, and DeleteGeoProbe instructions. Includes IP validation (rejects all non-publicly-routable addresses), code length validation, exchange activation checks, and reference count protection on delete. Reorder GeoProbe struct fields to place variable-length fields (code, parent_devices) at the end for correct Borsh deserialization. Update RFC16 to match and remove latency_threshold_ns. Part 2 of 3 for RFC16 geolocation verification. --- CHANGELOG.md | 2 +- rfcs/rfc16-geolocation-verification.md | 1 + smartcontract/programs/CLAUDE.md | 8 +- .../doublezero-geolocation/src/entrypoint.rs | 17 +- .../doublezero-geolocation/src/error.rs | 22 ++ .../src/instructions.rs | 51 ++- .../doublezero-geolocation/src/lib.rs | 1 + .../doublezero-geolocation/src/pda.rs | 63 +++- .../src/processors/geo_probe/create.rs | 115 +++++++ .../src/processors/geo_probe/delete.rs | 55 ++++ .../src/processors/geo_probe/mod.rs | 3 + .../src/processors/geo_probe/update.rs | 72 +++++ .../src/processors/mod.rs | 1 + .../doublezero-geolocation/src/seeds.rs | 1 + .../doublezero-geolocation/src/serializer.rs | 23 +- .../src/state/accounttype.rs | 3 + .../src/state/geo_probe.rs | 221 +++++++++++++ .../doublezero-geolocation/src/state/mod.rs | 1 + .../doublezero-geolocation/src/validation.rs | 299 ++++++++++++++++++ 19 files changed, 939 insertions(+), 20 deletions(-) create mode 100644 smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/create.rs create mode 100644 smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/delete.rs create mode 100644 smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/mod.rs create mode 100644 smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs create mode 100644 smartcontract/programs/doublezero-geolocation/src/state/geo_probe.rs create mode 100644 smartcontract/programs/doublezero-geolocation/src/validation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dcaa91071..abf3f0b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ All notable changes to this project will be documented in this file. - Serviceability: add feature flags support - Serviceability: expand `is_global` to reject all BGP martian address ranges (CGNAT 100.64/10, IETF 192.0.0/24, benchmarking 198.18/15, multicast 224/4, reserved 240/4, 0/8) - Serviceability: allow update and deletion of interfaces even when sibling interfaces have invalid CYOA configuration - - Geolocation: add `doublezero-geolocation` program scaffolding as per rfcs/rfc16-geolocation-verification.md + - Geolocation: add `doublezero-geolocation` program scaffolding and GeoProbe account type and related instructions as per rfcs/rfc16-geolocation-verification.md - SDK - SetFeatureFlagCommand added to manage on-chain feature flags for conditional behavior rollouts - Dependencies diff --git a/rfcs/rfc16-geolocation-verification.md b/rfcs/rfc16-geolocation-verification.md index a659e32d5..d8501dd11 100644 --- a/rfcs/rfc16-geolocation-verification.md +++ b/rfcs/rfc16-geolocation-verification.md @@ -196,6 +196,7 @@ pub struct GeoProbe { pub location_offset_port: u16, // UDP listen port (default 8923) pub metrics_publisher_pk: Pubkey, // Signing key for telemetry pub reference_count: u32, // GeolocationTargets referencing this probe + // Variable-length fields must be at the end for Borsh deserialization pub code: String, // e.g., "ams-probe-01" (max 32 bytes) pub parent_devices: Vec, // DZDs that measure this probe } diff --git a/smartcontract/programs/CLAUDE.md b/smartcontract/programs/CLAUDE.md index a48f151a4..db78382bd 100644 --- a/smartcontract/programs/CLAUDE.md +++ b/smartcontract/programs/CLAUDE.md @@ -2,7 +2,7 @@ ### Security -1. **PDA ownership verification**: Always verify the owner of PDA accounts (both internal PDAs and those from other programs like serviceability) to prevent being tricked into reading an account owned by another program. For serviceability accounts, verify the owner is the serviceability program ID. For your own PDAs, verify the owner is `program_id`. +1. **PDA ownership verification**: Always verify the owner of PDA accounts (both internal PDAs and those from other programs like serviceability) to prevent being tricked into reading an account owned by another program. For serviceability accounts, verify the owner is the serviceability program ID. For your own PDAs, verify the owner is `program_id`. Exception: for singleton PDAs (e.g., ProgramConfig, GlobalState) the account type discriminator check via `try_from` is sufficient — the ownership check is harmless but redundant since there is only one valid account. 2. **System program validation**: Checks for the system program are unnecessary because the system interface builds instructions using the system program as the program ID. If the wrong program is provided, you'll get a revert automatically. @@ -28,6 +28,12 @@ 2. **Use BorshDeserializeIncremental**: For instruction arguments that may gain new optional fields over time, use `BorshDeserializeIncremental` or derive `BorshDeserialize`. +### Testing + +1. **Assert specific errors**: Tests should assert specific error types (e.g., `ProgramError::Custom(17)`), not just `.is_err()`. This catches regressions where the instruction fails for the wrong reason. + +2. **Don't test framework functionality**: Avoid writing tests that only exercise SDK/framework behavior (e.g., testing that `Pubkey::find_program_address` is deterministic or produces different outputs for different inputs). Focus tests on your program's logic. + ### Program Upgrades 1. **Use standard interfaces**: Use `solana-loader-v3-interface` to parse `UpgradeableLoaderState` rather than implementing your own parser. The interface crate provides well-tested, maintained implementations. diff --git a/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs b/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs index d974f1950..5f46f345d 100644 --- a/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs @@ -1,7 +1,13 @@ use crate::{ instructions::GeolocationInstruction, - processors::program_config::{ - init::process_init_program_config, update::process_update_program_config, + processors::{ + geo_probe::{ + create::process_create_geo_probe, delete::process_delete_geo_probe, + update::process_update_geo_probe, + }, + program_config::{ + init::process_init_program_config, update::process_update_program_config, + }, }, }; @@ -30,6 +36,13 @@ pub fn process_instruction( GeolocationInstruction::UpdateProgramConfig(args) => { process_update_program_config(program_id, accounts, &args)? } + GeolocationInstruction::CreateGeoProbe(args) => { + process_create_geo_probe(program_id, accounts, &args)? + } + GeolocationInstruction::UpdateGeoProbe(args) => { + process_update_geo_probe(program_id, accounts, &args)? + } + GeolocationInstruction::DeleteGeoProbe => process_delete_geo_probe(program_id, accounts)?, }; Ok(()) diff --git a/smartcontract/programs/doublezero-geolocation/src/error.rs b/smartcontract/programs/doublezero-geolocation/src/error.rs index fd9ebdcbb..8f68cd491 100644 --- a/smartcontract/programs/doublezero-geolocation/src/error.rs +++ b/smartcontract/programs/doublezero-geolocation/src/error.rs @@ -8,6 +8,18 @@ pub enum GeolocationError { InvalidAccountType = 1, #[error("Not allowed")] NotAllowed = 2, + #[error("Invalid code length (max 32 bytes)")] + InvalidCodeLength = 4, + #[error("Invalid IP address: not publicly routable")] + InvalidIpAddress = 5, + #[error("Maximum parent devices reached")] + MaxParentDevicesReached = 6, + #[error("Invalid serviceability program ID")] + InvalidServiceabilityProgramId = 11, + #[error("Invalid account code")] + InvalidAccountCode = 12, + #[error("Reference count is not zero")] + ReferenceCountNotZero = 15, #[error("Unauthorized: payer is not the upgrade authority")] UnauthorizedInitializer = 17, #[error("min_compatible_version cannot exceed version")] @@ -32,6 +44,12 @@ mod tests { vec![ (GeolocationError::InvalidAccountType, 1), (GeolocationError::NotAllowed, 2), + (GeolocationError::InvalidCodeLength, 4), + (GeolocationError::InvalidIpAddress, 5), + (GeolocationError::MaxParentDevicesReached, 6), + (GeolocationError::InvalidServiceabilityProgramId, 11), + (GeolocationError::InvalidAccountCode, 12), + (GeolocationError::ReferenceCountNotZero, 15), (GeolocationError::UnauthorizedInitializer, 17), (GeolocationError::InvalidMinCompatibleVersion, 18), ] @@ -59,5 +77,9 @@ mod tests { "Invalid account type" ); assert_eq!(GeolocationError::NotAllowed.to_string(), "Not allowed"); + assert_eq!( + GeolocationError::InvalidIpAddress.to_string(), + "Invalid IP address: not publicly routable" + ); } } diff --git a/smartcontract/programs/doublezero-geolocation/src/instructions.rs b/smartcontract/programs/doublezero-geolocation/src/instructions.rs index 027cd0d63..8498b899c 100644 --- a/smartcontract/programs/doublezero-geolocation/src/instructions.rs +++ b/smartcontract/programs/doublezero-geolocation/src/instructions.rs @@ -1,37 +1,60 @@ use borsh::{BorshDeserialize, BorshSerialize}; -pub use crate::processors::program_config::{ - init::InitProgramConfigArgs, update::UpdateProgramConfigArgs, +pub use crate::processors::{ + geo_probe::{create::CreateGeoProbeArgs, update::UpdateGeoProbeArgs}, + program_config::{init::InitProgramConfigArgs, update::UpdateProgramConfigArgs}, }; #[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] pub enum GeolocationInstruction { InitProgramConfig(InitProgramConfigArgs), UpdateProgramConfig(UpdateProgramConfigArgs), + CreateGeoProbe(CreateGeoProbeArgs), + UpdateGeoProbe(UpdateGeoProbeArgs), + DeleteGeoProbe, } #[cfg(test)] mod tests { use super::*; + use solana_program::pubkey::Pubkey; + use std::net::Ipv4Addr; + + fn test_instruction(instruction: GeolocationInstruction) { + let data = borsh::to_vec(&instruction).unwrap(); + let decoded: GeolocationInstruction = borsh::from_slice(&data).unwrap(); + assert_eq!(instruction, decoded, "Instruction mismatch"); + } #[test] fn test_roundtrip_all_instructions() { - let cases = vec![ - GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), - GeolocationInstruction::UpdateProgramConfig(UpdateProgramConfigArgs { + test_instruction(GeolocationInstruction::InitProgramConfig( + InitProgramConfigArgs {}, + )); + test_instruction(GeolocationInstruction::UpdateProgramConfig( + UpdateProgramConfigArgs { version: Some(2), min_compatible_version: Some(1), - }), - GeolocationInstruction::UpdateProgramConfig(UpdateProgramConfigArgs { + }, + )); + test_instruction(GeolocationInstruction::UpdateProgramConfig( + UpdateProgramConfigArgs { version: None, min_compatible_version: None, - }), - ]; - for instruction in cases { - let data = borsh::to_vec(&instruction).unwrap(); - let decoded: GeolocationInstruction = borsh::from_slice(&data).unwrap(); - assert_eq!(instruction, decoded); - } + }, + )); + test_instruction(GeolocationInstruction::CreateGeoProbe(CreateGeoProbeArgs { + code: "test-probe".to_string(), + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + metrics_publisher_pk: Pubkey::new_unique(), + })); + test_instruction(GeolocationInstruction::UpdateGeoProbe(UpdateGeoProbeArgs { + public_ip: Some(Ipv4Addr::new(1, 1, 1, 1)), + location_offset_port: Some(9999), + metrics_publisher_pk: None, + })); + test_instruction(GeolocationInstruction::DeleteGeoProbe); } #[test] diff --git a/smartcontract/programs/doublezero-geolocation/src/lib.rs b/smartcontract/programs/doublezero-geolocation/src/lib.rs index 3252a53bf..828a775be 100644 --- a/smartcontract/programs/doublezero-geolocation/src/lib.rs +++ b/smartcontract/programs/doublezero-geolocation/src/lib.rs @@ -10,6 +10,7 @@ pub mod processors; pub mod seeds; mod serializer; pub mod state; +pub mod validation; use solana_program::pubkey::Pubkey; diff --git a/smartcontract/programs/doublezero-geolocation/src/pda.rs b/smartcontract/programs/doublezero-geolocation/src/pda.rs index 17a90b45d..b219f3e2b 100644 --- a/smartcontract/programs/doublezero-geolocation/src/pda.rs +++ b/smartcontract/programs/doublezero-geolocation/src/pda.rs @@ -1,7 +1,68 @@ use solana_program::pubkey::Pubkey; -use crate::seeds::{SEED_PREFIX, SEED_PROGRAM_CONFIG}; +use crate::seeds::{SEED_PREFIX, SEED_PROBE, SEED_PROGRAM_CONFIG}; pub fn get_program_config_pda(program_id: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[SEED_PREFIX, SEED_PROGRAM_CONFIG], program_id) } + +pub fn get_geo_probe_pda(program_id: &Pubkey, code: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_PROBE, code.as_bytes()], program_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_program_id() -> Pubkey { + Pubkey::new_unique() + } + + #[test] + fn test_program_config_pda_is_deterministic() { + let program_id = test_program_id(); + let (pda1, bump1) = get_program_config_pda(&program_id); + let (pda2, bump2) = get_program_config_pda(&program_id); + assert_eq!(pda1, pda2); + assert_eq!(bump1, bump2); + } + + #[test] + fn test_program_config_pda_differs_by_program_id() { + let (pda1, _) = get_program_config_pda(&Pubkey::new_unique()); + let (pda2, _) = get_program_config_pda(&Pubkey::new_unique()); + assert_ne!(pda1, pda2); + } + + #[test] + fn test_geo_probe_pda_is_deterministic() { + let program_id = test_program_id(); + let (pda1, bump1) = get_geo_probe_pda(&program_id, "probe-a"); + let (pda2, bump2) = get_geo_probe_pda(&program_id, "probe-a"); + assert_eq!(pda1, pda2); + assert_eq!(bump1, bump2); + } + + #[test] + fn test_geo_probe_pda_differs_by_code() { + let program_id = test_program_id(); + let (pda1, _) = get_geo_probe_pda(&program_id, "probe-a"); + let (pda2, _) = get_geo_probe_pda(&program_id, "probe-b"); + assert_ne!(pda1, pda2); + } + + #[test] + fn test_geo_probe_pda_differs_by_program_id() { + let (pda1, _) = get_geo_probe_pda(&Pubkey::new_unique(), "probe-a"); + let (pda2, _) = get_geo_probe_pda(&Pubkey::new_unique(), "probe-a"); + assert_ne!(pda1, pda2); + } + + #[test] + fn test_different_pda_types_do_not_collide() { + let program_id = test_program_id(); + let (config_pda, _) = get_program_config_pda(&program_id); + let (probe_pda, _) = get_geo_probe_pda(&program_id, "code"); + assert_ne!(config_pda, probe_pda); + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/create.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/create.rs new file mode 100644 index 000000000..931ca7f81 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/create.rs @@ -0,0 +1,115 @@ +use crate::{ + error::GeolocationError, + pda::get_geo_probe_pda, + processors::check_foundation_allowlist, + seeds::{SEED_PREFIX, SEED_PROBE}, + serializer::try_acc_create, + state::{accounttype::AccountType, geo_probe::GeoProbe}, + validation::{validate_code_length, validate_public_ip}, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use doublezero_program_common::validate_account_code; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use std::net::Ipv4Addr; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub struct CreateGeoProbeArgs { + pub code: String, + pub public_ip: Ipv4Addr, + pub location_offset_port: u16, + pub metrics_publisher_pk: Pubkey, +} + +pub fn process_create_geo_probe( + program_id: &Pubkey, + accounts: &[AccountInfo], + args: &CreateGeoProbeArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let probe_account = next_account_info(accounts_iter)?; + let exchange_account = next_account_info(accounts_iter)?; + let program_config_account = next_account_info(accounts_iter)?; + let serviceability_globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; + + if !payer_account.is_signer { + msg!("Payer must be a signer"); + return Err(ProgramError::MissingRequiredSignature); + } + + check_foundation_allowlist( + program_config_account, + serviceability_globalstate_account, + payer_account, + program_id, + )?; + + // Validate exchange_account belongs to the Serviceability program + let serviceability_program_id = crate::serviceability_program_id(); + if *exchange_account.owner != serviceability_program_id { + msg!( + "Exchange account owner {} does not match serviceability program {}", + exchange_account.owner, + serviceability_program_id + ); + return Err(GeolocationError::InvalidServiceabilityProgramId.into()); + } + + // Verify it's a valid, activated Exchange account + let exchange = + doublezero_serviceability::state::exchange::Exchange::try_from(exchange_account)?; + if exchange.status != doublezero_serviceability::state::exchange::ExchangeStatus::Activated { + msg!( + "Exchange {} is not activated (status: {:?})", + exchange_account.key, + exchange.status + ); + return Err(ProgramError::InvalidAccountData); + } + + validate_code_length(&args.code)?; + let code = validate_account_code(&args.code) + .map_err(|_| crate::error::GeolocationError::InvalidAccountCode)?; + validate_public_ip(&args.public_ip)?; + + let (expected_pda, bump_seed) = get_geo_probe_pda(program_id, &code); + if probe_account.key != &expected_pda { + msg!("Invalid GeoProbe PubKey"); + return Err(ProgramError::InvalidSeeds); + } + + if !probe_account.data_is_empty() { + return Err(ProgramError::AccountAlreadyInitialized); + } + + let probe = GeoProbe { + account_type: AccountType::GeoProbe, + owner: *payer_account.key, + exchange_pk: *exchange_account.key, + public_ip: args.public_ip, + location_offset_port: args.location_offset_port, + metrics_publisher_pk: args.metrics_publisher_pk, + reference_count: 0, + code, + parent_devices: vec![], + }; + + try_acc_create( + &probe, + probe_account, + payer_account, + system_program, + program_id, + &[SEED_PREFIX, SEED_PROBE, probe.code.as_bytes(), &[bump_seed]], + )?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/delete.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/delete.rs new file mode 100644 index 000000000..d078bff93 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/delete.rs @@ -0,0 +1,55 @@ +use crate::{ + error::GeolocationError, processors::check_foundation_allowlist, serializer::try_acc_close, + state::geo_probe::GeoProbe, +}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +pub fn process_delete_geo_probe(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let probe_account = next_account_info(accounts_iter)?; + let program_config_account = next_account_info(accounts_iter)?; + let serviceability_globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + if !payer_account.is_signer { + msg!("Payer must be a signer"); + return Err(ProgramError::MissingRequiredSignature); + } + + check_foundation_allowlist( + program_config_account, + serviceability_globalstate_account, + payer_account, + program_id, + )?; + + if probe_account.owner != program_id { + msg!("Invalid GeoProbe Account Owner"); + return Err(ProgramError::IllegalOwner); + } + if !probe_account.is_writable { + msg!("GeoProbe account must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + let probe = GeoProbe::try_from(probe_account)?; + + if probe.reference_count > 0 { + msg!( + "Cannot delete GeoProbe. reference_count of {} > 0", + probe.reference_count + ); + return Err(GeolocationError::ReferenceCountNotZero.into()); + } + + try_acc_close(probe_account, payer_account)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/mod.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/mod.rs new file mode 100644 index 000000000..fdb2f5561 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/mod.rs @@ -0,0 +1,3 @@ +pub mod create; +pub mod delete; +pub mod update; diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs new file mode 100644 index 000000000..fcbcc1698 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs @@ -0,0 +1,72 @@ +use crate::{ + processors::check_foundation_allowlist, serializer::try_acc_write, state::geo_probe::GeoProbe, + validation::validate_public_ip, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use std::net::Ipv4Addr; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub struct UpdateGeoProbeArgs { + pub public_ip: Option, + pub location_offset_port: Option, + pub metrics_publisher_pk: Option, +} + +pub fn process_update_geo_probe( + program_id: &Pubkey, + accounts: &[AccountInfo], + args: &UpdateGeoProbeArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let probe_account = next_account_info(accounts_iter)?; + let program_config_account = next_account_info(accounts_iter)?; + let serviceability_globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + if !payer_account.is_signer { + msg!("Payer must be a signer"); + return Err(ProgramError::MissingRequiredSignature); + } + + check_foundation_allowlist( + program_config_account, + serviceability_globalstate_account, + payer_account, + program_id, + )?; + + if probe_account.owner != program_id { + msg!("Invalid GeoProbe Account Owner"); + return Err(ProgramError::IllegalOwner); + } + if !probe_account.is_writable { + msg!("GeoProbe account must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + let mut probe = GeoProbe::try_from(probe_account)?; + + if let Some(ref public_ip) = args.public_ip { + validate_public_ip(public_ip)?; + probe.public_ip = *public_ip; + } + if let Some(location_offset_port) = args.location_offset_port { + probe.location_offset_port = location_offset_port; + } + if let Some(metrics_publisher_pk) = args.metrics_publisher_pk { + probe.metrics_publisher_pk = metrics_publisher_pk; + } + + try_acc_write(&probe, probe_account, payer_account, accounts)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs b/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs index 0e13e5d9f..e006fad2b 100644 --- a/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs @@ -1,3 +1,4 @@ +pub mod geo_probe; pub mod program_config; use crate::{error::GeolocationError, state::program_config::GeolocationProgramConfig}; diff --git a/smartcontract/programs/doublezero-geolocation/src/seeds.rs b/smartcontract/programs/doublezero-geolocation/src/seeds.rs index 4fc9f6f0f..aad1af7ef 100644 --- a/smartcontract/programs/doublezero-geolocation/src/seeds.rs +++ b/smartcontract/programs/doublezero-geolocation/src/seeds.rs @@ -1,2 +1,3 @@ pub const SEED_PREFIX: &[u8] = b"doublezero"; pub const SEED_PROGRAM_CONFIG: &[u8] = b"programconfig"; +pub const SEED_PROBE: &[u8] = b"probe"; diff --git a/smartcontract/programs/doublezero-geolocation/src/serializer.rs b/smartcontract/programs/doublezero-geolocation/src/serializer.rs index 2f41902bd..a5c69db13 100644 --- a/smartcontract/programs/doublezero-geolocation/src/serializer.rs +++ b/smartcontract/programs/doublezero-geolocation/src/serializer.rs @@ -3,7 +3,11 @@ use borsh::BorshSerialize; use doublezero_program_common::{ create_account::try_create_account, resize_account::resize_account_if_needed, }; -use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; +#[allow(deprecated)] // system_program not yet migrated to solana_sdk_ids crate-wide +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, + pubkey::Pubkey, system_program, +}; #[cfg(test)] use solana_program::msg; @@ -73,3 +77,20 @@ where Ok(()) } + +#[allow(deprecated)] // solana_program::system_program not yet migrated to solana_sdk_ids +pub fn try_acc_close( + close_account: &AccountInfo, + receiving_account: &AccountInfo, +) -> ProgramResult { + **receiving_account.lamports.borrow_mut() = receiving_account + .lamports() + .checked_add(close_account.lamports()) + .ok_or(ProgramError::InsufficientFunds)?; + **close_account.lamports.borrow_mut() = 0; + + close_account.realloc(0, false)?; + close_account.assign(&system_program::ID); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs b/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs index 8944cf77b..e67f7050b 100644 --- a/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs @@ -9,12 +9,14 @@ pub enum AccountType { #[default] None = 0, ProgramConfig = 1, + GeoProbe = 2, } impl From for AccountType { fn from(value: u8) -> Self { match value { 1 => AccountType::ProgramConfig, + 2 => AccountType::GeoProbe, _ => AccountType::None, } } @@ -25,6 +27,7 @@ impl fmt::Display for AccountType { match self { AccountType::None => write!(f, "none"), AccountType::ProgramConfig => write!(f, "programconfig"), + AccountType::GeoProbe => write!(f, "geoprobe"), } } } diff --git a/smartcontract/programs/doublezero-geolocation/src/state/geo_probe.rs b/smartcontract/programs/doublezero-geolocation/src/state/geo_probe.rs new file mode 100644 index 000000000..027641157 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/state/geo_probe.rs @@ -0,0 +1,221 @@ +use crate::{ + error::{GeolocationError, Validate}, + state::accounttype::AccountType, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; +use std::{fmt, net::Ipv4Addr}; + +pub const MAX_PARENT_DEVICES: usize = 5; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GeoProbe { + pub account_type: AccountType, // 1 + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + pub owner: Pubkey, // 32 + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + pub exchange_pk: Pubkey, // 32 + pub public_ip: Ipv4Addr, // 4 + pub location_offset_port: u16, // 2 + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + // This key identifies who publishes metrics for this probe. It is not validated at probe + // creation time, but rather at location offset validation time when signed data is verified. + pub metrics_publisher_pk: Pubkey, // 32 + pub reference_count: u32, // 4 + // Variable-length fields must be at the end for Borsh deserialization + pub code: String, // 4 + len + pub parent_devices: Vec, // 4 + 32 * len +} + +impl fmt::Display for GeoProbe { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "account_type: {}, owner: {}, exchange_pk: {}, public_ip: {}, location_offset_port: {}, \ + metrics_publisher_pk: {}, reference_count: {}, code: {}, parent_devices: {:?}", + self.account_type, self.owner, self.exchange_pk, self.public_ip, self.location_offset_port, + self.metrics_publisher_pk, self.reference_count, self.code, self.parent_devices, + ) + } +} + +impl TryFrom<&[u8]> for GeoProbe { + type Error = ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + let out = Self::deserialize(&mut data).map_err(|_| ProgramError::InvalidAccountData)?; + + if out.account_type != AccountType::GeoProbe { + return Err(ProgramError::InvalidAccountData); + } + + Ok(out) + } +} + +impl TryFrom<&AccountInfo<'_>> for GeoProbe { + type Error = ProgramError; + + fn try_from(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + let res = Self::try_from(&data[..]); + if res.is_err() { + msg!("Failed to deserialize GeoProbe: {:?}", res.as_ref().err()); + } + res + } +} + +impl Validate for GeoProbe { + fn validate(&self) -> Result<(), GeolocationError> { + if self.account_type != AccountType::GeoProbe { + msg!("Invalid account type: {}", self.account_type); + return Err(GeolocationError::InvalidAccountType); + } + if self.code.len() > 32 { + msg!("Code too long: {} bytes", self.code.len()); + return Err(GeolocationError::InvalidCodeLength); + } + if self.parent_devices.len() > MAX_PARENT_DEVICES { + msg!( + "Too many parent devices: {} (max {})", + self.parent_devices.len(), + MAX_PARENT_DEVICES + ); + return Err(GeolocationError::MaxParentDevicesReached); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_geo_probe_serialization() { + let val = GeoProbe { + account_type: AccountType::GeoProbe, + owner: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + public_ip: [8, 8, 8, 8].into(), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + reference_count: 3, + code: "probe-ams-01".to_string(), + parent_devices: vec![Pubkey::new_unique(), Pubkey::new_unique()], + }; + + let data = borsh::to_vec(&val).unwrap(); + let val2 = GeoProbe::try_from(&data[..]).unwrap(); + + val.validate().unwrap(); + val2.validate().unwrap(); + + assert_eq!( + borsh::object_length(&val).unwrap(), + borsh::object_length(&val2).unwrap() + ); + assert_eq!(val.owner, val2.owner); + assert_eq!(val.exchange_pk, val2.exchange_pk); + assert_eq!(val.public_ip, val2.public_ip); + assert_eq!(val.location_offset_port, val2.location_offset_port); + assert_eq!(val.metrics_publisher_pk, val2.metrics_publisher_pk); + assert_eq!(val.reference_count, val2.reference_count); + assert_eq!(val.code, val2.code); + assert_eq!(val.parent_devices, val2.parent_devices); + assert_eq!( + data.len(), + borsh::object_length(&val).unwrap(), + "Invalid Size" + ); + } + + #[test] + fn test_state_geo_probe_validate_error_invalid_account_type() { + let val = GeoProbe { + account_type: AccountType::ProgramConfig, + owner: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + public_ip: [8, 8, 8, 8].into(), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + reference_count: 0, + code: "probe-ams-01".to_string(), + parent_devices: vec![], + }; + let err = val.validate(); + assert!(err.is_err()); + assert_eq!(err.unwrap_err(), GeolocationError::InvalidAccountType); + } + + #[test] + fn test_state_geo_probe_validate_error_code_too_long() { + let val = GeoProbe { + account_type: AccountType::GeoProbe, + owner: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + public_ip: [8, 8, 8, 8].into(), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + reference_count: 0, + code: "a".repeat(33), + parent_devices: vec![], + }; + let err = val.validate(); + assert!(err.is_err()); + assert_eq!(err.unwrap_err(), GeolocationError::InvalidCodeLength); + } + + #[test] + fn test_state_geo_probe_validate_error_too_many_parent_devices() { + let val = GeoProbe { + account_type: AccountType::GeoProbe, + owner: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + public_ip: [8, 8, 8, 8].into(), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + reference_count: 0, + code: "probe-ams-01".to_string(), + parent_devices: vec![ + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), + Pubkey::new_unique(), // 6 > MAX_PARENT_DEVICES (5) + ], + }; + let err = val.validate(); + assert!(err.is_err()); + assert_eq!(err.unwrap_err(), GeolocationError::MaxParentDevicesReached); + } + + #[test] + fn test_state_geo_probe_try_from_invalid_account_type() { + let data = [AccountType::None as u8]; + let result = GeoProbe::try_from(&data[..]); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ProgramError::InvalidAccountData); + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/state/mod.rs b/smartcontract/programs/doublezero-geolocation/src/state/mod.rs index 58fc57bcb..dc09fac74 100644 --- a/smartcontract/programs/doublezero-geolocation/src/state/mod.rs +++ b/smartcontract/programs/doublezero-geolocation/src/state/mod.rs @@ -1,2 +1,3 @@ pub mod accounttype; +pub mod geo_probe; pub mod program_config; diff --git a/smartcontract/programs/doublezero-geolocation/src/validation.rs b/smartcontract/programs/doublezero-geolocation/src/validation.rs new file mode 100644 index 000000000..43b55aea7 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/validation.rs @@ -0,0 +1,299 @@ +use std::net::Ipv4Addr; + +use crate::error::GeolocationError; + +pub const MAX_CODE_LENGTH: usize = 32; + +pub fn validate_code_length(code: &str) -> Result<(), GeolocationError> { + if code.is_empty() || code.len() > MAX_CODE_LENGTH { + return Err(GeolocationError::InvalidCodeLength); + } + Ok(()) +} + +/// Validates that the given IPv4 address is publicly routable. +/// Rejects all non-globally-routable addresses including RFC 1918 private, +/// loopback, multicast, broadcast, link-local, shared address space (RFC 6598), +/// documentation/test ranges, benchmarking, protocol assignments, and reserved. +pub fn validate_public_ip(ip: &Ipv4Addr) -> Result<(), GeolocationError> { + let octets = ip.octets(); + + if ip.is_unspecified() { + return Err(GeolocationError::InvalidIpAddress); + } + + // 0.0.0.0/8 "This network" (RFC 791) + if octets[0] == 0 { + return Err(GeolocationError::InvalidIpAddress); + } + + if ip.is_loopback() { + return Err(GeolocationError::InvalidIpAddress); + } + + // Private: 10.0.0.0/8 + if octets[0] == 10 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Private: 172.16.0.0/12 + if octets[0] == 172 && (16..=31).contains(&octets[1]) { + return Err(GeolocationError::InvalidIpAddress); + } + + // Private: 192.168.0.0/16 + if octets[0] == 192 && octets[1] == 168 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Shared Address Space: 100.64.0.0/10 (RFC 6598) + if octets[0] == 100 && (64..=127).contains(&octets[1]) { + return Err(GeolocationError::InvalidIpAddress); + } + + // Link-local: 169.254.0.0/16 + if octets[0] == 169 && octets[1] == 254 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Protocol Assignments: 192.0.0.0/24 (RFC 6890) + if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Documentation: 192.0.2.0/24 TEST-NET-1 (RFC 5737) + if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Benchmarking: 198.18.0.0/15 (RFC 2544) + if octets[0] == 198 && (18..=19).contains(&octets[1]) { + return Err(GeolocationError::InvalidIpAddress); + } + + // Documentation: 198.51.100.0/24 TEST-NET-2 (RFC 5737) + if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Documentation: 203.0.113.0/24 TEST-NET-3 (RFC 5737) + if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 { + return Err(GeolocationError::InvalidIpAddress); + } + + // Multicast: 224.0.0.0/4 (224-239.x.x.x) + if (224..=239).contains(&octets[0]) { + return Err(GeolocationError::InvalidIpAddress); + } + + if ip.is_broadcast() { + return Err(GeolocationError::InvalidIpAddress); + } + + // Reserved: 240-254.x.x.x (future use) + if octets[0] >= 240 { + return Err(GeolocationError::InvalidIpAddress); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_code_length_empty() { + assert_eq!( + validate_code_length(""), + Err(GeolocationError::InvalidCodeLength) + ); + } + + #[test] + fn test_validate_code_length_max() { + let code = "a".repeat(MAX_CODE_LENGTH); + assert!(validate_code_length(&code).is_ok()); + } + + #[test] + fn test_validate_code_length_exceeds_max() { + let code = "a".repeat(MAX_CODE_LENGTH + 1); + assert_eq!( + validate_code_length(&code), + Err(GeolocationError::InvalidCodeLength) + ); + } + + #[test] + fn test_valid_public_ips() { + let valid_ips = [ + Ipv4Addr::new(8, 8, 8, 8), + Ipv4Addr::new(1, 1, 1, 1), + Ipv4Addr::new(185, 199, 108, 153), + ]; + for ip in &valid_ips { + assert!(validate_public_ip(ip).is_ok(), "expected {ip} to be valid"); + } + } + + #[test] + fn test_private_10_network() { + let ip = Ipv4Addr::new(10, 0, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_private_172_network() { + let ip = Ipv4Addr::new(172, 16, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_private_192_168_network() { + let ip = Ipv4Addr::new(192, 168, 1, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_loopback() { + let ip = Ipv4Addr::new(127, 0, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_multicast_low() { + let ip = Ipv4Addr::new(224, 0, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_multicast_high() { + let ip = Ipv4Addr::new(239, 255, 255, 255); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_broadcast() { + let ip = Ipv4Addr::new(255, 255, 255, 255); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_link_local() { + let ip = Ipv4Addr::new(169, 254, 1, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_unspecified() { + let ip = Ipv4Addr::new(0, 0, 0, 0); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_reserved_range() { + let ip = Ipv4Addr::new(240, 0, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_this_network_0_x() { + let ip = Ipv4Addr::new(0, 1, 2, 3); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_shared_address_space() { + let ips = [ + Ipv4Addr::new(100, 64, 0, 1), + Ipv4Addr::new(100, 127, 255, 254), + ]; + for ip in &ips { + assert_eq!( + validate_public_ip(ip), + Err(GeolocationError::InvalidIpAddress), + "expected {ip} to be rejected" + ); + } + // 100.63.x.x and 100.128.x.x should be valid + assert!(validate_public_ip(&Ipv4Addr::new(100, 63, 255, 255)).is_ok()); + assert!(validate_public_ip(&Ipv4Addr::new(100, 128, 0, 0)).is_ok()); + } + + #[test] + fn test_protocol_assignments() { + let ip = Ipv4Addr::new(192, 0, 0, 1); + assert_eq!( + validate_public_ip(&ip), + Err(GeolocationError::InvalidIpAddress) + ); + } + + #[test] + fn test_documentation_ranges() { + let ips = [ + Ipv4Addr::new(192, 0, 2, 1), // TEST-NET-1 + Ipv4Addr::new(198, 51, 100, 1), // TEST-NET-2 + Ipv4Addr::new(203, 0, 113, 1), // TEST-NET-3 + ]; + for ip in &ips { + assert_eq!( + validate_public_ip(ip), + Err(GeolocationError::InvalidIpAddress), + "expected {ip} to be rejected" + ); + } + } + + #[test] + fn test_benchmarking_range() { + let ips = [ + Ipv4Addr::new(198, 18, 0, 1), + Ipv4Addr::new(198, 19, 255, 254), + ]; + for ip in &ips { + assert_eq!( + validate_public_ip(ip), + Err(GeolocationError::InvalidIpAddress), + "expected {ip} to be rejected" + ); + } + // 198.17.x.x and 198.20.x.x should be valid + assert!(validate_public_ip(&Ipv4Addr::new(198, 17, 255, 255)).is_ok()); + assert!(validate_public_ip(&Ipv4Addr::new(198, 20, 0, 0)).is_ok()); + } +} From 738996a32d58da717533059b15d8d8047b5fd6e8 Mon Sep 17 00:00:00 2001 From: Nik Weidenbacher Date: Fri, 27 Feb 2026 18:16:01 +0000 Subject: [PATCH 2/3] geolocation: add integration tests for GeoProbe processors - Add comprehensive integration tests for create, update, and delete GeoProbe operations - Test validation logic for IP addresses, code length, and exchange status - Test reference count protection on deletion - Update CLAUDE.md to require integration tests for all processors - Remove unused system_program parameter from update processor --- rfcs/rfc16-geolocation-verification.md | 8 +- smartcontract/programs/CLAUDE.md | 6 + .../src/processors/geo_probe/update.rs | 1 - .../tests/geo_probe_test.rs | 323 ++++++++++++++++++ .../tests/test_helpers.rs | 221 ++++++++++++ 5 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs create mode 100644 smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs diff --git a/rfcs/rfc16-geolocation-verification.md b/rfcs/rfc16-geolocation-verification.md index d8501dd11..f0ee7497d 100644 --- a/rfcs/rfc16-geolocation-verification.md +++ b/rfcs/rfc16-geolocation-verification.md @@ -98,8 +98,8 @@ A geoProbe assigned to a specific DZD for periodic latency measurement, defined Outbound Probing Flow ``` ┌──────────┐ ┌───────────┐ ┌───────────┐ - │ │<─────Reply───────│ │<─────Reply───────│ │ - │ DZD │──────TWAMP──────>│ Probe │──────Probe──────>│ Target │ + │ │──────TWAMP──────>│ │──────Probe──────>│ │ + │ DZD │<─────Reply───────│ Probe │<─────Reply───────│ Target │ │ │──Signed Offset──>│ │──Signed Offset──>│ │ └──────────┘ └───────────┘ w/ references └───────────┘ ^ │ ^ │ │ @@ -117,8 +117,8 @@ IP │ │ Offset Target IPs │ │ Measured Offset │ Inbound Probing Flow ``` ┌──────────┐ ┌───────────┐ ┌───────────┐ - │ │<─────Reply───────│ │<──Signed Probe───│ │ - │ DZD │──────TWAMP──────>│ Probe │───Signed Reply──>│ Target │ + │ │──────TWAMP──────>│ │<──Signed Probe───│ │ + │ DZD │<─────Reply───────│ Probe │───Signed Reply──>│ Target │ │ │──Signed Offset──>│ │ │ │ └──────────┘ └───────────┘ └───────────┘ ^ │ Measured ^ │ │ diff --git a/smartcontract/programs/CLAUDE.md b/smartcontract/programs/CLAUDE.md index db78382bd..6741f8f18 100644 --- a/smartcontract/programs/CLAUDE.md +++ b/smartcontract/programs/CLAUDE.md @@ -34,6 +34,12 @@ 2. **Don't test framework functionality**: Avoid writing tests that only exercise SDK/framework behavior (e.g., testing that `Pubkey::find_program_address` is deterministic or produces different outputs for different inputs). Focus tests on your program's logic. +3. **Integration tests for all processors**: Every processor function (instruction handler) should have corresponding integration tests in the `tests/` directory. These tests should cover: + - Success cases with valid inputs + - All error cases (invalid inputs, unauthorized signers, wrong account states) + - Edge cases (boundary values, empty collections, maximum sizes) + - State transitions (account creation, updates, deletion) + ### Program Upgrades 1. **Use standard interfaces**: Use `solana-loader-v3-interface` to parse `UpgradeableLoaderState` rather than implementing your own parser. The interface crate provides well-tested, maintained implementations. diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs index fcbcc1698..360a19ada 100644 --- a/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs @@ -30,7 +30,6 @@ pub fn process_update_geo_probe( let program_config_account = next_account_info(accounts_iter)?; let serviceability_globalstate_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; - let _system_program = next_account_info(accounts_iter)?; if !payer_account.is_signer { msg!("Payer must be a signer"); diff --git a/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs b/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs new file mode 100644 index 000000000..f4ebc86b9 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs @@ -0,0 +1,323 @@ +#![allow(unused_mut)] + +mod test_helpers; + +use doublezero_geolocation::{ + error::GeolocationError, + instructions::GeolocationInstruction, + pda::get_geo_probe_pda, + processors::geo_probe::{create::CreateGeoProbeArgs, update::UpdateGeoProbeArgs}, + serviceability_program_id, + state::{accounttype::AccountType, geo_probe::GeoProbe}, +}; +use doublezero_serviceability::state::exchange::ExchangeStatus; +use solana_program_test::*; +use solana_sdk::{ + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + signature::Keypair, + transaction::{Transaction, TransactionError}, +}; +use std::net::Ipv4Addr; +use test_helpers::setup_test_with_exchange; + +#[tokio::test] +async fn test_create_geo_probe_success() { + let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + // Create GeoProbe + let code = "probe-ams-01"; + let (probe_pda, _) = get_geo_probe_pda(&program_id, code); + let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; + let serviceability_globalstate_pda = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + + let args = CreateGeoProbeArgs { + code: code.to_string(), + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + }; + + let ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::CreateGeoProbe(args.clone()), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(exchange_pubkey, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + // Use a deterministic keypair for the test payer + let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify the account was created + let probe_account = banks_client.get_account(probe_pda).await.unwrap().unwrap(); + let probe = GeoProbe::try_from(&probe_account.data[..]).unwrap(); + + assert_eq!(probe.account_type, AccountType::GeoProbe); + assert_eq!(probe.owner, payer_pubkey); + assert_eq!(probe.exchange_pk, exchange_pubkey); + assert_eq!(probe.public_ip, Ipv4Addr::new(8, 8, 8, 8)); + assert_eq!(probe.location_offset_port, 4242); + assert_eq!(probe.metrics_publisher_pk, args.metrics_publisher_pk); + assert_eq!(probe.reference_count, 0); + assert_eq!(probe.code, code); + assert_eq!(probe.parent_devices.len(), 0); +} + +#[tokio::test] +async fn test_create_geo_probe_invalid_code_length() { + let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + // Try to create GeoProbe with code that's too long + let code = "a".repeat(33); // Exceeds 32 char limit + let (probe_pda, _) = get_geo_probe_pda(&program_id, &code); + let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; + let serviceability_globalstate_pda = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + + let args = CreateGeoProbeArgs { + code, + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + }; + + let ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::CreateGeoProbe(args), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(exchange_pubkey, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + + let result = banks_client.process_transaction(tx).await; + assert!(result.is_err()); + + let err = result.unwrap_err().unwrap(); + match err { + TransactionError::InstructionError(0, InstructionError::Custom(code)) => { + assert_eq!(code, GeolocationError::InvalidCodeLength as u32); + } + _ => panic!("Expected InvalidCodeLength error, got: {:?}", err), + } +} + +#[tokio::test] +async fn test_create_geo_probe_exchange_not_activated() { + let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Pending).await; + + let code = "probe-pending"; + let (probe_pda, _) = get_geo_probe_pda(&program_id, code); + let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; + let serviceability_globalstate_pda = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + + let args = CreateGeoProbeArgs { + code: code.to_string(), + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + }; + + let ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::CreateGeoProbe(args), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(exchange_pubkey, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + + let result = banks_client.process_transaction(tx).await; + assert!(result.is_err()); + // Exchange not activated should return InvalidAccountData +} + +#[tokio::test] +async fn test_update_geo_probe_success() { + let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + // First create a GeoProbe + let code = "probe-update"; + let (probe_pda, _) = get_geo_probe_pda(&program_id, code); + let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; + let serviceability_globalstate_pda = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + + // Create probe first + let create_args = CreateGeoProbeArgs { + code: code.to_string(), + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + }; + + let create_ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::CreateGeoProbe(create_args.clone()), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(exchange_pubkey, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); + let tx = Transaction::new_signed_with_payer( + &[create_ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Now update the probe + let new_metrics_publisher = Pubkey::new_unique(); + let update_args = UpdateGeoProbeArgs { + public_ip: Some(Ipv4Addr::new(1, 1, 1, 1)), + location_offset_port: Some(5353), + metrics_publisher_pk: Some(new_metrics_publisher), + }; + + let update_ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::UpdateGeoProbe(update_args.clone()), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + ], + ); + + let tx = Transaction::new_signed_with_payer( + &[update_ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify the update + let probe_account = banks_client.get_account(probe_pda).await.unwrap().unwrap(); + let probe = GeoProbe::try_from(&probe_account.data[..]).unwrap(); + + assert_eq!(probe.public_ip, Ipv4Addr::new(1, 1, 1, 1)); + assert_eq!(probe.location_offset_port, 5353); + assert_eq!(probe.metrics_publisher_pk, new_metrics_publisher); + // Verify immutable fields didn't change + assert_eq!(probe.code, code); + assert_eq!(probe.exchange_pk, exchange_pubkey); +} + +#[tokio::test] +async fn test_delete_geo_probe_success() { + let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + // First create a GeoProbe + let code = "probe-delete"; + let (probe_pda, _) = get_geo_probe_pda(&program_id, code); + let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; + let serviceability_globalstate_pda = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + + // Create probe first + let create_args = CreateGeoProbeArgs { + code: code.to_string(), + public_ip: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 4242, + metrics_publisher_pk: Pubkey::new_unique(), + }; + + let create_ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::CreateGeoProbe(create_args), + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(exchange_pubkey, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); + let tx = Transaction::new_signed_with_payer( + &[create_ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Now delete the probe + let delete_ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::DeleteGeoProbe, + vec![ + AccountMeta::new(probe_pda, false), + AccountMeta::new_readonly(program_config_pda, false), + AccountMeta::new_readonly(serviceability_globalstate_pda, false), + AccountMeta::new(payer_pubkey, true), + ], + ); + + let tx = Transaction::new_signed_with_payer( + &[delete_ix], + Some(&payer_pubkey), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify the account was deleted + let probe_account = banks_client.get_account(probe_pda).await.unwrap(); + assert!(probe_account.is_none()); +} diff --git a/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs b/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs new file mode 100644 index 000000000..886982c32 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs @@ -0,0 +1,221 @@ +#![allow(unused_mut)] + +use doublezero_geolocation::{ + entrypoint::process_instruction, instructions::GeolocationInstruction, + pda::get_program_config_pda, processors::program_config::init::InitProgramConfigArgs, + serviceability_program_id, +}; +use doublezero_serviceability::state::{ + exchange::{Exchange, ExchangeStatus}, + globalstate::GlobalState, +}; +use solana_loader_v3_interface::state::UpgradeableLoaderState; +#[allow(deprecated)] +use solana_program::bpf_loader_upgradeable; +use solana_program_test::*; +use solana_sdk::{ + account::Account, + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + signature::Signer, + transaction::Transaction, +}; + +/// Builds a bincode-serialized UpgradeableLoaderState::ProgramData account +pub fn build_program_data_account(upgrade_authority: &Pubkey) -> Account { + let state = UpgradeableLoaderState::ProgramData { + slot: 0, + upgrade_authority_address: Some(*upgrade_authority), + }; + let data = bincode::serde::encode_to_vec(state, bincode::config::legacy()).unwrap(); + + Account { + lamports: 1_000_000_000, + data, + owner: bpf_loader_upgradeable::id(), + executable: false, + rent_epoch: 0, + } +} + +/// Creates a mock Exchange account owned by the serviceability program +pub fn create_mock_exchange_account(owner: &Pubkey, status: ExchangeStatus) -> Account { + let exchange = Exchange { + account_type: doublezero_serviceability::state::accounttype::AccountType::Exchange, + owner: *owner, + index: 1, + bump_seed: 1, + lat: 52.3676, + lng: 4.9041, + bgp_community: 64512, + unused: 0, + status, + code: "test-exchange".to_string(), + name: "Test Exchange".to_string(), + reference_count: 0, + device1_pk: Pubkey::new_unique(), + device2_pk: Pubkey::new_unique(), + }; + + let data = borsh::to_vec(&exchange).unwrap(); + Account { + lamports: 1_000_000_000, + data, + owner: serviceability_program_id(), + executable: false, + rent_epoch: 0, + } +} + +/// Creates a mock GlobalState account for serviceability +pub fn create_mock_globalstate_account(foundation_allowlist: Vec) -> Account { + let globalstate = GlobalState { + account_type: doublezero_serviceability::state::accounttype::AccountType::GlobalState, + bump_seed: 1, + account_index: 0, + foundation_allowlist, + _device_allowlist: vec![], + _user_allowlist: vec![], + activator_authority_pk: Pubkey::new_unique(), + sentinel_authority_pk: Pubkey::new_unique(), + contributor_airdrop_lamports: 1_000_000, + user_airdrop_lamports: 1_000_000, + health_oracle_pk: Pubkey::new_unique(), + qa_allowlist: vec![], + feature_flags: 0, + }; + + let data = borsh::to_vec(&globalstate).unwrap(); + Account { + lamports: 1_000_000_000, + data, + owner: serviceability_program_id(), + executable: false, + rent_epoch: 0, + } +} + +/// Sets up a test environment with initialized ProgramConfig +#[allow(dead_code)] +pub async fn setup_test_with_config() -> ( + BanksClient, + Pubkey, + tokio::sync::RwLock, + Pubkey, +) { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_geolocation", + program_id, + processor!(process_instruction), + ); + + // Add program data account + let payer_pubkey = Pubkey::new_unique(); + let (program_data_pda, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::id()); + program_test.add_account(program_data_pda, build_program_data_account(&payer_pubkey)); + + // Add serviceability GlobalState with foundation allowlist + let serviceability_globalstate_pubkey = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + program_test.add_account( + serviceability_globalstate_pubkey, + create_mock_globalstate_account(vec![payer_pubkey]), + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + let recent_blockhash = tokio::sync::RwLock::new(recent_blockhash); + + // Initialize ProgramConfig + let (program_config_pda, _) = get_program_config_pda(&program_id); + let ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), + vec![ + AccountMeta::new(program_config_pda, false), + AccountMeta::new_readonly(program_data_pda, false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + (banks_client, program_id, recent_blockhash, payer.pubkey()) +} + +/// Sets up test with config and an exchange +pub async fn setup_test_with_exchange( + exchange_status: ExchangeStatus, +) -> ( + BanksClient, + Pubkey, + tokio::sync::RwLock, + Pubkey, + Pubkey, +) { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_geolocation", + program_id, + processor!(process_instruction), + ); + + // Add program data account + let payer_pubkey = Pubkey::new_unique(); + let (program_data_pda, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::id()); + program_test.add_account(program_data_pda, build_program_data_account(&payer_pubkey)); + + // Add serviceability GlobalState with foundation allowlist + let serviceability_globalstate_pubkey = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + program_test.add_account( + serviceability_globalstate_pubkey, + create_mock_globalstate_account(vec![payer_pubkey]), + ); + + // Add exchange account + let exchange_pubkey = Pubkey::new_unique(); + let exchange_account = create_mock_exchange_account(&payer_pubkey, exchange_status); + program_test.add_account(exchange_pubkey, exchange_account); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + let recent_blockhash = tokio::sync::RwLock::new(recent_blockhash); + + // Initialize ProgramConfig + let (program_config_pda, _) = get_program_config_pda(&program_id); + let ix = Instruction::new_with_borsh( + program_id, + &GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), + vec![ + AccountMeta::new(program_config_pda, false), + AccountMeta::new_readonly(program_data_pda, false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ], + ); + + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + ( + banks_client, + program_id, + recent_blockhash, + payer.pubkey(), + exchange_pubkey, + ) +} From 083f58fa0c9bc2bdeedff915bfde7a1e4d2bdf73 Mon Sep 17 00:00:00 2001 From: Nik Weidenbacher Date: Fri, 27 Feb 2026 20:59:36 +0000 Subject: [PATCH 3/3] geolocation: test infrastructure now properly mimics the Solana runtime environment where the program upgrade authority check expects the payer to match the authority stored in the program data account --- .../tests/geo_probe_test.rs | 55 +++---- .../tests/test_helpers.rs | 153 ++++++------------ 2 files changed, 73 insertions(+), 135 deletions(-) diff --git a/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs b/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs index f4ebc86b9..f54d4d290 100644 --- a/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs +++ b/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs @@ -15,7 +15,7 @@ use solana_program_test::*; use solana_sdk::{ instruction::{AccountMeta, Instruction, InstructionError}, pubkey::Pubkey, - signature::Keypair, + signature::Signer, transaction::{Transaction, TransactionError}, }; use std::net::Ipv4Addr; @@ -23,7 +23,7 @@ use test_helpers::setup_test_with_exchange; #[tokio::test] async fn test_create_geo_probe_success() { - let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = setup_test_with_exchange(ExchangeStatus::Activated).await; // Create GeoProbe @@ -48,16 +48,14 @@ async fn test_create_geo_probe_success() { AccountMeta::new_readonly(exchange_pubkey, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); - // Use a deterministic keypair for the test payer - let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); let tx = Transaction::new_signed_with_payer( &[ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -68,7 +66,7 @@ async fn test_create_geo_probe_success() { let probe = GeoProbe::try_from(&probe_account.data[..]).unwrap(); assert_eq!(probe.account_type, AccountType::GeoProbe); - assert_eq!(probe.owner, payer_pubkey); + assert_eq!(probe.owner, payer.pubkey()); assert_eq!(probe.exchange_pk, exchange_pubkey); assert_eq!(probe.public_ip, Ipv4Addr::new(8, 8, 8, 8)); assert_eq!(probe.location_offset_port, 4242); @@ -80,12 +78,15 @@ async fn test_create_geo_probe_success() { #[tokio::test] async fn test_create_geo_probe_invalid_code_length() { - let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = setup_test_with_exchange(ExchangeStatus::Activated).await; // Try to create GeoProbe with code that's too long - let code = "a".repeat(33); // Exceeds 32 char limit - let (probe_pda, _) = get_geo_probe_pda(&program_id, &code); + // Use exactly 33 chars which exceeds the 32 byte limit + let code = "a123456789012345678901234567890ab".to_string(); + assert_eq!(code.len(), 33); // Verify it's indeed 33 chars + // For PDA, we'll use truncated version to avoid panic + let (probe_pda, _) = get_geo_probe_pda(&program_id, &code[..32]); let program_config_pda = doublezero_geolocation::pda::get_program_config_pda(&program_id).0; let serviceability_globalstate_pda = doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; @@ -105,15 +106,14 @@ async fn test_create_geo_probe_invalid_code_length() { AccountMeta::new_readonly(exchange_pubkey, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); - let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); let tx = Transaction::new_signed_with_payer( &[ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -132,7 +132,7 @@ async fn test_create_geo_probe_invalid_code_length() { #[tokio::test] async fn test_create_geo_probe_exchange_not_activated() { - let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = setup_test_with_exchange(ExchangeStatus::Pending).await; let code = "probe-pending"; @@ -156,15 +156,14 @@ async fn test_create_geo_probe_exchange_not_activated() { AccountMeta::new_readonly(exchange_pubkey, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); - let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); let tx = Transaction::new_signed_with_payer( &[ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -176,7 +175,7 @@ async fn test_create_geo_probe_exchange_not_activated() { #[tokio::test] async fn test_update_geo_probe_success() { - let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = setup_test_with_exchange(ExchangeStatus::Activated).await; // First create a GeoProbe @@ -202,15 +201,14 @@ async fn test_update_geo_probe_success() { AccountMeta::new_readonly(exchange_pubkey, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); - let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); let tx = Transaction::new_signed_with_payer( &[create_ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -231,13 +229,13 @@ async fn test_update_geo_probe_success() { AccountMeta::new(probe_pda, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), ], ); let tx = Transaction::new_signed_with_payer( &[update_ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -257,7 +255,7 @@ async fn test_update_geo_probe_success() { #[tokio::test] async fn test_delete_geo_probe_success() { - let (mut banks_client, program_id, recent_blockhash, payer_pubkey, exchange_pubkey) = + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = setup_test_with_exchange(ExchangeStatus::Activated).await; // First create a GeoProbe @@ -283,15 +281,14 @@ async fn test_delete_geo_probe_success() { AccountMeta::new_readonly(exchange_pubkey, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); - let payer = Keypair::from_bytes(&[0u8; 64]).unwrap(); let tx = Transaction::new_signed_with_payer( &[create_ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); @@ -305,13 +302,13 @@ async fn test_delete_geo_probe_success() { AccountMeta::new(probe_pda, false), AccountMeta::new_readonly(program_config_pda, false), AccountMeta::new_readonly(serviceability_globalstate_pda, false), - AccountMeta::new(payer_pubkey, true), + AccountMeta::new(payer.pubkey(), true), ], ); let tx = Transaction::new_signed_with_payer( &[delete_ix], - Some(&payer_pubkey), + Some(&payer.pubkey()), &[&payer], *recent_blockhash.read().await, ); diff --git a/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs b/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs index 886982c32..92c9c4230 100644 --- a/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs @@ -14,32 +14,32 @@ use solana_loader_v3_interface::state::UpgradeableLoaderState; use solana_program::bpf_loader_upgradeable; use solana_program_test::*; use solana_sdk::{ - account::Account, + account::AccountSharedData, instruction::{AccountMeta, Instruction}, pubkey::Pubkey, - signature::Signer, + signature::{Keypair, Signer}, transaction::Transaction, }; /// Builds a bincode-serialized UpgradeableLoaderState::ProgramData account -pub fn build_program_data_account(upgrade_authority: &Pubkey) -> Account { +pub fn build_program_data_account(upgrade_authority: &Pubkey) -> AccountSharedData { let state = UpgradeableLoaderState::ProgramData { slot: 0, upgrade_authority_address: Some(*upgrade_authority), }; let data = bincode::serde::encode_to_vec(state, bincode::config::legacy()).unwrap(); - Account { - lamports: 1_000_000_000, - data, - owner: bpf_loader_upgradeable::id(), - executable: false, - rent_epoch: 0, - } + let mut account = + AccountSharedData::new(1_000_000_000, data.len(), &bpf_loader_upgradeable::id()); + account.set_data_from_slice(&data); + account } -/// Creates a mock Exchange account owned by the serviceability program -pub fn create_mock_exchange_account(owner: &Pubkey, status: ExchangeStatus) -> Account { +/// Creates a mock Exchange account owned by the serviceability program (for set_account) +pub fn create_mock_exchange_account_shared( + owner: &Pubkey, + status: ExchangeStatus, +) -> AccountSharedData { let exchange = Exchange { account_type: doublezero_serviceability::state::accounttype::AccountType::Exchange, owner: *owner, @@ -58,17 +58,16 @@ pub fn create_mock_exchange_account(owner: &Pubkey, status: ExchangeStatus) -> A }; let data = borsh::to_vec(&exchange).unwrap(); - Account { - lamports: 1_000_000_000, - data, - owner: serviceability_program_id(), - executable: false, - rent_epoch: 0, - } + let mut account = + AccountSharedData::new(1_000_000_000, data.len(), &serviceability_program_id()); + account.set_data_from_slice(&data); + account } -/// Creates a mock GlobalState account for serviceability -pub fn create_mock_globalstate_account(foundation_allowlist: Vec) -> Account { +/// Creates a mock GlobalState account for serviceability (for set_account) +pub fn create_mock_globalstate_account_shared( + foundation_allowlist: Vec, +) -> AccountSharedData { let globalstate = GlobalState { account_type: doublezero_serviceability::state::accounttype::AccountType::GlobalState, bump_seed: 1, @@ -86,69 +85,10 @@ pub fn create_mock_globalstate_account(foundation_allowlist: Vec) -> Acc }; let data = borsh::to_vec(&globalstate).unwrap(); - Account { - lamports: 1_000_000_000, - data, - owner: serviceability_program_id(), - executable: false, - rent_epoch: 0, - } -} - -/// Sets up a test environment with initialized ProgramConfig -#[allow(dead_code)] -pub async fn setup_test_with_config() -> ( - BanksClient, - Pubkey, - tokio::sync::RwLock, - Pubkey, -) { - let program_id = Pubkey::new_unique(); - let mut program_test = ProgramTest::new( - "doublezero_geolocation", - program_id, - processor!(process_instruction), - ); - - // Add program data account - let payer_pubkey = Pubkey::new_unique(); - let (program_data_pda, _) = - Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::id()); - program_test.add_account(program_data_pda, build_program_data_account(&payer_pubkey)); - - // Add serviceability GlobalState with foundation allowlist - let serviceability_globalstate_pubkey = - doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; - program_test.add_account( - serviceability_globalstate_pubkey, - create_mock_globalstate_account(vec![payer_pubkey]), - ); - - let (mut banks_client, payer, recent_blockhash) = program_test.start().await; - let recent_blockhash = tokio::sync::RwLock::new(recent_blockhash); - - // Initialize ProgramConfig - let (program_config_pda, _) = get_program_config_pda(&program_id); - let ix = Instruction::new_with_borsh( - program_id, - &GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), - vec![ - AccountMeta::new(program_config_pda, false), - AccountMeta::new_readonly(program_data_pda, false), - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new_readonly(solana_program::system_program::id(), false), - ], - ); - - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&payer.pubkey()), - &[&payer], - *recent_blockhash.read().await, - ); - banks_client.process_transaction(tx).await.unwrap(); - - (banks_client, program_id, recent_blockhash, payer.pubkey()) + let mut account = + AccountSharedData::new(1_000_000_000, data.len(), &serviceability_program_id()); + account.set_data_from_slice(&data); + account } /// Sets up test with config and an exchange @@ -158,37 +98,36 @@ pub async fn setup_test_with_exchange( BanksClient, Pubkey, tokio::sync::RwLock, - Pubkey, + Keypair, Pubkey, ) { let program_id = Pubkey::new_unique(); - let mut program_test = ProgramTest::new( + let program_test = ProgramTest::new( "doublezero_geolocation", program_id, processor!(process_instruction), ); - // Add program data account - let payer_pubkey = Pubkey::new_unique(); + // Start with context to be able to set accounts after + let mut context = program_test.start_with_context().await; + let payer_pubkey = context.payer.pubkey(); + + // Inject the program_data account that the upgrade-authority check expects. let (program_data_pda, _) = Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::id()); - program_test.add_account(program_data_pda, build_program_data_account(&payer_pubkey)); + let program_data_account = build_program_data_account(&payer_pubkey); + context.set_account(&program_data_pda, &program_data_account); - // Add serviceability GlobalState with foundation allowlist + // Add serviceability GlobalState with foundation allowlist including the payer let serviceability_globalstate_pubkey = doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; - program_test.add_account( - serviceability_globalstate_pubkey, - create_mock_globalstate_account(vec![payer_pubkey]), - ); + let globalstate_account = create_mock_globalstate_account_shared(vec![payer_pubkey]); + context.set_account(&serviceability_globalstate_pubkey, &globalstate_account); // Add exchange account let exchange_pubkey = Pubkey::new_unique(); - let exchange_account = create_mock_exchange_account(&payer_pubkey, exchange_status); - program_test.add_account(exchange_pubkey, exchange_account); - - let (mut banks_client, payer, recent_blockhash) = program_test.start().await; - let recent_blockhash = tokio::sync::RwLock::new(recent_blockhash); + let exchange_account = create_mock_exchange_account_shared(&payer_pubkey, exchange_status); + context.set_account(&exchange_pubkey, &exchange_account); // Initialize ProgramConfig let (program_config_pda, _) = get_program_config_pda(&program_id); @@ -198,24 +137,26 @@ pub async fn setup_test_with_exchange( vec![ AccountMeta::new(program_config_pda, false), AccountMeta::new_readonly(program_data_pda, false), - AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(payer_pubkey, true), AccountMeta::new_readonly(solana_program::system_program::id(), false), ], ); let tx = Transaction::new_signed_with_payer( &[ix], - Some(&payer.pubkey()), - &[&payer], - *recent_blockhash.read().await, + Some(&payer_pubkey), + &[&context.payer], + context.last_blockhash, ); - banks_client.process_transaction(tx).await.unwrap(); + context.banks_client.process_transaction(tx).await.unwrap(); + + let recent_blockhash = tokio::sync::RwLock::new(context.last_blockhash); ( - banks_client, + context.banks_client, program_id, recent_blockhash, - payer.pubkey(), + context.payer, exchange_pubkey, ) }