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..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 ^ │ │ @@ -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..6741f8f18 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,18 @@ 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. + +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/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..360a19ada --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geo_probe/update.rs @@ -0,0 +1,71 @@ +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)?; + + 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()); + } +} 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..f54d4d290 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/tests/geo_probe_test.rs @@ -0,0 +1,320 @@ +#![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::Signer, + 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, 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), + ], + ); + + 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, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + // Try to create GeoProbe with code that's too long + // 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; + + 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 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, 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 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, 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 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, 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 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..92c9c4230 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/tests/test_helpers.rs @@ -0,0 +1,162 @@ +#![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::AccountSharedData, + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +/// Builds a bincode-serialized UpgradeableLoaderState::ProgramData 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(); + + 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 (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, + 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(); + 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 (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, + 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(); + 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 +pub async fn setup_test_with_exchange( + exchange_status: ExchangeStatus, +) -> ( + BanksClient, + Pubkey, + tokio::sync::RwLock, + Keypair, + Pubkey, +) { + let program_id = Pubkey::new_unique(); + let program_test = ProgramTest::new( + "doublezero_geolocation", + program_id, + processor!(process_instruction), + ); + + // 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()); + 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 including the payer + let serviceability_globalstate_pubkey = + doublezero_serviceability::pda::get_globalstate_pda(&serviceability_program_id()).0; + 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_shared(&payer_pubkey, exchange_status); + context.set_account(&exchange_pubkey, &exchange_account); + + // 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), + &[&context.payer], + context.last_blockhash, + ); + context.banks_client.process_transaction(tx).await.unwrap(); + + let recent_blockhash = tokio::sync::RwLock::new(context.last_blockhash); + + ( + context.banks_client, + program_id, + recent_blockhash, + context.payer, + exchange_pubkey, + ) +}