From cd0e3308c23c8a839038fb757692520609355bd2 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Thu, 9 Apr 2026 08:38:42 -0400 Subject: [PATCH] geolocation: add set-result-destination CLI command and Go SDK support Rust SDK command, CLI with client-side destination validation, display in user get, Go SDK deserialization with backwards compatibility. --- CHANGELOG.md | 1 + .../src/cli/user.rs | 3 + client/doublezero-geolocation-cli/src/main.rs | 1 + sdk/geolocation/go/state.go | 25 +- sdk/geolocation/go/state_test.go | 51 ++- smartcontract/cli/src/geoclicommand.rs | 6 + smartcontract/cli/src/geolocation/user/mod.rs | 1 + .../user/set_result_destination.rs | 290 ++++++++++++++++++ .../src/geolocation/geolocation_user/mod.rs | 1 + .../set_result_destination.rs | 118 +++++++ 10 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 smartcontract/cli/src/geolocation/user/set_result_destination.rs create mode 100644 smartcontract/sdk/rs/src/geolocation/geolocation_user/set_result_destination.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8684d087c1..f4d98e66ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ All notable changes to this project will be documented in this file. - Add `IsRetryableFunc` field to `RetryOptions` for configurable retry criteria in the Solana JSON-RPC client; add `"rate limited"` string match and RPC code `-32429` to the default implementation - Geolocation - Standardize CLI flag naming: probe mutation commands use `--probe` (was `--code`) accepting pubkey or code; rename `--signing-keypair` → `--signing-pubkey` and `--target-pk` → `--target-signing-pubkey`; add `--json-compact` to `get` commands + - 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) - geoprobe-target can now store LocationOffset messages in ClickHouse - Add ICMP pinger to geoprobe-agent for measuring outbound ICMP targets with interleaved batch send/receive, integrated into the existing measurement cycle alongside TWAMP - Remove `--additional-parent`, `--additional-targets`, `--additional-icmp-targets`, and `--allowed-pubkeys` CLI flags from geoprobe-agent; all configuration now comes from onchain state via parent and target discovery 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/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()); + } +}