diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6f38778..1b3f6ab2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ 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 +- SDK - SetFeatureFlagCommand added to manage on-chain feature flags for conditional behavior rollouts - Dependencies - Upgrade Solana SDK workspace dependencies from 2.2.7 to 2.3.x (`solana-sdk`, `solana-client`, `solana-program-test`, and others) diff --git a/Cargo.lock b/Cargo.lock index 3fda7a07f..b7c9355e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1632,6 +1632,26 @@ dependencies = [ "solana-sdk", ] +[[package]] +name = "doublezero-geolocation" +version = "0.8.10" +dependencies = [ + "bincode 2.0.1", + "borsh 1.5.7", + "borsh-incremental", + "doublezero-config", + "doublezero-program-common", + "doublezero-serviceability", + "serde", + "serde_bytes", + "solana-bincode", + "solana-loader-v3-interface 3.0.0", + "solana-program", + "solana-program-test", + "solana-sdk", + "thiserror 2.0.17", +] + [[package]] name = "doublezero-program-common" version = "0.8.10" @@ -4837,7 +4857,7 @@ dependencies = [ "solana-epoch-schedule", "solana-fee-calculator", "solana-instruction", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-nonce", "solana-program-option", "solana-program-pack", @@ -5139,7 +5159,7 @@ dependencies = [ "solana-hash", "solana-instruction", "solana-keccak-hasher", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-loader-v4-interface", "solana-log-collector", "solana-measure", @@ -5820,6 +5840,20 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "solana-loader-v3-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4be76cfa9afd84ca2f35ebc09f0da0f0092935ccdac0595d98447f259538c2" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", +] + [[package]] name = "solana-loader-v3-interface" version = "5.0.0" @@ -5862,7 +5896,7 @@ dependencies = [ "solana-bincode", "solana-bpf-loader-program", "solana-instruction", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-loader-v4-interface", "solana-log-collector", "solana-measure", @@ -6179,7 +6213,7 @@ dependencies = [ "solana-keccak-hasher", "solana-last-restart-slot", "solana-loader-v2-interface", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-loader-v4-interface", "solana-message", "solana-msg", @@ -6339,7 +6373,7 @@ dependencies = [ "solana-hash", "solana-instruction", "solana-keypair", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-log-collector", "solana-logger", "solana-message", @@ -6715,7 +6749,7 @@ dependencies = [ "solana-instruction", "solana-keypair", "solana-lattice-hash", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-loader-v4-interface", "solana-measure", "solana-message", @@ -7247,7 +7281,7 @@ dependencies = [ "solana-hash", "solana-instruction", "solana-instructions-sysvar", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-loader-v4-interface", "solana-loader-v4-program", "solana-log-collector", @@ -7643,7 +7677,7 @@ dependencies = [ "solana-hash", "solana-instruction", "solana-loader-v2-interface", - "solana-loader-v3-interface", + "solana-loader-v3-interface 5.0.0", "solana-message", "solana-program-option", "solana-pubkey", diff --git a/Cargo.toml b/Cargo.toml index ea5788d76..b0499e10e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "smartcontract/programs/doublezero-record", "smartcontract/programs/doublezero-serviceability", "smartcontract/programs/doublezero-telemetry", + "smartcontract/programs/doublezero-geolocation", "smartcontract/programs/common", "e2e/docker/ledger/fork-accounts", ] @@ -75,6 +76,8 @@ serde_yaml = "0" serial_test = "0" solana-account-decoder = "2.3.1" solana-client = "2.3.1" +solana-bincode = "2.2.1" +solana-loader-v3-interface = { version = "3.0.0", features = ["serde"] } solana-program = "2.3.0" solana-program-test = "2.3.1" solana-pubsub-client = "2.3.1" @@ -109,3 +112,7 @@ features = ["no-entrypoint"] [workspace.dependencies.doublezero-telemetry] path = "smartcontract/programs/doublezero-telemetry" features = ["no-entrypoint"] + +[workspace.dependencies.doublezero-geolocation] +path = "smartcontract/programs/doublezero-geolocation" +features = ["no-entrypoint"] diff --git a/Makefile b/Makefile index ebad1e880..b4599a019 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ rust-build-programs: .PHONY: rust-lint rust-lint: rust-fmt-check @cargo install cargo-hack - cargo hack clippy --workspace --all-targets --exclude doublezero-telemetry --exclude doublezero-serviceability --exclude doublezero-program-common --exclude doublezero-record -- -Dclippy::all -Dwarnings + cargo hack clippy --workspace --all-targets --exclude doublezero-telemetry --exclude doublezero-serviceability --exclude doublezero-program-common --exclude doublezero-record --exclude doublezero-geolocation -- -Dclippy::all -Dwarnings cd smartcontract && $(MAKE) lint-programs .PHONY: rust-fmt @@ -94,7 +94,7 @@ rust-fmt-check: .PHONY: rust-test rust-test: - cargo test --workspace --exclude doublezero-telemetry --exclude doublezero-serviceability --exclude doublezero-program-common --exclude doublezero-record --all-features + cargo test --workspace --exclude doublezero-telemetry --exclude doublezero-serviceability --exclude doublezero-program-common --exclude doublezero-record --exclude doublezero-geolocation --all-features cd smartcontract && $(MAKE) test-programs $(MAKE) rust-program-accounts-compat diff --git a/rfcs/rfc16-geolocation-verification.md b/rfcs/rfc16-geolocation-verification.md index 85d6cfccd..a659e32d5 100644 --- a/rfcs/rfc16-geolocation-verification.md +++ b/rfcs/rfc16-geolocation-verification.md @@ -194,10 +194,10 @@ pub struct GeoProbe { pub exchange_pk: Pubkey, // Reference to Serviceability Exchange account pub public_ip: Ipv4Addr, // Where probe listens pub location_offset_port: u16, // UDP listen port (default 8923) - pub code: String, // e.g., "ams-probe-01" (max 32 bytes) - pub parent_devices: Vec, // DZDs that measure this probe pub metrics_publisher_pk: Pubkey, // Signing key for telemetry pub reference_count: u32, // GeolocationTargets referencing this probe + pub code: String, // e.g., "ams-probe-01" (max 32 bytes) + pub parent_devices: Vec, // DZDs that measure this probe } ``` **PDA Seeds:** `["doublezero", "probe", code.as_bytes()]` diff --git a/smartcontract/Makefile b/smartcontract/Makefile index 8926d3011..70f6e3d07 100644 --- a/smartcontract/Makefile +++ b/smartcontract/Makefile @@ -26,7 +26,8 @@ test-programs: test-sbf -p doublezero-program-common \ -p doublezero-record \ -p doublezero-telemetry \ - -p doublezero-serviceability + -p doublezero-serviceability \ + -p doublezero-geolocation .PHONY: test-sbf test-sbf: @@ -44,6 +45,7 @@ lint-programs: -p doublezero-record \ -p doublezero-telemetry \ -p doublezero-serviceability \ + -p doublezero-geolocation \ $(CLIPPY_FLAGS) .PHONY: build @@ -56,3 +58,4 @@ build-programs: cd programs/doublezero-record && cargo build-sbf cd programs/doublezero-serviceability && cargo build-sbf cd programs/doublezero-telemetry && cargo build-sbf --features $(env) + cd programs/doublezero-geolocation && cargo build-sbf --features $(env) diff --git a/smartcontract/programs/CLAUDE.md b/smartcontract/programs/CLAUDE.md new file mode 100644 index 000000000..a48f151a4 --- /dev/null +++ b/smartcontract/programs/CLAUDE.md @@ -0,0 +1,33 @@ +## Smartcontract Development Best Practices + +### 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`. + +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. + +3. **PDA validation**: When validating PDAs with expected seeds/bumps, you don't need to separately check the account derivation before the PDA validation - the PDA validation itself confirms the derivation is correct. + +### Error Handling + +1. **Simplify error enum conversions**: Use `#[repr(u32)]` on your error enum and implement `From for ProgramError` using `as u32`. This eliminates the need to manually maintain error code mappings when adding new variants. Remove `Custom(u32)` variants unless there's a specific use case. + +2. **Clear error messages**: Error messages should clearly state what condition is expected, not just what failed. For example, use "Cannot delete GeoProbe. reference_count of {n} > 0" instead of "ReferenceCountNotZero" so users understand what needs to be true. + +### Code Organization + +1. **Instruction struct placement**: Place instruction argument structs in the same file where the instruction is implemented, rather than collecting them all in a central `instructions.rs` file. This improves locality and makes it easier to understand what arguments an instruction uses. + +2. **Minimize stored data**: Don't store bump seeds unless the account needs to sign for something. Bump seeds are only needed for CPI signing, not for PDA validation. + +3. **Avoid redundant instruction arguments**: If you're passing an account, don't also pass that account's pubkey as an instruction argument and then check they match. Just use the account's key directly. + +### Serialization + +1. **Prefer standard derives**: Use `BorshDeserialize` when possible instead of implementing custom deserialization. Custom `unpack()` methods that manually match on instruction indices often duplicate what Borsh's derive already provides. + +2. **Use BorshDeserializeIncremental**: For instruction arguments that may gain new optional fields over time, use `BorshDeserializeIncremental` or derive `BorshDeserialize`. + +### 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/Cargo.toml b/smartcontract/programs/doublezero-geolocation/Cargo.toml new file mode 100644 index 000000000..06e9ca8c3 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "doublezero-geolocation" + +# clippy ignores rust-version.toml +rust-version = "1.84.1" # cargo build-sbf --version + +# Workspace inherited keys +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +borsh.workspace = true +borsh-incremental.workspace = true +serde = { workspace = true, optional = true } +serde_bytes = { workspace = true, optional = true } +solana-bincode.workspace = true +solana-loader-v3-interface.workspace = true +solana-program.workspace = true +doublezero-program-common.workspace = true +doublezero-serviceability.workspace = true +thiserror.workspace = true + +[dev-dependencies] +bincode.workspace = true +solana-sdk.workspace = true +solana-program-test.workspace = true + +[build-dependencies] +doublezero-config.workspace = true + +[features] +default = [] +localnet = [] +testnet = [] +devnet = [] +mainnet-beta = [] +no-entrypoint = [] +serde = ["dep:serde", "dep:serde_bytes"] diff --git a/smartcontract/programs/doublezero-geolocation/build.rs b/smartcontract/programs/doublezero-geolocation/build.rs new file mode 100644 index 000000000..3d416ec13 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/build.rs @@ -0,0 +1,47 @@ +use std::{env, fs, path::Path}; + +use doublezero_config::Environment; + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest = Path::new(&out_dir).join("build_constants.rs"); + + // Determine the network environment config with serviceability program ID based on the + // features through cargo environment variables during build, so that we can validate and print + // the values being used at build-time. + // The CARGO_FEATURE_* env variables are set by cargo for build scripts based on the features + // defined in the Cargo.toml file. + let environment: Option = if std::env::var("CARGO_FEATURE_MAINNET_BETA").is_ok() { + Some(Environment::MainnetBeta) + } else if std::env::var("CARGO_FEATURE_TESTNET").is_ok() { + Some(Environment::Testnet) + } else if std::env::var("CARGO_FEATURE_DEVNET").is_ok() { + Some(Environment::Devnet) + } else { + None + }; + + let (env_code, serviceability_program_id) = match environment { + Some(environment) => ( + environment.to_string(), + environment + .config() + .unwrap() + .serviceability_program_id + .to_string(), + ), + None => ( + "localnet".to_string(), + "7CTniUa88iJKUHTrCkB4TjAoG6TD7AMivhQeuqN2LPtX".to_string(), + ), + }; + + println!("cargo:warning=Environment: {env_code}"); + println!("cargo:warning=Serviceability Program ID: {serviceability_program_id}"); + + fs::write( + dest, + format!(r#"pub const SERVICEABILITY_PROGRAM_ID: &str = "{serviceability_program_id}";"#), + ) + .unwrap(); +} diff --git a/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs b/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs new file mode 100644 index 000000000..d974f1950 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/entrypoint.rs @@ -0,0 +1,36 @@ +use crate::{ + instructions::GeolocationInstruction, + processors::program_config::{ + init::process_init_program_config, update::process_update_program_config, + }, +}; + +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError, + pubkey::Pubkey, +}; + +#[cfg(not(feature = "no-entrypoint"))] +solana_program::entrypoint!(process_instruction); + +pub fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + data: &[u8], +) -> ProgramResult { + let instruction: GeolocationInstruction = + borsh::from_slice(data).map_err(|_| ProgramError::InvalidInstructionData)?; + + msg!("Instruction: {:?}", instruction); + + match instruction { + GeolocationInstruction::InitProgramConfig(args) => { + process_init_program_config(program_id, accounts, &args)? + } + GeolocationInstruction::UpdateProgramConfig(args) => { + process_update_program_config(program_id, accounts, &args)? + } + }; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/error.rs b/smartcontract/programs/doublezero-geolocation/src/error.rs new file mode 100644 index 000000000..fd9ebdcbb --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/error.rs @@ -0,0 +1,63 @@ +use solana_program::program_error::ProgramError; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Clone)] +#[repr(u32)] +pub enum GeolocationError { + #[error("Invalid account type")] + InvalidAccountType = 1, + #[error("Not allowed")] + NotAllowed = 2, + #[error("Unauthorized: payer is not the upgrade authority")] + UnauthorizedInitializer = 17, + #[error("min_compatible_version cannot exceed version")] + InvalidMinCompatibleVersion = 18, +} + +impl From for ProgramError { + fn from(e: GeolocationError) -> Self { + ProgramError::Custom(e as u32) + } +} + +pub trait Validate { + fn validate(&self) -> Result<(), GeolocationError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_variants() -> Vec<(GeolocationError, u32)> { + vec![ + (GeolocationError::InvalidAccountType, 1), + (GeolocationError::NotAllowed, 2), + (GeolocationError::UnauthorizedInitializer, 17), + (GeolocationError::InvalidMinCompatibleVersion, 18), + ] + } + + #[test] + fn test_error_codes() { + for (variant, expected_code) in all_variants() { + let program_error: ProgramError = variant.clone().into(); + let ProgramError::Custom(code) = program_error else { + panic!("expected ProgramError::Custom for {:?}", variant); + }; + assert_eq!( + code, expected_code, + "variant {:?} should map to code {}", + variant, expected_code + ); + } + } + + #[test] + fn test_error_display_messages() { + assert_eq!( + GeolocationError::InvalidAccountType.to_string(), + "Invalid account type" + ); + assert_eq!(GeolocationError::NotAllowed.to_string(), "Not allowed"); + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/instructions.rs b/smartcontract/programs/doublezero-geolocation/src/instructions.rs new file mode 100644 index 000000000..027cd0d63 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/instructions.rs @@ -0,0 +1,42 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +pub use crate::processors::program_config::{ + init::InitProgramConfigArgs, update::UpdateProgramConfigArgs, +}; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub enum GeolocationInstruction { + InitProgramConfig(InitProgramConfigArgs), + UpdateProgramConfig(UpdateProgramConfigArgs), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip_all_instructions() { + let cases = vec![ + GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), + GeolocationInstruction::UpdateProgramConfig(UpdateProgramConfigArgs { + version: Some(2), + min_compatible_version: Some(1), + }), + 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] + fn test_deserialize_invalid() { + assert!(borsh::from_slice::(&[]).is_err()); + assert!(borsh::from_slice::(&[255]).is_err()); + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/lib.rs b/smartcontract/programs/doublezero-geolocation/src/lib.rs new file mode 100644 index 000000000..3252a53bf --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/lib.rs @@ -0,0 +1,28 @@ +#![allow(unexpected_cfgs)] + +#[cfg(any(not(feature = "no-entrypoint"), test))] +pub mod entrypoint; + +pub mod error; +pub mod instructions; +pub mod pda; +pub mod processors; +pub mod seeds; +mod serializer; +pub mod state; + +use solana_program::pubkey::Pubkey; + +#[cfg(not(test))] +mod build_constants { + include!(concat!(env!("OUT_DIR"), "/build_constants.rs")); +} + +pub const fn serviceability_program_id() -> Pubkey { + Pubkey::from_str_const(crate::build_constants::SERVICEABILITY_PROGRAM_ID) +} + +#[cfg(test)] +mod build_constants { + pub const SERVICEABILITY_PROGRAM_ID: &str = ""; +} diff --git a/smartcontract/programs/doublezero-geolocation/src/pda.rs b/smartcontract/programs/doublezero-geolocation/src/pda.rs new file mode 100644 index 000000000..17a90b45d --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/pda.rs @@ -0,0 +1,7 @@ +use solana_program::pubkey::Pubkey; + +use crate::seeds::{SEED_PREFIX, 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) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs b/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs new file mode 100644 index 000000000..0e13e5d9f --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/mod.rs @@ -0,0 +1,38 @@ +pub mod program_config; + +use crate::{error::GeolocationError, state::program_config::GeolocationProgramConfig}; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; + +pub fn check_foundation_allowlist( + program_config_account: &AccountInfo, + serviceability_globalstate_account: &AccountInfo, + payer_account: &AccountInfo, + program_id: &Pubkey, +) -> Result { + if program_config_account.owner != program_id { + msg!("Invalid ProgramConfig Account Owner"); + return Err(ProgramError::IllegalOwner); + } + + let program_config = GeolocationProgramConfig::try_from(program_config_account)?; + + let serviceability_program_id = &crate::serviceability_program_id(); + if serviceability_globalstate_account.owner != serviceability_program_id { + msg!( + "Expected serviceability program: {}, got: {}", + serviceability_program_id, + serviceability_globalstate_account.owner + ); + return Err(ProgramError::IncorrectProgramId); + } + + let globalstate = doublezero_serviceability::state::globalstate::GlobalState::try_from( + serviceability_globalstate_account, + )?; + + if !globalstate.foundation_allowlist.contains(payer_account.key) { + return Err(GeolocationError::NotAllowed.into()); + } + + Ok(program_config) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/program_config/init.rs b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/init.rs new file mode 100644 index 000000000..bcb043699 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/init.rs @@ -0,0 +1,99 @@ +use crate::{ + error::GeolocationError, + pda::get_program_config_pda, + seeds::{SEED_PREFIX, SEED_PROGRAM_CONFIG}, + serializer::try_acc_create, + state::{accounttype::AccountType, program_config::GeolocationProgramConfig}, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +use super::parse_upgrade_authority; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub struct InitProgramConfigArgs {} + +pub fn process_init_program_config( + program_id: &Pubkey, + accounts: &[AccountInfo], + _args: &InitProgramConfigArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let program_config_account = next_account_info(accounts_iter)?; + let program_data_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); + } + if !program_config_account.is_writable { + msg!("ProgramConfig must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + // Verify the program data account derives from the program id + let expected_program_data = Pubkey::find_program_address( + &[program_id.as_ref()], + &solana_program::bpf_loader_upgradeable::id(), + ) + .0; + if program_data_account.key != &expected_program_data { + msg!("Invalid program data account"); + return Err(ProgramError::InvalidAccountData); + } + + // Verify payer is the program's upgrade authority + let program_data = program_data_account.try_borrow_data()?; + match parse_upgrade_authority(&program_data)? { + Some(authority) if authority == *payer_account.key => {} + Some(authority) => { + msg!( + "Payer {} is not the upgrade authority {}", + payer_account.key, + authority + ); + return Err(GeolocationError::UnauthorizedInitializer.into()); + } + None => { + msg!("Program has no upgrade authority (immutable)"); + return Err(ProgramError::InvalidAccountData); + } + } + + let (expected_pda, bump_seed) = get_program_config_pda(program_id); + if program_config_account.key != &expected_pda { + msg!("Invalid ProgramConfig Pubkey"); + return Err(ProgramError::InvalidSeeds); + } + + if !program_config_account.data_is_empty() { + return Err(ProgramError::AccountAlreadyInitialized); + } + + let program_config = GeolocationProgramConfig { + account_type: AccountType::ProgramConfig, + bump_seed, + version: 1, + min_compatible_version: 1, + }; + + try_acc_create( + &program_config, + program_config_account, + payer_account, + system_program, + program_id, + &[SEED_PREFIX, SEED_PROGRAM_CONFIG, &[bump_seed]], + )?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/program_config/mod.rs b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/mod.rs new file mode 100644 index 000000000..7fcaecf68 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/mod.rs @@ -0,0 +1,21 @@ +pub mod init; +pub mod update; + +use solana_bincode::limited_deserialize; +use solana_loader_v3_interface::state::UpgradeableLoaderState; +use solana_program::{program_error::ProgramError, pubkey::Pubkey}; + +pub(crate) fn parse_upgrade_authority(data: &[u8]) -> Result, ProgramError> { + let state: UpgradeableLoaderState = limited_deserialize( + data, + UpgradeableLoaderState::size_of_programdata_metadata() as u64, + ) + .map_err(|_| ProgramError::InvalidAccountData)?; + match state { + UpgradeableLoaderState::ProgramData { + upgrade_authority_address, + .. + } => Ok(upgrade_authority_address), + _ => Err(ProgramError::InvalidAccountData), + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/program_config/update.rs b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/update.rs new file mode 100644 index 000000000..a3143a1a5 --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/processors/program_config/update.rs @@ -0,0 +1,107 @@ +use crate::{ + error::GeolocationError, serializer::try_acc_write, + state::program_config::GeolocationProgramConfig, +}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +use super::parse_upgrade_authority; + +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub struct UpdateProgramConfigArgs { + pub version: Option, + pub min_compatible_version: Option, +} + +pub fn process_update_program_config( + program_id: &Pubkey, + accounts: &[AccountInfo], + args: &UpdateProgramConfigArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let program_config_account = next_account_info(accounts_iter)?; + let program_data_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); + } + if system_program.key != &solana_program::system_program::id() { + msg!("Invalid System Program account"); + return Err(ProgramError::IncorrectProgramId); + } + if !program_config_account.is_writable { + msg!("ProgramConfig must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if program_config_account.owner != program_id { + msg!("Invalid ProgramConfig Account Owner"); + return Err(ProgramError::IllegalOwner); + } + + // Verify the program data account is owned by the BPF Upgradeable Loader + if program_data_account.owner != &solana_program::bpf_loader_upgradeable::id() { + msg!("Program data account not owned by BPF Upgradeable Loader"); + return Err(ProgramError::IllegalOwner); + } + + // Verify the program data account derives from the program id + let expected_program_data = Pubkey::find_program_address( + &[program_id.as_ref()], + &solana_program::bpf_loader_upgradeable::id(), + ) + .0; + if program_data_account.key != &expected_program_data { + msg!("Invalid program data account"); + return Err(ProgramError::InvalidAccountData); + } + + // Verify payer is the program's upgrade authority + let program_data = program_data_account.try_borrow_data()?; + match parse_upgrade_authority(&program_data)? { + Some(authority) if authority == *payer_account.key => {} + Some(authority) => { + msg!( + "Payer {} is not the upgrade authority {}", + payer_account.key, + authority + ); + return Err(GeolocationError::UnauthorizedInitializer.into()); + } + None => { + msg!("Program has no upgrade authority (immutable)"); + return Err(ProgramError::InvalidAccountData); + } + } + drop(program_data); + + let mut program_config = GeolocationProgramConfig::try_from(program_config_account)?; + + if let Some(version) = args.version { + program_config.version = version; + } + if let Some(min_compatible_version) = args.min_compatible_version { + program_config.min_compatible_version = min_compatible_version; + } + if program_config.min_compatible_version > program_config.version { + return Err(GeolocationError::InvalidMinCompatibleVersion.into()); + } + + try_acc_write( + &program_config, + program_config_account, + payer_account, + accounts, + )?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/seeds.rs b/smartcontract/programs/doublezero-geolocation/src/seeds.rs new file mode 100644 index 000000000..4fc9f6f0f --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/seeds.rs @@ -0,0 +1,2 @@ +pub const SEED_PREFIX: &[u8] = b"doublezero"; +pub const SEED_PROGRAM_CONFIG: &[u8] = b"programconfig"; diff --git a/smartcontract/programs/doublezero-geolocation/src/serializer.rs b/smartcontract/programs/doublezero-geolocation/src/serializer.rs new file mode 100644 index 000000000..2f41902bd --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/serializer.rs @@ -0,0 +1,75 @@ +use crate::error::Validate; +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}; + +#[cfg(test)] +use solana_program::msg; + +pub fn try_acc_create<'a, T>( + value: &T, + account: &AccountInfo<'a>, + payer_account: &AccountInfo<'a>, + system_program: &AccountInfo<'a>, + program_id: &Pubkey, + new_account_signer_seeds: &[&[u8]], +) -> ProgramResult +where + T: BorshSerialize + Validate + std::fmt::Debug, +{ + value.validate()?; + + let account_space = borsh::object_length(value)?; + + #[cfg(test)] + { + use solana_sdk::{rent::Rent, sysvar::Sysvar}; + + let rent = Rent::get().expect("Unable to get rent"); + let required_lamports = rent.minimum_balance(account_space); + msg!("Rent: {}", required_lamports); + } + + try_create_account( + payer_account.key, + account.key, + account.lamports(), + account_space, + program_id, + &[ + account.clone(), + payer_account.clone(), + system_program.clone(), + ], + new_account_signer_seeds, + )?; + + let mut account_data = &mut account.data.borrow_mut()[..]; + value.serialize(&mut account_data)?; + + #[cfg(test)] + msg!("Created: {:?}", value); + + Ok(()) +} + +pub fn try_acc_write( + value: &T, + account: &AccountInfo, + payer: &AccountInfo, + accounts: &[AccountInfo], +) -> ProgramResult +where + T: Validate + borsh::BorshSerialize, +{ + value.validate()?; + + resize_account_if_needed(account, payer, accounts, borsh::object_length(value)?)?; + + let mut data = &mut account.data.borrow_mut()[..]; + value.serialize(&mut data)?; + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs b/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs new file mode 100644 index 000000000..8944cf77b --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/state/accounttype.rs @@ -0,0 +1,30 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use std::fmt; + +#[repr(u8)] +#[derive(BorshSerialize, BorshDeserialize, Debug, Default, Copy, Clone, PartialEq)] +#[borsh(use_discriminant = true)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AccountType { + #[default] + None = 0, + ProgramConfig = 1, +} + +impl From for AccountType { + fn from(value: u8) -> Self { + match value { + 1 => AccountType::ProgramConfig, + _ => AccountType::None, + } + } +} + +impl fmt::Display for AccountType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccountType::None => write!(f, "none"), + AccountType::ProgramConfig => write!(f, "programconfig"), + } + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/state/mod.rs b/smartcontract/programs/doublezero-geolocation/src/state/mod.rs new file mode 100644 index 000000000..58fc57bcb --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/state/mod.rs @@ -0,0 +1,2 @@ +pub mod accounttype; +pub mod program_config; diff --git a/smartcontract/programs/doublezero-geolocation/src/state/program_config.rs b/smartcontract/programs/doublezero-geolocation/src/state/program_config.rs new file mode 100644 index 000000000..8cd3180ff --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/src/state/program_config.rs @@ -0,0 +1,120 @@ +use crate::{ + error::{GeolocationError, Validate}, + state::accounttype::AccountType, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GeolocationProgramConfig { + pub account_type: AccountType, // 1 + pub bump_seed: u8, // 1 + pub version: u32, // 4 + pub min_compatible_version: u32, // 4 +} + +impl fmt::Display for GeolocationProgramConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "account_type: {}, bump_seed: {}, version: {}, min_compatible_version: {}", + self.account_type, self.bump_seed, self.version, self.min_compatible_version, + ) + } +} + +impl TryFrom<&AccountInfo<'_>> for GeolocationProgramConfig { + type Error = ProgramError; + + fn try_from(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + let config = Self::try_from(&data[..]).map_err(|e| { + msg!("Failed to deserialize GeolocationProgramConfig: {}", e); + ProgramError::InvalidAccountData + })?; + if config.account_type != AccountType::ProgramConfig { + msg!("Invalid account type: {}", config.account_type); + return Err(ProgramError::InvalidAccountData); + } + Ok(config) + } +} + +impl Validate for GeolocationProgramConfig { + fn validate(&self) -> Result<(), GeolocationError> { + if self.account_type != AccountType::ProgramConfig { + msg!("Invalid account type: {}", self.account_type); + return Err(GeolocationError::InvalidAccountType); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_programconfig_try_from_defaults() { + let data = [AccountType::ProgramConfig as u8]; + let val = GeolocationProgramConfig::try_from(&data[..]).unwrap(); + + assert_eq!(val.version, 0); + assert_eq!(val.min_compatible_version, 0); + } + + #[test] + fn test_state_programconfig_serialization() { + let val = GeolocationProgramConfig { + account_type: AccountType::ProgramConfig, + bump_seed: 1, + version: 3, + min_compatible_version: 1, + }; + + let data = borsh::to_vec(&val).unwrap(); + let val2 = GeolocationProgramConfig::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.version, val2.version); + assert_eq!(val.min_compatible_version, val2.min_compatible_version); + assert_eq!( + data.len(), + borsh::object_length(&val).unwrap(), + "Invalid Size" + ); + } + + #[test] + fn test_state_programconfig_validate_error_invalid_account_type() { + let val = GeolocationProgramConfig { + account_type: AccountType::None, + bump_seed: 1, + version: 3, + min_compatible_version: 1, + }; + assert_eq!( + val.validate().unwrap_err(), + GeolocationError::InvalidAccountType + ); + } + + #[test] + fn test_state_programconfig_try_from_invalid_account_type() { + let data = [AccountType::None as u8]; + let val = GeolocationProgramConfig::try_from(&data[..]).unwrap(); + assert_eq!( + val.validate().unwrap_err(), + GeolocationError::InvalidAccountType + ); + } +} diff --git a/smartcontract/programs/doublezero-geolocation/tests/update_program_config_test.rs b/smartcontract/programs/doublezero-geolocation/tests/update_program_config_test.rs new file mode 100644 index 000000000..4cff9e66c --- /dev/null +++ b/smartcontract/programs/doublezero-geolocation/tests/update_program_config_test.rs @@ -0,0 +1,240 @@ +use doublezero_geolocation::{ + entrypoint::process_instruction, + error::GeolocationError, + instructions::GeolocationInstruction, + pda::get_program_config_pda, + processors::program_config::{init::InitProgramConfigArgs, update::UpdateProgramConfigArgs}, + state::program_config::GeolocationProgramConfig, +}; +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, InstructionError}, + pubkey::Pubkey, + signature::Signer, + transaction::{Transaction, TransactionError}, +}; + +/// Builds a bincode-serialized UpgradeableLoaderState::ProgramData account +/// with the given upgrade authority. This is needed because ProgramTest with +/// processor!() deploys programs as builtins without creating the BPF +/// upgradeable loader program_data account that the geolocation program +/// requires for upgrade-authority verification. +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 +} + +fn build_accounts(program_id: &Pubkey, payer: &Pubkey) -> Vec { + let (program_config_pda, _) = get_program_config_pda(program_id); + let (program_data_pda, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::id()); + + vec![ + AccountMeta::new(program_config_pda, false), + AccountMeta::new_readonly(program_data_pda, false), + AccountMeta::new(*payer, true), + AccountMeta::new_readonly(solana_program::system_program::id(), false), + ] +} + +fn build_instruction( + program_id: &Pubkey, + instruction: &GeolocationInstruction, + accounts: Vec, +) -> Instruction { + Instruction::new_with_bytes(*program_id, &borsh::to_vec(instruction).unwrap(), accounts) +} + +async fn read_program_config( + banks_client: &mut BanksClient, + program_id: &Pubkey, +) -> GeolocationProgramConfig { + let (pda, _) = get_program_config_pda(program_id); + let account = banks_client + .get_account(pda) + .await + .unwrap() + .expect("ProgramConfig account must exist"); + GeolocationProgramConfig::try_from(&account.data[..]).unwrap() +} + +async fn setup() -> (BanksClient, solana_sdk::signature::Keypair, Pubkey) { + let program_id = Pubkey::new_unique(); + let program_test = ProgramTest::new( + "doublezero_geolocation", + program_id, + processor!(process_instruction), + ); + let mut context = program_test.start_with_context().await; + + // 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(&context.payer.pubkey()); + context.set_account(&program_data_pda, &program_data_account); + + (context.banks_client, context.payer, program_id) +} + +async fn init_program_config( + banks_client: &mut BanksClient, + payer: &solana_sdk::signature::Keypair, + program_id: &Pubkey, +) { + let accounts = build_accounts(program_id, &payer.pubkey()); + let ix = build_instruction( + program_id, + &GeolocationInstruction::InitProgramConfig(InitProgramConfigArgs {}), + accounts, + ); + let blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let mut tx = Transaction::new_with_payer(&[ix], Some(&payer.pubkey())); + tx.sign(&[payer], blockhash); + banks_client.process_transaction(tx).await.unwrap(); +} + +async fn send_update( + banks_client: &mut BanksClient, + payer: &solana_sdk::signature::Keypair, + program_id: &Pubkey, + args: UpdateProgramConfigArgs, +) -> Result<(), BanksClientError> { + let accounts = build_accounts(program_id, &payer.pubkey()); + let ix = build_instruction( + program_id, + &GeolocationInstruction::UpdateProgramConfig(args), + accounts, + ); + let blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let mut tx = Transaction::new_with_payer(&[ix], Some(&payer.pubkey())); + tx.sign(&[payer], blockhash); + banks_client.process_transaction(tx).await +} + +fn assert_geolocation_error(result: Result<(), BanksClientError>, expected: GeolocationError) { + let expected_code = expected.clone() as u32; + match result { + Ok(_) => panic!("Expected error {:?}, but got Ok", expected), + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + ))) => assert_eq!( + code, expected_code, + "Expected {:?} ({}), got {}", + expected, expected_code, code + ), + Err(other) => panic!("Expected {:?}, got {:?}", expected, other), + } +} + +#[tokio::test] +async fn test_update_program_config_version_downgrade_below_min_compatible_version() { + let (mut banks_client, payer, program_id) = setup().await; + init_program_config(&mut banks_client, &payer, &program_id).await; + + // Bump to version=5, min_compatible_version=3 + send_update( + &mut banks_client, + &payer, + &program_id, + UpdateProgramConfigArgs { + version: Some(5), + min_compatible_version: Some(3), + }, + ) + .await + .unwrap(); + + let config = read_program_config(&mut banks_client, &program_id).await; + assert_eq!(config.version, 5); + assert_eq!(config.min_compatible_version, 3); + + // Attempt to downgrade version below the existing min_compatible_version + let result = send_update( + &mut banks_client, + &payer, + &program_id, + UpdateProgramConfigArgs { + version: Some(1), + min_compatible_version: None, + }, + ) + .await; + assert_geolocation_error(result, GeolocationError::InvalidMinCompatibleVersion); + + // State must be unchanged + let config = read_program_config(&mut banks_client, &program_id).await; + assert_eq!(config.version, 5); + assert_eq!(config.min_compatible_version, 3); +} + +#[tokio::test] +async fn test_update_program_config_min_compatible_version_exceeds_version() { + let (mut banks_client, payer, program_id) = setup().await; + init_program_config(&mut banks_client, &payer, &program_id).await; + + // min_compatible_version=5 exceeds current version=1 + let result = send_update( + &mut banks_client, + &payer, + &program_id, + UpdateProgramConfigArgs { + version: None, + min_compatible_version: Some(5), + }, + ) + .await; + assert_geolocation_error(result, GeolocationError::InvalidMinCompatibleVersion); +} + +#[tokio::test] +async fn test_update_program_config_success() { + let (mut banks_client, payer, program_id) = setup().await; + init_program_config(&mut banks_client, &payer, &program_id).await; + + let config = read_program_config(&mut banks_client, &program_id).await; + assert_eq!(config.version, 1); + assert_eq!(config.min_compatible_version, 1); + + // Update version to 5 + send_update( + &mut banks_client, + &payer, + &program_id, + UpdateProgramConfigArgs { + version: Some(5), + min_compatible_version: None, + }, + ) + .await + .unwrap(); + + // Update min_compatible_version to 3 + send_update( + &mut banks_client, + &payer, + &program_id, + UpdateProgramConfigArgs { + version: None, + min_compatible_version: Some(3), + }, + ) + .await + .unwrap(); + + let config = read_program_config(&mut banks_client, &program_id).await; + assert_eq!(config.version, 5); + assert_eq!(config.min_compatible_version, 3); +}