diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc6ee656c..31413865e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. - Smartcontract - Allow `SubscribeMulticastGroup` for users in `Pending` status so that `CreateSubscribeUser` can be followed by additional subscribe calls before the activator runs ([#3521](https://github.com/malbeclabs/doublezero/pull/3521)) - Add optional `owner` field to `UpdateMulticastGroup` instruction, allowing foundation members to reassign ownership of a multicast group ([#3527](https://github.com/malbeclabs/doublezero/pull/3527)) +- Geolocation + - Add optional result destination to `GeolocationUser` so LocationOffsets can be sent to an alternate endpoint instead of the target IP; supports both IP and domain destinations (e.g., `185.199.108.1:9000` or `results.example.com:9000`); includes `SetResultDestination` onchain instruction, CLI `user set-result-destination` command, and Go SDK deserialization (backwards-compatible with existing accounts) - CLI - Add `--owner` flag to `multicast group update`, accepting a pubkey or `me` ([#3527](https://github.com/malbeclabs/doublezero/pull/3527)) diff --git a/client/doublezero-geolocation-cli/src/cli/user.rs b/client/doublezero-geolocation-cli/src/cli/user.rs index 8483dc6ff4..99a5b8df9f 100644 --- a/client/doublezero-geolocation-cli/src/cli/user.rs +++ b/client/doublezero-geolocation-cli/src/cli/user.rs @@ -3,6 +3,7 @@ use doublezero_cli::geolocation::user::{ add_target::AddTargetCliCommand, create::CreateGeolocationUserCliCommand, delete::DeleteGeolocationUserCliCommand, get::GetGeolocationUserCliCommand, list::ListGeolocationUserCliCommand, remove_target::RemoveTargetCliCommand, + set_result_destination::SetResultDestinationCliCommand, update_payment_status::UpdatePaymentStatusCliCommand, }; @@ -26,6 +27,8 @@ pub enum UserCommands { AddTarget(AddTargetCliCommand), /// Remove a target from a user RemoveTarget(RemoveTargetCliCommand), + /// Set result destination for geolocation results + SetResultDestination(SetResultDestinationCliCommand), /// Update payment status (foundation-only) UpdatePayment(UpdatePaymentStatusCliCommand), } diff --git a/client/doublezero-geolocation-cli/src/main.rs b/client/doublezero-geolocation-cli/src/main.rs index 33e22e673b..9ae3e0723f 100644 --- a/client/doublezero-geolocation-cli/src/main.rs +++ b/client/doublezero-geolocation-cli/src/main.rs @@ -121,6 +121,7 @@ fn main() -> eyre::Result<()> { UserCommands::List(args) => args.execute(&client, &mut handle), UserCommands::AddTarget(args) => args.execute(&client, &mut handle), UserCommands::RemoveTarget(args) => args.execute(&client, &mut handle), + UserCommands::SetResultDestination(args) => args.execute(&client, &mut handle), UserCommands::UpdatePayment(args) => args.execute(&client, &mut handle), }, Command::InitConfig(args) => args.execute(&client, &mut handle), diff --git a/sdk/geolocation/go/state.go b/sdk/geolocation/go/state.go index 4f3a378e9e..b792de8fbe 100644 --- a/sdk/geolocation/go/state.go +++ b/sdk/geolocation/go/state.go @@ -327,14 +327,15 @@ type KeyedGeolocationUser struct { } type GeolocationUser struct { - AccountType AccountType // 1 byte - Owner solana.PublicKey // 32 bytes - Code string // 4-byte length prefix + UTF-8 bytes - TokenAccount solana.PublicKey // 32 bytes - PaymentStatus GeolocationPaymentStatus // 1 byte - Billing GeolocationBillingConfig // 1 + 16 = 17 bytes - Status GeolocationUserStatus // 1 byte - Targets []GeolocationTarget // 4-byte count + 71*N bytes + AccountType AccountType // 1 byte + Owner solana.PublicKey // 32 bytes + Code string // 4-byte length prefix + UTF-8 bytes + TokenAccount solana.PublicKey // 32 bytes + PaymentStatus GeolocationPaymentStatus // 1 byte + Billing GeolocationBillingConfig // 1 + 16 = 17 bytes + Status GeolocationUserStatus // 1 byte + Targets []GeolocationTarget // 4-byte count + 71*N bytes + ResultDestination string // 4-byte length prefix + UTF-8 bytes (empty = unset) } func (g *GeolocationUser) Serialize(w io.Writer) error { @@ -369,6 +370,9 @@ func (g *GeolocationUser) Serialize(w io.Writer) error { return err } } + if err := enc.Encode(g.ResultDestination); err != nil { + return err + } return nil } @@ -446,5 +450,10 @@ func (g *GeolocationUser) Deserialize(data []byte) error { return err } } + // ResultDestination is appended; old accounts without it default to empty string. + if err := dec.Decode(&g.ResultDestination); err != nil { + g.ResultDestination = "" + return nil + } return nil } diff --git a/sdk/geolocation/go/state_test.go b/sdk/geolocation/go/state_test.go index 72cca51a76..68526b4a00 100644 --- a/sdk/geolocation/go/state_test.go +++ b/sdk/geolocation/go/state_test.go @@ -190,6 +190,7 @@ func TestSDK_Geolocation_State_GeolocationUser_RoundTrip(t *testing.T) { GeoProbePK: solana.NewWallet().PublicKey(), }, }, + ResultDestination: "185.199.108.1:9000", } var buf bytes.Buffer @@ -208,6 +209,7 @@ func TestSDK_Geolocation_State_GeolocationUser_RoundTrip(t *testing.T) { require.Len(t, decoded.Targets, 2) require.Equal(t, original.Targets[0], decoded.Targets[0]) require.Equal(t, original.Targets[1], decoded.Targets[1]) + require.Equal(t, original.ResultDestination, decoded.ResultDestination) } func TestSDK_Geolocation_State_GeolocationUser_EmptyTargets(t *testing.T) { @@ -226,8 +228,9 @@ func TestSDK_Geolocation_State_GeolocationUser_EmptyTargets(t *testing.T) { LastDeductionDzEpoch: 0, }, }, - Status: geolocation.GeolocationUserStatusSuspended, - Targets: []geolocation.GeolocationTarget{}, + Status: geolocation.GeolocationUserStatusSuspended, + Targets: []geolocation.GeolocationTarget{}, + ResultDestination: "", } var buf bytes.Buffer @@ -242,6 +245,50 @@ func TestSDK_Geolocation_State_GeolocationUser_EmptyTargets(t *testing.T) { require.Equal(t, geolocation.GeolocationPaymentStatusDelinquent, decoded.PaymentStatus) } +func TestSDK_Geolocation_State_GeolocationUser_BackwardCompat_NoResultDestination(t *testing.T) { + t.Parallel() + + original := &geolocation.GeolocationUser{ + AccountType: geolocation.AccountTypeGeolocationUser, + Owner: solana.NewWallet().PublicKey(), + Code: "old-user", + TokenAccount: solana.NewWallet().PublicKey(), + PaymentStatus: geolocation.GeolocationPaymentStatusPaid, + Billing: geolocation.GeolocationBillingConfig{ + Variant: geolocation.BillingConfigFlatPerEpoch, + FlatPerEpoch: geolocation.FlatPerEpochConfig{ + Rate: 1000, + LastDeductionDzEpoch: 42, + }, + }, + Status: geolocation.GeolocationUserStatusActivated, + Targets: []geolocation.GeolocationTarget{ + { + TargetType: geolocation.GeoLocationTargetTypeOutbound, + IPAddress: [4]uint8{8, 8, 8, 8}, + LocationOffsetPort: 8923, + TargetPK: solana.PublicKey{}, + GeoProbePK: solana.NewWallet().PublicKey(), + }, + }, + ResultDestination: "", + } + + var buf bytes.Buffer + require.NoError(t, original.Serialize(&buf)) + + // Truncate the trailing 4 bytes (empty Borsh string = 4-byte length prefix) + // to simulate old data without the result_destination field. + data := buf.Bytes()[:buf.Len()-4] + + var decoded geolocation.GeolocationUser + require.NoError(t, decoded.Deserialize(data)) + + require.Equal(t, original.Owner, decoded.Owner) + require.Equal(t, original.Targets[0], decoded.Targets[0]) + require.Equal(t, "", decoded.ResultDestination) +} + func TestSDK_Geolocation_State_GeolocationTarget_RoundTrip(t *testing.T) { t.Parallel() diff --git a/smartcontract/cli/src/geoclicommand.rs b/smartcontract/cli/src/geoclicommand.rs index 173ea50684..5afc567761 100644 --- a/smartcontract/cli/src/geoclicommand.rs +++ b/smartcontract/cli/src/geoclicommand.rs @@ -11,6 +11,7 @@ use doublezero_sdk::{ add_target::AddTargetCommand, create::CreateGeolocationUserCommand, delete::DeleteGeolocationUserCommand, get::GetGeolocationUserCommand, list::ListGeolocationUserCommand, remove_target::RemoveTargetCommand, + set_result_destination::SetResultDestinationCommand, update_payment_status::UpdatePaymentStatusCommand, }, programconfig::init::InitProgramConfigCommand, @@ -53,6 +54,7 @@ pub trait GeoCliCommand { ) -> eyre::Result>; fn add_target(&self, cmd: AddTargetCommand) -> eyre::Result; fn remove_target(&self, cmd: RemoveTargetCommand) -> eyre::Result; + fn set_result_destination(&self, cmd: SetResultDestinationCommand) -> eyre::Result; fn update_payment_status(&self, cmd: UpdatePaymentStatusCommand) -> eyre::Result; fn resolve_exchange_pk(&self, pubkey_or_code: String) -> eyre::Result; @@ -155,6 +157,10 @@ impl GeoCliCommand for GeoCliCommandImpl<'_> { cmd.execute(self.client) } + fn set_result_destination(&self, cmd: SetResultDestinationCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn update_payment_status(&self, cmd: UpdatePaymentStatusCommand) -> eyre::Result { cmd.execute(self.client) } diff --git a/smartcontract/cli/src/geolocation/user/mod.rs b/smartcontract/cli/src/geolocation/user/mod.rs index 773fd15475..a078280b56 100644 --- a/smartcontract/cli/src/geolocation/user/mod.rs +++ b/smartcontract/cli/src/geolocation/user/mod.rs @@ -4,4 +4,5 @@ pub mod delete; pub mod get; pub mod list; pub mod remove_target; +pub mod set_result_destination; pub mod update_payment_status; diff --git a/smartcontract/cli/src/geolocation/user/set_result_destination.rs b/smartcontract/cli/src/geolocation/user/set_result_destination.rs new file mode 100644 index 0000000000..69bd7a4fa1 --- /dev/null +++ b/smartcontract/cli/src/geolocation/user/set_result_destination.rs @@ -0,0 +1,290 @@ +use crate::{geoclicommand::GeoCliCommand, validators::validate_code}; +use clap::Args; +use doublezero_geolocation::validation::validate_public_ip; +use doublezero_sdk::geolocation::geolocation_user::{ + get::GetGeolocationUserCommand, set_result_destination::SetResultDestinationCommand, +}; +use solana_sdk::pubkey::Pubkey; +use std::{io::Write, net::Ipv4Addr}; + +#[derive(Args, Debug)] +pub struct SetResultDestinationCliCommand { + /// User code + #[arg(long, value_parser = validate_code)] + pub user: String, + /// Destination as host:port (e.g., "185.199.108.1:9000" or "results.example.com:9000") + #[arg(long, conflicts_with = "clear")] + pub destination: Option, + /// Clear the result destination + #[arg(long)] + pub clear: bool, +} + +// RFC 1035 ยง2.3.4 +const MAX_DOMAIN_LENGTH: usize = 253; +const MAX_LABEL_LENGTH: usize = 63; + +fn validate_domain(host: &str) -> eyre::Result<()> { + if host.len() > MAX_DOMAIN_LENGTH { + return Err(eyre::eyre!( + "domain too long ({} chars, max {MAX_DOMAIN_LENGTH})", + host.len() + )); + } + let labels: Vec<&str> = host.split('.').collect(); + if labels.len() < 2 { + return Err(eyre::eyre!( + "domain must have at least two labels (e.g., \"example.com\")" + )); + } + for label in &labels { + if label.is_empty() || label.len() > MAX_LABEL_LENGTH { + return Err(eyre::eyre!("invalid domain label length: {}", label.len())); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(eyre::eyre!( + "domain label \"{}\" cannot start or end with a hyphen", + label + )); + } + if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(eyre::eyre!( + "domain label \"{}\" contains invalid characters", + label + )); + } + } + Ok(()) +} + +fn validate_destination(destination: &str) -> eyre::Result<()> { + let colon_pos = destination.rfind(':').ok_or_else(|| { + eyre::eyre!("invalid destination \"{destination}\": expected host:port format") + })?; + let host = &destination[..colon_pos]; + let port_str = &destination[colon_pos + 1..]; + + port_str + .parse::() + .map_err(|_| eyre::eyre!("invalid port \"{port_str}\": must be a number 0-65535"))?; + + if let Ok(ip) = host.parse::() { + validate_public_ip(&ip).map_err(|e| eyre::eyre!("invalid IP address {host}: {e}"))?; + return Ok(()); + } + + validate_domain(host)?; + Ok(()) +} + +impl SetResultDestinationCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + let destination = if self.clear { + String::new() + } else { + let dest = self + .destination + .ok_or_else(|| eyre::eyre!("--destination is required (or use --clear)"))?; + validate_destination(&dest)?; + dest + }; + + let (_, user) = client.get_geolocation_user(GetGeolocationUserCommand { + pubkey_or_code: self.user.clone(), + })?; + + let mut probe_pks: Vec = Vec::new(); + for target in &user.targets { + if !probe_pks.contains(&target.geoprobe_pk) { + probe_pks.push(target.geoprobe_pk); + } + } + + let sig = client.set_result_destination(SetResultDestinationCommand { + code: self.user, + destination, + probe_pks, + })?; + + writeln!(out, "Signature: {sig}")?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geoclicommand::MockGeoCliCommand; + use doublezero_geolocation::state::{ + accounttype::AccountType, + geolocation_user::{ + FlatPerEpochConfig, GeoLocationTargetType, GeolocationBillingConfig, + GeolocationPaymentStatus, GeolocationTarget, GeolocationUser, GeolocationUserStatus, + }, + }; + use mockall::predicate; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::net::Ipv4Addr; + + fn make_user(targets: Vec) -> GeolocationUser { + GeolocationUser { + account_type: AccountType::GeolocationUser, + owner: Pubkey::new_unique(), + code: "geo-user-01".to_string(), + token_account: Pubkey::new_unique(), + payment_status: GeolocationPaymentStatus::Paid, + billing: GeolocationBillingConfig::FlatPerEpoch(FlatPerEpochConfig { + rate: 1000, + last_deduction_dz_epoch: 42, + }), + status: GeolocationUserStatus::Activated, + targets, + result_destination: String::new(), + } + } + + #[test] + fn test_cli_set_result_destination() { + let mut client = MockGeoCliCommand::new(); + + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let probe_pk1 = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let probe_pk2 = Pubkey::from_str_const("GQ2UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcc"); + let signature = Signature::new_unique(); + + let user = make_user(vec![ + GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk1, + }, + GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(1, 1, 1, 1), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk2, + }, + GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(9, 9, 9, 9), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk1, + }, + ]); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: "geo-user-01".to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + client + .expect_set_result_destination() + .with(predicate::eq(SetResultDestinationCommand { + code: "geo-user-01".to_string(), + destination: "185.199.108.1:9000".to_string(), + probe_pks: vec![probe_pk1, probe_pk2], + })) + .returning(move |_| Ok(signature)); + + let mut output = Vec::new(); + let res = SetResultDestinationCliCommand { + user: "geo-user-01".to_string(), + destination: Some("185.199.108.1:9000".to_string()), + clear: false, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("Signature:")); + } + + #[test] + fn test_cli_set_result_destination_clear() { + let mut client = MockGeoCliCommand::new(); + + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let signature = Signature::new_unique(); + + let user = make_user(vec![]); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: "geo-user-01".to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + client + .expect_set_result_destination() + .with(predicate::eq(SetResultDestinationCommand { + code: "geo-user-01".to_string(), + destination: String::new(), + probe_pks: vec![], + })) + .returning(move |_| Ok(signature)); + + let mut output = Vec::new(); + let res = SetResultDestinationCliCommand { + user: "geo-user-01".to_string(), + destination: None, + clear: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("Signature:")); + } + + #[test] + fn test_cli_set_result_destination_missing_destination() { + let client = MockGeoCliCommand::new(); + + let mut output = Vec::new(); + let res = SetResultDestinationCliCommand { + user: "geo-user-01".to_string(), + destination: None, + clear: false, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("--destination")); + } + + #[test] + fn test_cli_set_result_destination_invalid_destinations() { + let cases = vec![ + ("no-port", "expected host:port"), + ("10.0.0.1:9000", "invalid IP"), + ("192.168.1.1:9000", "invalid IP"), + ("example.com:99999", "invalid port"), + ("example.com:abc", "invalid port"), + ("bad..domain:80", "invalid domain label length"), + ("-bad.example.com:80", "cannot start or end with a hyphen"), + ("localhost:9000", "at least two labels"), + ("bad_label.example.com:80", "invalid characters"), + ]; + for (dest, expected_msg) in cases { + let client = MockGeoCliCommand::new(); + let mut output = Vec::new(); + let res = SetResultDestinationCliCommand { + user: "geo-user-01".to_string(), + destination: Some(dest.to_string()), + clear: false, + } + .execute(&client, &mut output); + assert!(res.is_err(), "expected error for destination \"{dest}\""); + let err = res.unwrap_err().to_string(); + assert!( + err.contains(expected_msg), + "destination \"{dest}\": expected error containing \"{expected_msg}\", got \"{err}\"" + ); + } + } +} diff --git a/smartcontract/programs/doublezero-geolocation/src/error.rs b/smartcontract/programs/doublezero-geolocation/src/error.rs index 4bf6adedba..54698d7701 100644 --- a/smartcontract/programs/doublezero-geolocation/src/error.rs +++ b/smartcontract/programs/doublezero-geolocation/src/error.rs @@ -40,8 +40,8 @@ pub enum GeolocationError { TargetAlreadyExists = 23, #[error("Invalid payment status")] InvalidPaymentStatus = 24, - #[error("Too many referenced probes to update in a single transaction")] - TooManyReferencedProbes = 25, + #[error("Probe account count does not match user targets")] + ProbeAccountCountMismatch = 25, } impl From for ProgramError { @@ -74,7 +74,7 @@ mod tests { (GeolocationError::TargetNotFound, 22), (GeolocationError::TargetAlreadyExists, 23), (GeolocationError::InvalidPaymentStatus, 24), - (GeolocationError::TooManyReferencedProbes, 25), + (GeolocationError::ProbeAccountCountMismatch, 25), ] } diff --git a/smartcontract/programs/doublezero-geolocation/src/processors/geolocation_user/set_result_destination.rs b/smartcontract/programs/doublezero-geolocation/src/processors/geolocation_user/set_result_destination.rs index 715ee1b21e..09c8be0594 100644 --- a/smartcontract/programs/doublezero-geolocation/src/processors/geolocation_user/set_result_destination.rs +++ b/smartcontract/programs/doublezero-geolocation/src/processors/geolocation_user/set_result_destination.rs @@ -6,10 +6,7 @@ use crate::{ }; use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::{ - account_info::{next_account_info, AccountInfo}, - entrypoint::ProgramResult, - msg, - program_error::ProgramError, + account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError, pubkey::Pubkey, }; use std::{collections::HashSet, net::Ipv4Addr}; @@ -82,11 +79,17 @@ pub fn process_set_result_destination( accounts: &[AccountInfo], args: &SetResultDestinationArgs, ) -> ProgramResult { - let accounts_iter = &mut accounts.iter(); - - let user_account = next_account_info(accounts_iter)?; - let payer_account = next_account_info(accounts_iter)?; - let _system_program = next_account_info(accounts_iter)?; + if accounts.len() < 3 { + msg!("Not enough accounts"); + return Err(ProgramError::NotEnoughAccountKeys); + } + // Account layout: [user, probe_0..probe_N, payer, system_program] + // Payer and system_program are always the last two accounts (appended by + // execute_transaction in the SDK), with variable-length probe accounts + // between the user and the payer. + let user_account = &accounts[0]; + let payer_account = &accounts[accounts.len() - 2]; + let probe_accounts = &accounts[1..accounts.len() - 2]; if !payer_account.is_signer { msg!("Payer must be a signer"); @@ -124,18 +127,16 @@ pub fn process_set_result_destination( } } - // Remaining accounts are the probe accounts to bump target_update_count on. - let remaining: Vec<&AccountInfo> = accounts_iter.collect(); - if remaining.len() != unique_probes.len() { + if probe_accounts.len() != unique_probes.len() { msg!( "Expected {} probe accounts, got {}", unique_probes.len(), - remaining.len() + probe_accounts.len() ); - return Err(GeolocationError::TooManyReferencedProbes.into()); + return Err(GeolocationError::ProbeAccountCountMismatch.into()); } - for probe_account in &remaining { + for probe_account in probe_accounts { if probe_account.owner != program_id { msg!("Invalid GeoProbe account owner"); return Err(ProgramError::IllegalOwner); @@ -155,8 +156,8 @@ pub fn process_set_result_destination( try_acc_write(&user, user_account, payer_account, accounts)?; - for probe_account in &remaining { - let mut probe = GeoProbe::try_from(*probe_account)?; + for probe_account in probe_accounts { + let mut probe = GeoProbe::try_from(probe_account)?; probe.target_update_count = probe.target_update_count.wrapping_add(1); // Probe uses change in this value to check for updates. try_acc_write(&probe, probe_account, payer_account, accounts)?; } diff --git a/smartcontract/programs/doublezero-geolocation/tests/geolocation_user_test.rs b/smartcontract/programs/doublezero-geolocation/tests/geolocation_user_test.rs index 9d7c49946f..6eb524973e 100644 --- a/smartcontract/programs/doublezero-geolocation/tests/geolocation_user_test.rs +++ b/smartcontract/programs/doublezero-geolocation/tests/geolocation_user_test.rs @@ -1463,14 +1463,15 @@ fn build_set_result_destination_ix( probe_pdas: &[Pubkey], args: SetResultDestinationArgs, ) -> Instruction { - let mut accounts = vec![ - AccountMeta::new(*user_pda, false), - AccountMeta::new(*payer, true), - AccountMeta::new_readonly(solana_program::system_program::id(), false), - ]; + let mut accounts = vec![AccountMeta::new(*user_pda, false)]; for probe_pda in probe_pdas { accounts.push(AccountMeta::new(*probe_pda, false)); } + accounts.push(AccountMeta::new(*payer, true)); + accounts.push(AccountMeta::new_readonly( + solana_program::system_program::id(), + false, + )); Instruction::new_with_borsh( *program_id, &GeolocationInstruction::SetResultDestination(args), @@ -1895,3 +1896,177 @@ async fn test_set_result_destination_invalid_format() { _ => panic!("Expected InvalidInstructionData error, got: {:?}", err), } } + +#[tokio::test] +async fn test_set_result_destination_unrelated_probe() { + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + let probe_pda = create_geo_probe( + &mut banks_client, + &program_id, + &recent_blockhash, + &payer, + &exchange_pubkey, + "probe-srd-rel", + ) + .await; + + let unrelated_probe_pda = create_geo_probe( + &mut banks_client, + &program_id, + &recent_blockhash, + &payer, + &exchange_pubkey, + "probe-srd-unrel", + ) + .await; + + let user_code = "user-srd-unrel"; + let ix = build_create_user_ix( + &program_id, + user_code, + &Pubkey::new_unique(), + &payer.pubkey(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + let (user_pda, _) = get_geolocation_user_pda(&program_id, user_code); + + let add_ix = build_add_target_ix( + &program_id, + &user_pda, + &probe_pda, + &payer.pubkey(), + AddTargetArgs { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + }, + ); + let tx = Transaction::new_signed_with_payer( + &[add_ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Pass unrelated_probe_pda instead of probe_pda + let set_ix = build_set_result_destination_ix( + &program_id, + &user_pda, + &payer.pubkey(), + &[unrelated_probe_pda], + SetResultDestinationArgs { + destination: "185.199.108.1:9000".to_string(), + }, + ); + let tx = Transaction::new_signed_with_payer( + &[set_ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + let result = banks_client.process_transaction(tx).await; + let err = result.unwrap_err().unwrap(); + match err { + TransactionError::InstructionError(0, InstructionError::InvalidAccountData) => {} + _ => panic!("Expected InvalidAccountData error, got: {:?}", err), + } +} + +#[tokio::test] +async fn test_set_result_destination_wrong_probe_count() { + let (mut banks_client, program_id, recent_blockhash, payer, exchange_pubkey) = + setup_test_with_exchange(ExchangeStatus::Activated).await; + + let probe_pda = create_geo_probe( + &mut banks_client, + &program_id, + &recent_blockhash, + &payer, + &exchange_pubkey, + "probe-srd-cnt", + ) + .await; + + let extra_probe_pda = create_geo_probe( + &mut banks_client, + &program_id, + &recent_blockhash, + &payer, + &exchange_pubkey, + "probe-srd-extra", + ) + .await; + + let user_code = "user-srd-cnt"; + let ix = build_create_user_ix( + &program_id, + user_code, + &Pubkey::new_unique(), + &payer.pubkey(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + let (user_pda, _) = get_geolocation_user_pda(&program_id, user_code); + + let add_ix = build_add_target_ix( + &program_id, + &user_pda, + &probe_pda, + &payer.pubkey(), + AddTargetArgs { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + }, + ); + let tx = Transaction::new_signed_with_payer( + &[add_ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + banks_client.process_transaction(tx).await.unwrap(); + + // Pass two probe accounts when user only has one unique probe + let set_ix = build_set_result_destination_ix( + &program_id, + &user_pda, + &payer.pubkey(), + &[probe_pda, extra_probe_pda], + SetResultDestinationArgs { + destination: "185.199.108.1:9000".to_string(), + }, + ); + let tx = Transaction::new_signed_with_payer( + &[set_ix], + Some(&payer.pubkey()), + &[&payer], + *recent_blockhash.read().await, + ); + let result = banks_client.process_transaction(tx).await; + let err = result.unwrap_err().unwrap(); + match err { + TransactionError::InstructionError(0, InstructionError::Custom(code)) => { + assert_eq!(code, GeolocationError::ProbeAccountCountMismatch as u32); + } + _ => panic!("Expected ProbeAccountCountMismatch error, got: {:?}", err), + } +} diff --git a/smartcontract/sdk/rs/src/geolocation/geolocation_user/mod.rs b/smartcontract/sdk/rs/src/geolocation/geolocation_user/mod.rs index c6976ce587..8044dffc8a 100644 --- a/smartcontract/sdk/rs/src/geolocation/geolocation_user/mod.rs +++ b/smartcontract/sdk/rs/src/geolocation/geolocation_user/mod.rs @@ -4,5 +4,6 @@ pub mod delete; pub mod get; pub mod list; pub mod remove_target; +pub mod set_result_destination; pub mod update; pub mod update_payment_status; diff --git a/smartcontract/sdk/rs/src/geolocation/geolocation_user/set_result_destination.rs b/smartcontract/sdk/rs/src/geolocation/geolocation_user/set_result_destination.rs new file mode 100644 index 0000000000..92f16a4864 --- /dev/null +++ b/smartcontract/sdk/rs/src/geolocation/geolocation_user/set_result_destination.rs @@ -0,0 +1,118 @@ +use doublezero_geolocation::{ + instructions::{GeolocationInstruction, SetResultDestinationArgs}, + pda, + validation::validate_code_length, +}; +use doublezero_program_common::validate_account_code; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +use crate::geolocation::client::GeolocationClient; + +#[derive(Debug, PartialEq, Clone)] +pub struct SetResultDestinationCommand { + pub code: String, + pub destination: String, + pub probe_pks: Vec, +} + +impl SetResultDestinationCommand { + pub fn execute(&self, client: &dyn GeolocationClient) -> eyre::Result { + validate_code_length(&self.code)?; + let code = + validate_account_code(&self.code).map_err(|err| eyre::eyre!("invalid code: {err}"))?; + + let program_id = client.get_program_id(); + let (user_pda, _) = pda::get_geolocation_user_pda(&program_id, &code); + + let mut accounts = vec![AccountMeta::new(user_pda, false)]; + for probe_pk in &self.probe_pks { + accounts.push(AccountMeta::new(*probe_pk, false)); + } + + client.execute_transaction( + GeolocationInstruction::SetResultDestination(SetResultDestinationArgs { + destination: self.destination.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geolocation::client::MockGeolocationClient; + use mockall::predicate; + + #[test] + fn test_set_result_destination() { + let mut client = MockGeolocationClient::new(); + + let program_id = Pubkey::new_unique(); + client.expect_get_program_id().returning(move || program_id); + + let code = "geo-user-01"; + let probe_pk1 = Pubkey::new_unique(); + let probe_pk2 = Pubkey::new_unique(); + + let (user_pda, _) = pda::get_geolocation_user_pda(&program_id, code); + + client + .expect_execute_transaction() + .with( + predicate::eq(GeolocationInstruction::SetResultDestination( + SetResultDestinationArgs { + destination: "185.199.108.1:9000".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new(user_pda, false), + AccountMeta::new(probe_pk1, false), + AccountMeta::new(probe_pk2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let command = SetResultDestinationCommand { + code: code.to_string(), + destination: "185.199.108.1:9000".to_string(), + probe_pks: vec![probe_pk1, probe_pk2], + }; + + let result = command.execute(&client); + assert!(result.is_ok()); + } + + #[test] + fn test_set_result_destination_clear() { + let mut client = MockGeolocationClient::new(); + + let program_id = Pubkey::new_unique(); + client.expect_get_program_id().returning(move || program_id); + + let code = "geo-user-01"; + + let (user_pda, _) = pda::get_geolocation_user_pda(&program_id, code); + + client + .expect_execute_transaction() + .with( + predicate::eq(GeolocationInstruction::SetResultDestination( + SetResultDestinationArgs { + destination: String::new(), + }, + )), + predicate::eq(vec![AccountMeta::new(user_pda, false)]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let command = SetResultDestinationCommand { + code: code.to_string(), + destination: String::new(), + probe_pks: vec![], + }; + + let result = command.execute(&client); + assert!(result.is_ok()); + } +}