From 3e142e9c081b3ce2af32d53a82347d48580c0d8e Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Wed, 17 Jul 2024 09:50:15 -0700 Subject: [PATCH] HIP-119: Location Trust Score from maximum asserted distance difference (#840) * Calculate Location Trust Score from asserted distance in heartbeat HIP-119 introduces a new set of tables for location trust scores based on `(radio_type, distance_to_asserted)`. This also allows for a new minimum trust score multiplier of `0x`. * Remove use of max_distance_to_asserted This value is now contained within the function `asserted_distance_to_trust_multiplier` in the coverage point calculator. * Update test for increased allowable location trust multipliers All location trust scores used to be 1.0 or 0.25. HIP-119 adds 0.00 as a multiplier possibility based on distance. * typo in test name * Use lowest possible location trust multiplier value for bad case scenarios * Pass location trust to determine boost eligibility Location Trust Scores are no longer reduced based on the presence of a boosted hex. However, having an average distance from an asserted location past 50m can cause a radio to be ineligible for boosted rewards. * namespace location consts to provide more context Both constants have to do with service provider boosting in regards to a radios location trust scores. Namespacing allows for not needing to shove all possible context into a top level name. * make service provider boosting module It didn't feel quite correct to have half the service provider boosting code in lib.rs and the other in location.rs. The constants have to with location trust scores, but they do not get used there. * Remove trust score tests that expect score modification from boosting The distance to asserted no longer changes trust scores in the presence of a boosted hex. Service provider boosting eligibility is determined from the distance to asserted, that is tested at the top level of this crate. * Test Boosting does not apply when too far away * consolidate seeding heartbeats v1 and v3 The only difference was the location distance from asserted and assigned location trust multipliers. Refactoring heartbeat seeding further is left as an exercise to the next person who has a need to change these tests. * Test being too far from asserted location removes service provider boosting HIP-119 removes the part of calculating coverage points that degrades a trust score when a radio covers a boosted hex and is more than 50m away from their asserted location. Now, being +50m away from an asserted location makes a radio not eligible for receiving service provider boosted rewards. They continue to receive the full force of their location trust score. Being an Indoor radio 100m away from an asserted location is enough to keep a location trust multiplier of 1.0x, but not receive boosted rewards. * add hip-125 mentions in the docs * Try to link to relevant hips when possible Adding links to multiple places so you don't have to know a secret location where they exist. * Active boosting ineligibility takes precendence over passive ineligibility * Remove answered questions Both were answered yes * New location scores apply to Wifi only CBRS is always trusted for location. The location trust score is intercepted earlier when validating heartbeats. If for some reason CBRS radios do make it to this function, they will receive a good trust score anyways. * ServiceProvider -> SP for brevity --- coverage_point_calculator/src/lib.rs | 185 +++++++--- coverage_point_calculator/src/location.rs | 143 +++----- .../src/service_provider_boosting.rs | 37 ++ .../tests/coverage_point_calculator.rs | 14 +- mobile_verifier/src/heartbeats/cbrs.rs | 5 - mobile_verifier/src/heartbeats/mod.rs | 31 +- mobile_verifier/src/heartbeats/wifi.rs | 5 - mobile_verifier/src/reward_shares.rs | 8 +- .../src/rewarder/boosted_hex_eligibility.rs | 28 +- mobile_verifier/src/settings.rs | 9 - .../tests/integrations/boosting_oracles.rs | 1 - .../tests/integrations/hex_boosting.rs | 332 ++++++++++++------ .../tests/integrations/last_location.rs | 14 +- .../tests/integrations/modeled_coverage.rs | 127 +++---- 14 files changed, 543 insertions(+), 396 deletions(-) create mode 100644 coverage_point_calculator/src/service_provider_boosting.rs diff --git a/coverage_point_calculator/src/lib.rs b/coverage_point_calculator/src/lib.rs index 2e23ee181..8e891d737 100644 --- a/coverage_point_calculator/src/lib.rs +++ b/coverage_point_calculator/src/lib.rs @@ -24,6 +24,7 @@ //! - [HIP-98][qos-score] //! - states 30m requirement for boosted hexes [HIP-107][prevent-gaming] //! - increase Boosted hex restriction, 30m -> 50m [Pull Request][boosted-hex-restriction] +//! - Maximum Asserted Distance Difference [HIP-119][location-gaming] //! //! - [CoveragePoints::speedtest_multiplier] //! - [HIP-74][modeled-coverage] @@ -32,7 +33,7 @@ //! //! ## Notable Conditions: //! - [LocationTrust] -//! - If a Radio covers any boosted hexes, [LocationTrust] scores must meet distance requirements, or be degraded. +//! - The average distance to asserted must be <=50m to be eligible for boosted rewards. //! - CBRS Radio's location is always trusted because of GPS. //! //! - [Speedtest] @@ -43,6 +44,10 @@ //! - If a Radio is not [BoostedHexStatus::Eligible], boost values are removed before calculations. //! - If a Hex is boosted by a Provider, the Oracle Assignment multiplier is automatically 1x. //! +//! - [ServiceProviderBoostedRewardEligibility] +//! - Radio must pass at least 1mb of data from 3 unique phones [HIP-84][provider-boosting] +//! - Service Provider can invalidate boosted rewards of a hotspot [HIP-125][provider-banning] +//! //! [modeled-coverage]: https://github.com/helium/HIP/blob/main/0074-mobile-poc-modeled-coverage-rewards.md#outdoor-radios //! [provider-boosting]: https://github.com/helium/HIP/blob/main/0084-service-provider-hex-boosting.md //! [wifi-aps]: https://github.com/helium/HIP/blob/main/0093-addition-of-wifi-aps-to-mobile-subdao.md @@ -53,18 +58,23 @@ //! [cbrs-experimental]: https://github.com/helium/HIP/blob/main/0113-reward-cbrs-as-experimental.md //! [mobile-poc-blog]: https://docs.helium.com/mobile/proof-of-coverage //! [boosted-hex-restriction]: https://github.com/helium/oracles/pull/808 +//! [location-gaming]: https://github.com/helium/HIP/blob/main/0119-closing-gaming-loopholes-within-the-mobile-network.md +//! [provider-banning]: https://github.com/helium/HIP/blob/main/0125-temporary-anti-gaming-measures-for-boosted-hexes.md //! pub use crate::{ hexes::{CoveredHex, HexPoints}, - location::LocationTrust, + location::{asserted_distance_to_trust_multiplier, LocationTrust}, + service_provider_boosting::SPBoostedRewardEligibility, speedtest::{BytesPs, Speedtest, SpeedtestTier}, }; use coverage_map::SignalLevel; use rust_decimal::Decimal; use rust_decimal_macros::dec; +use service_provider_boosting::{MAX_AVERAGE_DISTANCE, MIN_WIFI_TRUST_MULTIPLIER}; mod hexes; mod location; +mod service_provider_boosting; mod speedtest; pub type Result = std::result::Result; @@ -122,7 +132,7 @@ pub struct CoveragePoints { /// Input Radio Type pub radio_type: RadioType, /// Input ServiceProviderBoostedRewardEligibility - pub service_provider_boosted_reward_eligibility: ServiceProviderBoostedRewardEligibility, + pub service_provider_boosted_reward_eligibility: SPBoostedRewardEligibility, /// Derived Eligibility for Boosted Hex Rewards pub boosted_hex_eligibility: BoostedHexStatus, /// Speedtests used in calculcation @@ -136,18 +146,18 @@ pub struct CoveragePoints { impl CoveragePoints { pub fn new( radio_type: RadioType, - service_provider_boosted_reward_eligibility: ServiceProviderBoostedRewardEligibility, + service_provider_boosted_reward_eligibility: SPBoostedRewardEligibility, speedtests: Vec, - trust_scores: Vec, + location_trust_scores: Vec, ranked_coverage: Vec, ) -> Result { - let location_trust_scores = location::clean_trust_scores(trust_scores, &ranked_coverage); let location_trust_multiplier = location::multiplier(radio_type, &location_trust_scores); let boost_eligibility = BoostedHexStatus::new( - &radio_type, + radio_type, location_trust_multiplier, - &service_provider_boosted_reward_eligibility, + &location_trust_scores, + service_provider_boosted_reward_eligibility, ); let covered_hexes = @@ -181,7 +191,7 @@ impl CoveragePoints { /// value referred to as "shares". /// /// Ref: - /// https://github.com/helium/proto/blob/master/src/service/poc_mobile.proto + /// /// `message radio_reward` pub fn coverage_points_v1(&self) -> Decimal { let total_coverage_points = self.coverage_points.base + self.boosted_points(); @@ -209,39 +219,48 @@ impl CoveragePoints { match self.boosted_hex_eligibility { BoostedHexStatus::Eligible => self.coverage_points.boosted, BoostedHexStatus::WifiLocationScoreBelowThreshold(_) => dec!(0), + BoostedHexStatus::AverageAssertedDistanceOverLimit(_) => dec!(0), BoostedHexStatus::RadioThresholdNotMet => dec!(0), BoostedHexStatus::ServiceProviderBanned => dec!(0), } } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoostedHexStatus { Eligible, WifiLocationScoreBelowThreshold(Decimal), + AverageAssertedDistanceOverLimit(Decimal), RadioThresholdNotMet, ServiceProviderBanned, } impl BoostedHexStatus { fn new( - radio_type: &RadioType, - location_trust_score: Decimal, - service_provider_boosted_reward_eligibility: &ServiceProviderBoostedRewardEligibility, + radio_type: RadioType, + location_trust_multiplier: Decimal, + location_trust_scores: &[LocationTrust], + service_provider_boosted_reward_eligibility: SPBoostedRewardEligibility, ) -> Self { - // hip-93: if radio is wifi & location_trust score multiplier < 0.75, no boosting - if radio_type.is_wifi() && location_trust_score < dec!(0.75) { - return Self::WifiLocationScoreBelowThreshold(location_trust_score); - } - - // hip-84: if radio has not met minimum data and subscriber thresholds, no boosting match service_provider_boosted_reward_eligibility { - ServiceProviderBoostedRewardEligibility::Eligible => Self::Eligible, - ServiceProviderBoostedRewardEligibility::ServiceProviderBanned => { - Self::ServiceProviderBanned - } - ServiceProviderBoostedRewardEligibility::RadioThresholdNotMet => { - Self::RadioThresholdNotMet + // hip-125: if radio has been banned by service provider, no boosting + SPBoostedRewardEligibility::ServiceProviderBanned => Self::ServiceProviderBanned, + // hip-84: if radio has not met minimum data and subscriber thresholds, no boosting + SPBoostedRewardEligibility::RadioThresholdNotMet => Self::RadioThresholdNotMet, + SPBoostedRewardEligibility::Eligible => { + // hip-93: if radio is wifi & location_trust score multiplier < 0.75, no boosting + if radio_type.is_wifi() && location_trust_multiplier < MIN_WIFI_TRUST_MULTIPLIER { + return Self::WifiLocationScoreBelowThreshold(location_trust_multiplier); + } + + // hip-119: if the average distance to asserted is beyond 50m, no boosting + let average_distance = + location::average_distance(radio_type, location_trust_scores); + if average_distance > MAX_AVERAGE_DISTANCE { + return Self::AverageAssertedDistanceOverLimit(average_distance); + } + + Self::Eligible } } } @@ -315,13 +334,6 @@ impl RadioType { } } -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ServiceProviderBoostedRewardEligibility { - Eligible, - ServiceProviderBanned, - RadioThresholdNotMet, -} - #[cfg(test)] mod tests { @@ -346,7 +358,7 @@ mod tests { ) { let wifi = CoveragePoints::new( RadioType::IndoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -368,7 +380,7 @@ mod tests { #[test] fn hip_84_radio_meets_minimum_subscriber_threshold_for_boosted_hexes() { - let calculate_wifi = |eligibility: ServiceProviderBoostedRewardEligibility| { + let calculate_wifi = |eligibility: SPBoostedRewardEligibility| { CoveragePoints::new( RadioType::IndoorWifi, eligibility, @@ -393,13 +405,12 @@ mod tests { // Radio meeting the threshold is eligible for boosted hexes. // Boosted hex provides radio with more than base_points. - let verified_wifi = calculate_wifi(ServiceProviderBoostedRewardEligibility::Eligible); + let verified_wifi = calculate_wifi(SPBoostedRewardEligibility::Eligible); assert_eq!(base_points * dec!(5), verified_wifi.coverage_points_v1()); // Radio not meeting the threshold is not eligible for boosted hexes. // Boost from hex is not applied, radio receives base points. - let unverified_wifi = - calculate_wifi(ServiceProviderBoostedRewardEligibility::RadioThresholdNotMet); + let unverified_wifi = calculate_wifi(SPBoostedRewardEligibility::RadioThresholdNotMet); assert_eq!(base_points, unverified_wifi.coverage_points_v1()); } @@ -408,7 +419,7 @@ mod tests { let calculate_wifi = |location_trust_scores: Vec| { CoveragePoints::new( RadioType::IndoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_scores, vec![RankedCoverage { @@ -441,12 +452,48 @@ mod tests { assert!(untrusted_wifi.coverage_points_v1() < base_points); } + #[test] + fn hip_119_radio_with_past_50m_from_asserted_receives_no_boosted_hexes() { + let calculate_wifi = |location_trust_scores: Vec| { + CoveragePoints::new( + RadioType::IndoorWifi, + SPBoostedRewardEligibility::Eligible, + speedtest_maximum(), + location_trust_scores, + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: NonZeroU32::new(5), + }], + ) + .expect("indoor wifi with location scores") + }; + + let base_points = RadioType::IndoorWifi + .base_coverage_points(&SignalLevel::High) + .unwrap(); + + // Radio with distance to asserted under the limit is eligible for boosted hexes. + // Boosted hex provides radio with more than base_points. + let trusted_wifi = calculate_wifi(location_trust_with_asserted_distance(&[0, 49])); + assert!(trusted_wifi.total_shares() > base_points); + + // Radio with distance to asserted over the limit is not eligible for boosted hexes. + // Boost from hex is not applied. + let untrusted_wifi = calculate_wifi(location_trust_with_asserted_distance(&[50, 51])); + assert_eq!(untrusted_wifi.total_shares(), base_points); + } + #[test] fn speedtests_effect_reward_shares() { let calculate_indoor_cbrs = |speedtests: Vec| { CoveragePoints::new( RadioType::IndoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtests, location_trust_maximum(), vec![RankedCoverage { @@ -534,7 +581,7 @@ mod tests { use Assignment::*; let indoor_cbrs = CoveragePoints::new( RadioType::IndoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![ @@ -591,7 +638,7 @@ mod tests { ) { let outdoor_wifi = CoveragePoints::new( radio_type, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -620,7 +667,7 @@ mod tests { ) { let indoor_wifi = CoveragePoints::new( radio_type, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![ @@ -663,7 +710,7 @@ mod tests { // Location scores are averaged together let indoor_wifi = CoveragePoints::new( RadioType::IndoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_with_scores(&[dec!(0.1), dec!(0.2), dec!(0.3), dec!(0.4)]), vec![RankedCoverage { @@ -707,7 +754,7 @@ mod tests { ]; let indoor_wifi = CoveragePoints::new( RadioType::IndoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), covered_hexes.clone(), @@ -730,7 +777,7 @@ mod tests { ) { let outdoor_cbrs = CoveragePoints::new( RadioType::OutdoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -757,7 +804,7 @@ mod tests { ) { let indoor_cbrs = CoveragePoints::new( RadioType::IndoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -786,7 +833,7 @@ mod tests { ) { let outdoor_wifi = CoveragePoints::new( RadioType::OutdoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -813,7 +860,7 @@ mod tests { ) { let indoor_wifi = CoveragePoints::new( RadioType::IndoorWifi, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtest_maximum(), location_trust_maximum(), vec![RankedCoverage { @@ -831,6 +878,36 @@ mod tests { assert_eq!(expected, indoor_wifi.coverage_points_v1()); } + #[test] + fn wifi_with_bad_location_boosted_hex_status_prioritizes_service_provider_statuses() { + let bad_location = vec![LocationTrust { + meters_to_asserted: 100, + trust_score: dec!(0.0), + }]; + + let wifi_bad_trust_score = |sp_status: SPBoostedRewardEligibility| { + BoostedHexStatus::new( + RadioType::IndoorWifi, + location::multiplier(RadioType::IndoorWifi, &bad_location), + &bad_location, + sp_status, + ) + }; + + assert_eq!( + wifi_bad_trust_score(SPBoostedRewardEligibility::Eligible), + BoostedHexStatus::WifiLocationScoreBelowThreshold(dec!(0)), + ); + assert_eq!( + wifi_bad_trust_score(SPBoostedRewardEligibility::ServiceProviderBanned), + BoostedHexStatus::ServiceProviderBanned + ); + assert_eq!( + wifi_bad_trust_score(SPBoostedRewardEligibility::RadioThresholdNotMet), + BoostedHexStatus::RadioThresholdNotMet + ); + } + fn hex_location() -> hextree::Cell { hextree::Cell::from_raw(0x8c2681a3064edff).unwrap() } @@ -896,6 +973,18 @@ mod tests { .collect() } + fn location_trust_with_asserted_distance(distances_to_asserted: &[u32]) -> Vec { + distances_to_asserted + .to_owned() + .iter() + .copied() + .map(|meters_to_asserted| LocationTrust { + meters_to_asserted, + trust_score: dec!(1.0), + }) + .collect() + } + fn pubkey() -> Vec { vec![1] } diff --git a/coverage_point_calculator/src/location.rs b/coverage_point_calculator/src/location.rs index a75ef88eb..ad4ac179d 100644 --- a/coverage_point_calculator/src/location.rs +++ b/coverage_point_calculator/src/location.rs @@ -1,14 +1,8 @@ -use coverage_map::RankedCoverage; use rust_decimal::Decimal; use rust_decimal_macros::dec; use crate::RadioType; -/// When a Radio is covering any boosted hexes, it's trust score location must -/// be within this distance to it's asserted location. Otherwise the trust_score -/// will be capped at 0.25x. -const RESTRICTIVE_MAX_DISTANCE: Meters = 50; - type Meters = u32; #[derive(Debug, Clone, PartialEq)] @@ -17,20 +11,44 @@ pub struct LocationTrust { pub trust_score: Decimal, } -pub(crate) fn clean_trust_scores( - trust_scores: Vec, - ranked_coverage: &[RankedCoverage], -) -> Vec { - let any_boosted_hexes = ranked_coverage.iter().any(|hex| hex.boosted.is_some()); - - if any_boosted_hexes { - trust_scores - .into_iter() - .map(LocationTrust::into_boosted) - .collect() - } else { - trust_scores +/// Returns the trust multiplier for a given radio type and distance to it's asserted location. +/// +/// [HIP-119: Gaming Loopholes][gaming-loopholes] +/// +/// [gaming-loopholes]: https://github.com/helium/HIP/blob/main/0119-closing-gaming-loopholes-within-the-mobile-network.md#maximum-asserted-distance-difference +pub fn asserted_distance_to_trust_multiplier( + radio_type: RadioType, + meters_to_asserted: Meters, +) -> Decimal { + match radio_type { + RadioType::IndoorWifi => match meters_to_asserted { + 0..=200 => dec!(1.00), + 201..=300 => dec!(0.25), + _ => dec!(0.00), + }, + RadioType::OutdoorWifi => match meters_to_asserted { + 0..=75 => dec!(1.00), + 76..=100 => dec!(0.25), + _ => dec!(0.00), + }, + RadioType::IndoorCbrs => dec!(1.0), + RadioType::OutdoorCbrs => dec!(1.0), + } +} + +pub(crate) fn average_distance(radio_type: RadioType, trust_scores: &[LocationTrust]) -> Decimal { + // CBRS radios are always trusted because they have internal GPS + if radio_type.is_cbrs() { + return dec!(0); } + + let count = Decimal::from(trust_scores.len()); + let sum: Decimal = trust_scores + .iter() + .map(|l| Decimal::from(l.meters_to_asserted)) + .sum(); + + sum / count } pub(crate) fn multiplier(radio_type: RadioType, trust_scores: &[LocationTrust]) -> Decimal { @@ -45,35 +63,18 @@ pub(crate) fn multiplier(radio_type: RadioType, trust_scores: &[LocationTrust]) scores / count } -impl LocationTrust { - fn into_boosted(self) -> Self { - // Cap multipliers to 0.25x when a radio covers _any_ boosted hex - // and it's distance to asserted is above the threshold. - let trust_score = if self.meters_to_asserted > RESTRICTIVE_MAX_DISTANCE { - dec!(0.25).min(self.trust_score) - } else { - self.trust_score - }; - - LocationTrust { - trust_score, - meters_to_asserted: self.meters_to_asserted, - } - } -} - #[cfg(test)] mod tests { - use std::num::NonZeroU32; - - use coverage_map::SignalLevel; - use hex_assignments::{assignment::HexAssignments, Assignment}; use super::*; #[test] - fn all_locations_within_max_boosted_distance() { + fn distance_does_not_effect_multiplier() { let trust_scores = vec![ + LocationTrust { + meters_to_asserted: 0, + trust_score: dec!(0.5), + }, LocationTrust { meters_to_asserted: 49, trust_score: dec!(0.5), @@ -82,17 +83,6 @@ mod tests { meters_to_asserted: 50, trust_score: dec!(0.5), }, - ]; - let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); - let unboosted = clean_trust_scores(trust_scores, &[]); - - assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &boosted)); - assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); - } - - #[test] - fn all_locations_past_max_boosted_distance() { - let trust_scores = vec![ LocationTrust { meters_to_asserted: 51, trust_score: dec!(0.5), @@ -101,36 +91,13 @@ mod tests { meters_to_asserted: 100, trust_score: dec!(0.5), }, - ]; - - let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); - let unboosted = clean_trust_scores(trust_scores, &[]); - - assert_eq!(dec!(0.25), multiplier(RadioType::IndoorWifi, &boosted)); - assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); - } - - #[test] - fn locations_around_max_boosted_distance() { - let trust_scores = vec![ LocationTrust { - meters_to_asserted: 50, - trust_score: dec!(0.5), - }, - LocationTrust { - meters_to_asserted: 51, + meters_to_asserted: 99999, trust_score: dec!(0.5), }, ]; - let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); - let unboosted = clean_trust_scores(trust_scores, &[]); - - // location past distance limit trust score is degraded - let degraded_mult = (dec!(0.5) + dec!(0.25)) / dec!(2); - assert_eq!(degraded_mult, multiplier(RadioType::IndoorWifi, &boosted)); - // location past distance limit trust score is untouched - assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); + assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &trust_scores)); } #[test] @@ -143,26 +110,6 @@ mod tests { trust_score: dec!(0), }]; - let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); - let unboosted = clean_trust_scores(trust_scores, &[]); - - assert_eq!(dec!(1), multiplier(RadioType::IndoorCbrs, &boosted)); - assert_eq!(dec!(1), multiplier(RadioType::IndoorCbrs, &unboosted)); - } - - fn boosted_ranked_coverage() -> Vec { - vec![RankedCoverage { - hex: hextree::Cell::from_raw(0x8c2681a3064edff).unwrap(), - rank: 1, - hotspot_key: vec![], - cbsd_id: None, - signal_level: SignalLevel::High, - assignments: HexAssignments { - footfall: Assignment::A, - landtype: Assignment::A, - urbanized: Assignment::A, - }, - boosted: NonZeroU32::new(5), - }] + assert_eq!(dec!(1), multiplier(RadioType::IndoorCbrs, &trust_scores)); } } diff --git a/coverage_point_calculator/src/service_provider_boosting.rs b/coverage_point_calculator/src/service_provider_boosting.rs new file mode 100644 index 000000000..af31b524a --- /dev/null +++ b/coverage_point_calculator/src/service_provider_boosting.rs @@ -0,0 +1,37 @@ +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +// In order for the Wi-Fi access point to be eligible for boosted hex rewards +// as described in HIP84 the location trust score needs to be 0.75 or higher. +// +// [HIP-93: Add Wifi to Mobile Dao][add-wifi-aps] +// +// [add-wifi-aps]: https://github.com/helium/HIP/blob/main/0093-addition-of-wifi-aps-to-mobile-subdao.md#341-indoor-access-points-rewards +pub(crate) const MIN_WIFI_TRUST_MULTIPLIER: Decimal = dec!(0.75); + +// In order for access points to be eligible for boosted Service Provider +// rewards defined in HIP-84, the asserted distances must be 50 meters or +// less than the reported location from external services for both indoor +// and outdoor Access Points. +// +// [HIP-119: Gaming Loopholes][gaming-loopholes] +// +// [gaming-loopholes]: https://github.com/helium/HIP/blob/main/0119-closing-gaming-loopholes-within-the-mobile-network.md#maximum-asserted-distance-for-boosted-hexes +pub(crate) const MAX_AVERAGE_DISTANCE: Decimal = dec!(50); + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SPBoostedRewardEligibility { + Eligible, + /// Service Provider can invalidate boosted rewards of a hotspot + /// + /// [HIP-125: Anti gaming measures][anti-gaming] + /// + /// [anti-gaming]: https://github.com/helium/HIP/blob/main/0125-temporary-anti-gaming-measures-for-boosted-hexes.md + ServiceProviderBanned, + /// Radio must pass at least 1mb of data from 3 unique phones. + /// + /// [HIP-84: Provider Hex Boosting][provider-boosting] + /// + /// [provider-boosting]: https://github.com/helium/HIP/blob/main/0084-service-provider-hex-boosting.md + RadioThresholdNotMet, +} diff --git a/coverage_point_calculator/tests/coverage_point_calculator.rs b/coverage_point_calculator/tests/coverage_point_calculator.rs index aba3323ed..f8de92fc9 100644 --- a/coverage_point_calculator/tests/coverage_point_calculator.rs +++ b/coverage_point_calculator/tests/coverage_point_calculator.rs @@ -3,8 +3,8 @@ use std::num::NonZeroU32; use chrono::Utc; use coverage_map::{BoostedHexMap, RankedCoverage, SignalLevel, UnrankedCoverage}; use coverage_point_calculator::{ - BytesPs, CoveragePoints, LocationTrust, RadioType, Result, - ServiceProviderBoostedRewardEligibility, Speedtest, SpeedtestTier, + BytesPs, CoveragePoints, LocationTrust, RadioType, Result, SPBoostedRewardEligibility, + Speedtest, SpeedtestTier, }; use hex_assignments::{assignment::HexAssignments, Assignment}; use rust_decimal_macros::dec; @@ -52,7 +52,7 @@ fn base_radio_coverage_points() { ] { let coverage_points = CoveragePoints::new( radio_type, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, speedtests.clone(), location_trust_scores.clone(), hexes.clone(), @@ -113,7 +113,7 @@ fn radios_with_coverage() { ] { let coverage_points = CoveragePoints::new( radio_type, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, default_speedtests.clone(), default_location_trust_scores.clone(), base_hex_iter.clone().take(num_hexes).collect(), @@ -240,7 +240,7 @@ fn cbrs_outdoor_with_mixed_signal_level_coverage() -> Result { let radio = CoveragePoints::new( RadioType::OutdoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, Speedtest::mock(SpeedtestTier::Good), vec![], // Location Trust is ignored for Cbrs vec![ @@ -372,7 +372,7 @@ fn indoor_cbrs_radio( ) -> Result { CoveragePoints::new( RadioType::IndoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, Speedtest::mock(speedtest_tier), vec![], coverage.to_owned(), @@ -385,7 +385,7 @@ fn outdoor_cbrs_radio( ) -> Result { CoveragePoints::new( RadioType::OutdoorCbrs, - ServiceProviderBoostedRewardEligibility::Eligible, + SPBoostedRewardEligibility::Eligible, Speedtest::mock(speedtest_tier), vec![], coverage.to_owned(), diff --git a/mobile_verifier/src/heartbeats/cbrs.rs b/mobile_verifier/src/heartbeats/cbrs.rs index fa3cdf3a6..2d7e8f0a1 100644 --- a/mobile_verifier/src/heartbeats/cbrs.rs +++ b/mobile_verifier/src/heartbeats/cbrs.rs @@ -28,7 +28,6 @@ pub struct CbrsHeartbeatDaemon { pool: sqlx::Pool, gateway_info_resolver: GIR, heartbeats: Receiver>, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, heartbeat_sink: FileSinkClient, seniority_sink: FileSinkClient, @@ -65,7 +64,6 @@ where pool, gateway_resolver, cbrs_heartbeats, - settings.max_asserted_distance_deviation, settings.max_distance_from_coverage, valid_heartbeats, seniority_updates, @@ -83,7 +81,6 @@ where pool: sqlx::Pool, gateway_info_resolver: GIR, heartbeats: Receiver>, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, heartbeat_sink: FileSinkClient, seniority_sink: FileSinkClient, @@ -93,7 +90,6 @@ where pool, gateway_info_resolver, heartbeats, - max_distance_to_asserted, max_distance_to_coverage, heartbeat_sink, seniority_sink, @@ -170,7 +166,6 @@ where &self.gateway_info_resolver, coverage_object_cache, location_cache, - self.max_distance_to_asserted, self.max_distance_to_coverage, &epoch, &self.geofence, diff --git a/mobile_verifier/src/heartbeats/mod.rs b/mobile_verifier/src/heartbeats/mod.rs index 2e21a68a9..985fcae52 100644 --- a/mobile_verifier/src/heartbeats/mod.rs +++ b/mobile_verifier/src/heartbeats/mod.rs @@ -373,7 +373,6 @@ impl ValidatedHeartbeat { gateway_info_resolver: &impl GatewayResolver, coverage_object_cache: &CoverageObjectCache, last_location_cache: &LocationCache, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, epoch: &Range>, geofence: &impl GeofenceValidator, @@ -538,17 +537,29 @@ impl ValidatedHeartbeat { true } }; + let distance_to_asserted = asserted_latlng.distance_m(hb_latlng).round() as i64; - let location_trust_score_multiplier = if is_valid - // The heartbeat location to asserted location must be less than the max_distance_to_asserted value: - && distance_to_asserted <= max_distance_to_asserted as i64 - // The heartbeat location to every associated coverage hex must be less than max_distance_to_coverage: - && coverage_object.max_distance_m(hb_latlng).round() as u32 <= max_distance_to_coverage - { - dec!(1.0) + let max_distance = coverage_object.max_distance_m(hb_latlng).round() as u32; + + let location_trust_score_multiplier = if !is_valid { + dec!(0) + } else if max_distance >= max_distance_to_coverage { + // Furthest hex in Heartbeat exceeds allowed coverage distance + dec!(0) } else { - dec!(0.25) + // HIP-119 maximum asserted distance check + use coverage_point_calculator::{ + asserted_distance_to_trust_multiplier, RadioType, + }; + let radio_type = match (heartbeat.hb_type, coverage_object.meta.indoor) { + (HbType::Cbrs, true) => RadioType::IndoorCbrs, + (HbType::Cbrs, false) => RadioType::OutdoorCbrs, + (HbType::Wifi, true) => RadioType::IndoorWifi, + (HbType::Wifi, false) => RadioType::OutdoorWifi, + }; + asserted_distance_to_trust_multiplier(radio_type, distance_to_asserted as u32) }; + Ok(Self::new( heartbeat, cell_type, @@ -575,7 +586,6 @@ impl ValidatedHeartbeat { gateway_info_resolver: &'a impl GatewayResolver, coverage_object_cache: &'a CoverageObjectCache, last_location_cache: &'a LocationCache, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, epoch: &'a Range>, geofence: &'a impl GeofenceValidator, @@ -586,7 +596,6 @@ impl ValidatedHeartbeat { gateway_info_resolver, coverage_object_cache, last_location_cache, - max_distance_to_asserted, max_distance_to_coverage, epoch, geofence, diff --git a/mobile_verifier/src/heartbeats/wifi.rs b/mobile_verifier/src/heartbeats/wifi.rs index 7456af788..f40da6e24 100644 --- a/mobile_verifier/src/heartbeats/wifi.rs +++ b/mobile_verifier/src/heartbeats/wifi.rs @@ -27,7 +27,6 @@ pub struct WifiHeartbeatDaemon { pool: sqlx::Pool, gateway_info_resolver: GIR, heartbeats: Receiver>, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, heartbeat_sink: FileSinkClient, seniority_sink: FileSinkClient, @@ -63,7 +62,6 @@ where pool, gateway_resolver, wifi_heartbeats, - settings.max_asserted_distance_deviation, settings.max_distance_from_coverage, valid_heartbeats, seniority_updates, @@ -81,7 +79,6 @@ where pool: sqlx::Pool, gateway_info_resolver: GIR, heartbeats: Receiver>, - max_distance_to_asserted: u32, max_distance_to_coverage: u32, heartbeat_sink: FileSinkClient, seniority_sink: FileSinkClient, @@ -91,7 +88,6 @@ where pool, gateway_info_resolver, heartbeats, - max_distance_to_asserted, max_distance_to_coverage, heartbeat_sink, seniority_sink, @@ -161,7 +157,6 @@ where &self.gateway_info_resolver, coverage_object_cache, location_cache, - self.max_distance_to_asserted, self.max_distance_to_coverage, &epoch, &self.geofence, diff --git a/mobile_verifier/src/reward_shares.rs b/mobile_verifier/src/reward_shares.rs index 7496acbb7..5a35cdfbc 100644 --- a/mobile_verifier/src/reward_shares.rs +++ b/mobile_verifier/src/reward_shares.rs @@ -8,7 +8,7 @@ use crate::{ subscriber_location::SubscriberValidatedLocations, }; use chrono::{DateTime, Duration, Utc}; -use coverage_point_calculator::ServiceProviderBoostedRewardEligibility; +use coverage_point_calculator::SPBoostedRewardEligibility; use file_store::traits::TimestampEncode; use futures::{Stream, StreamExt}; use helium_crypto::PublicKeyBinary; @@ -427,7 +427,7 @@ struct RadioInfo { coverage_obj_uuid: Uuid, seniority: Seniority, trust_scores: Vec, - sp_boosted_reward_eligibility: ServiceProviderBoostedRewardEligibility, + sp_boosted_reward_eligibility: SPBoostedRewardEligibility, speedtests: Vec, } @@ -2035,7 +2035,7 @@ mod test { inserted_at: now, update_reason: 0, }, - sp_boosted_reward_eligibility: ServiceProviderBoostedRewardEligibility::Eligible, + sp_boosted_reward_eligibility: SPBoostedRewardEligibility::Eligible, speedtests: vec![ coverage_point_calculator::Speedtest { upload_speed: coverage_point_calculator::BytesPs::new(100_000_000), @@ -2068,7 +2068,7 @@ mod test { inserted_at: now, update_reason: 0, }, - sp_boosted_reward_eligibility: ServiceProviderBoostedRewardEligibility::Eligible, + sp_boosted_reward_eligibility: SPBoostedRewardEligibility::Eligible, speedtests: vec![], }, ); diff --git a/mobile_verifier/src/rewarder/boosted_hex_eligibility.rs b/mobile_verifier/src/rewarder/boosted_hex_eligibility.rs index 21f327fea..3db3ea19e 100644 --- a/mobile_verifier/src/rewarder/boosted_hex_eligibility.rs +++ b/mobile_verifier/src/rewarder/boosted_hex_eligibility.rs @@ -1,4 +1,4 @@ -use coverage_point_calculator::ServiceProviderBoostedRewardEligibility; +use coverage_point_calculator::SPBoostedRewardEligibility; use helium_crypto::PublicKeyBinary; use crate::{radio_threshold::VerifiedRadioThresholds, sp_boosted_rewards_bans::BannedRadios}; @@ -21,13 +21,13 @@ impl BoostedHexEligibility { &self, key: PublicKeyBinary, cbsd_id_opt: Option, - ) -> ServiceProviderBoostedRewardEligibility { + ) -> SPBoostedRewardEligibility { if self.banned_radios.contains(&key, cbsd_id_opt.as_deref()) { - ServiceProviderBoostedRewardEligibility::ServiceProviderBanned + SPBoostedRewardEligibility::ServiceProviderBanned } else if self.radio_thresholds.is_verified(key, cbsd_id_opt) { - ServiceProviderBoostedRewardEligibility::Eligible + SPBoostedRewardEligibility::Eligible } else { - ServiceProviderBoostedRewardEligibility::RadioThresholdNotMet + SPBoostedRewardEligibility::RadioThresholdNotMet } } } @@ -56,14 +56,14 @@ mod tests { let eligibility = boosted_hex_eligibility.eligibility(pub_key.clone(), None); assert_eq!( - ServiceProviderBoostedRewardEligibility::ServiceProviderBanned, + SPBoostedRewardEligibility::ServiceProviderBanned, eligibility ); let eligibility = boosted_hex_eligibility.eligibility(pub_key, Some(cbsd_id)); assert_eq!( - ServiceProviderBoostedRewardEligibility::ServiceProviderBanned, + SPBoostedRewardEligibility::ServiceProviderBanned, eligibility ); } @@ -81,14 +81,14 @@ mod tests { let eligibility = boosted_hex_eligibility.eligibility(pub_key.clone(), None); assert_eq!( - ServiceProviderBoostedRewardEligibility::RadioThresholdNotMet, + SPBoostedRewardEligibility::RadioThresholdNotMet, eligibility ); let eligibility = boosted_hex_eligibility.eligibility(pub_key, Some(cbsd_id)); assert_eq!( - ServiceProviderBoostedRewardEligibility::RadioThresholdNotMet, + SPBoostedRewardEligibility::RadioThresholdNotMet, eligibility ); } @@ -109,17 +109,11 @@ mod tests { let eligibility = boosted_hex_eligibility.eligibility(pub_key.clone(), None); - assert_eq!( - ServiceProviderBoostedRewardEligibility::Eligible, - eligibility - ); + assert_eq!(SPBoostedRewardEligibility::Eligible, eligibility); let eligibility = boosted_hex_eligibility.eligibility(pub_key, Some(cbsd_id)); - assert_eq!( - ServiceProviderBoostedRewardEligibility::Eligible, - eligibility - ); + assert_eq!(SPBoostedRewardEligibility::Eligible, eligibility); } fn generate_keypair() -> Keypair { diff --git a/mobile_verifier/src/settings.rs b/mobile_verifier/src/settings.rs index 1a3949ebd..54a31d9dd 100644 --- a/mobile_verifier/src/settings.rs +++ b/mobile_verifier/src/settings.rs @@ -38,11 +38,6 @@ pub struct Settings { /// its respective coverage object #[serde(default = "default_max_distance_from_coverage")] pub max_distance_from_coverage: u32, - /// Max distance in meters between the asserted location of a WIFI hotspot - /// and the lat/lng defined in a heartbeat - /// beyond which its location weight will be reduced - #[serde(default = "default_max_asserted_distance_deviation")] - pub max_asserted_distance_deviation: u32, /// Directory in which new oracle boosting data sets are downloaded into pub data_sets_directory: PathBuf, /// Poll duration for new data sets @@ -66,10 +61,6 @@ fn default_max_distance_from_coverage() -> u32 { 2000 } -fn default_max_asserted_distance_deviation() -> u32 { - 100 -} - fn default_log() -> String { "mobile_verifier=debug,poc_store=info".to_string() } diff --git a/mobile_verifier/tests/integrations/boosting_oracles.rs b/mobile_verifier/tests/integrations/boosting_oracles.rs index 0cf60efeb..d5d00d844 100644 --- a/mobile_verifier/tests/integrations/boosting_oracles.rs +++ b/mobile_verifier/tests/integrations/boosting_oracles.rs @@ -362,7 +362,6 @@ async fn test_footfall_and_urbanization_and_landtype(pool: PgPool) -> anyhow::Re &coverage_objects, &location_cache, 2000, - 2000, &epoch, &MockGeofence, )); diff --git a/mobile_verifier/tests/integrations/hex_boosting.rs b/mobile_verifier/tests/integrations/hex_boosting.rs index a0058ec75..a6de082e5 100644 --- a/mobile_verifier/tests/integrations/hex_boosting.rs +++ b/mobile_verifier/tests/integrations/hex_boosting.rs @@ -724,7 +724,23 @@ async fn test_reduced_location_score_with_boosted_hexes(pool: PgPool) -> anyhow: // seed all the things let mut txn = pool.clone().begin().await?; - seed_heartbeats_v3(epoch.start, &mut txn).await?; + seed_heartbeats_with_location_trust( + epoch.start, + &mut txn, + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + HotspotLocationTrust { + meters: 300, + multiplier: dec!(0.25), + }, + ) + .await?; seed_speedtests(epoch.end, &mut txn).await?; seed_radio_thresholds(epoch.start, &mut txn).await?; txn.commit().await?; @@ -871,6 +887,185 @@ async fn test_reduced_location_score_with_boosted_hexes(pool: PgPool) -> anyhow: Ok(()) } +#[sqlx::test] +async fn test_distance_from_asserted_removes_boosting_but_not_location_trust( + pool: PgPool, +) -> anyhow::Result<()> { + let (mobile_rewards_client, mut mobile_rewards) = common::create_file_sink(); + let (speedtest_avg_client, _speedtest_avg_server) = common::create_file_sink(); + let now = Utc::now(); + let epoch = (now - ChronoDuration::hours(24))..now; + let epoch_duration = epoch.end - epoch.start; + let boost_period_length = Duration::days(30); + + // seed all the things + let mut txn = pool.clone().begin().await?; + seed_heartbeats_with_location_trust( + epoch.start, + &mut txn, + // hotspot 1 can receive boosting + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + // hotspot 2 can receive boosting but has no boosted hexes + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + // hotspot 3 is too far for boosting + HotspotLocationTrust { + meters: 100, + multiplier: dec!(1.0), + }, + ) + .await?; + seed_speedtests(epoch.end, &mut txn).await?; + seed_radio_thresholds(epoch.start, &mut txn).await?; + txn.commit().await?; + update_assignments(&pool).await?; + + // setup boosted hex where reward start time is in the second period length + let multipliers1 = vec![NonZeroU32::new(2).unwrap()]; + let start_ts_1 = epoch.start; + let end_ts_1 = start_ts_1 + (boost_period_length * multipliers1.len() as i32); + + // setup boosted hex where no start or end time is set + let multipliers2 = vec![NonZeroU32::new(2).unwrap()]; + + let boosted_hexes = vec![ + BoostedHexInfo { + // hotspot 1's location + location: Cell::from_raw(0x8a1fb466d2dffff_u64)?, + start_ts: Some(start_ts_1), + end_ts: Some(end_ts_1), + period_length: boost_period_length, + multipliers: multipliers1, + boosted_hex_pubkey: Pubkey::from_str(BOOST_HEX_PUBKEY).unwrap(), + boost_config_pubkey: Pubkey::from_str(BOOST_CONFIG_PUBKEY).unwrap(), + version: 0, + }, + BoostedHexInfo { + // hotspot 3's location + location: Cell::from_raw(0x8c2681a306607ff_u64)?, + start_ts: None, + end_ts: None, + period_length: boost_period_length, + multipliers: multipliers2, + boosted_hex_pubkey: Pubkey::from_str(BOOST_HEX_PUBKEY).unwrap(), + boost_config_pubkey: Pubkey::from_str(BOOST_CONFIG_PUBKEY).unwrap(), + version: 0, + }, + ]; + + let hex_boosting_client = MockHexBoostingClient::new(boosted_hexes); + let total_poc_emissions = reward_shares::get_scheduled_tokens_for_poc(epoch_duration) + .to_u64() + .unwrap(); + + let (_, rewards) = tokio::join!( + // run rewards for poc and dc + rewarder::reward_poc_and_dc( + &pool, + &hex_boosting_client, + &mobile_rewards_client, + &speedtest_avg_client, + &epoch, + dec!(0.0001) + ), + receive_expected_rewards_maybe_unallocated( + &mut mobile_rewards, + ExpectUnallocated::NoWhenValue(total_poc_emissions) + ) + ); + + let Ok((poc_rewards, unallocated_reward)) = rewards else { + panic!("no rewards received"); + }; + + let mut poc_rewards = poc_rewards.iter(); + let hotspot_2 = poc_rewards.next().unwrap(); // full location trust NO boosts + let hotspot_1 = poc_rewards.next().unwrap(); // full location trust 1 boost + let hotspot_3 = poc_rewards.next().unwrap(); // reduced location trust 1 boost + assert_eq!( + None, + poc_rewards.next(), + "Received more hotspots than expected in rewards" + ); + assert_eq!( + HOTSPOT_1.to_string(), + PublicKeyBinary::from(hotspot_1.hotspot_key.clone()).to_string() + ); + assert_eq!( + HOTSPOT_2.to_string(), + PublicKeyBinary::from(hotspot_2.hotspot_key.clone()).to_string() + ); + assert_eq!( + HOTSPOT_3.to_string(), + PublicKeyBinary::from(hotspot_3.hotspot_key.clone()).to_string() + ); + + // Calculating expected rewards + let (regular_poc, boosted_poc) = get_poc_allocation_buckets(epoch_duration); + + // Here's how we get the regular shares per coverage points + // | base coverage point | speedtest | location | total | + // |---------------------|-----------|----------|-------| + // | 400 | 0.75 | 1.00 | 300 | + // | 400 | 0.75 | 1.00 | 300 | + // | 400 | 0.75 | 1.00 | 300 | + // |---------------------|-----------|----------|-------| + // | 900 | + let regular_share = regular_poc / dec!(900); + + // Boosted hexes are 2x, only one radio qualifies based on the location trust + // 300 * 1 == 300 + // To get points _only_ from boosting. + let boosted_share = boosted_poc / dec!(300); + + let exp_reward_1 = + rounded(regular_share * dec!(300)) + rounded(boosted_share * dec!(300) * dec!(1)); + let exp_reward_2 = + rounded(regular_share * dec!(300)) + rounded(boosted_share * dec!(300) * dec!(0)); + let exp_reward_3 = + rounded(regular_share * dec!(300)) + rounded(boosted_share * dec!(300) * dec!(0)); + + assert_eq!(exp_reward_1, hotspot_1.poc_reward); + assert_eq!(exp_reward_2, hotspot_2.poc_reward); + assert_eq!(exp_reward_3, hotspot_3.poc_reward); + + // assert the number of boosted hexes for each radio + //hotspot 1 has one boosted hex + assert_eq!(1, hotspot_1.boosted_hexes.len()); + //hotspot 2 has no boosted hexes + assert_eq!(0, hotspot_2.boosted_hexes.len()); + // hotspot 3 has a boosted location but as its location trust score + // is reduced the boost does not get applied + assert_eq!(0, hotspot_3.boosted_hexes.len()); + + // assert the hex boost multiplier values + // assert_eq!(2, hotspot_1.boosted_hexes[0].multiplier); + assert_eq!(2, hotspot_1.boosted_hexes[0].multiplier); + assert_eq!(0x8a1fb466d2dffff_u64, hotspot_1.boosted_hexes[0].location); + + // confirm the total rewards allocated matches expectations + let poc_sum = hotspot_1.poc_reward + hotspot_2.poc_reward + hotspot_3.poc_reward; + let total = poc_sum + unallocated_reward.amount; + + let expected_sum = reward_shares::get_scheduled_tokens_for_poc(epoch.end - epoch.start) + .to_u64() + .unwrap(); + assert_eq!(expected_sum, total); + + // confirm the rewarded percentage amount matches expectations + let daily_total = reward_shares::get_total_scheduled_tokens(epoch.end - epoch.start); + let percent = (Decimal::from(total) / daily_total) + .round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven); + assert_eq!(percent, dec!(0.6)); + + Ok(()) +} + #[sqlx::test] async fn test_poc_with_cbrs_and_multi_coverage_boosted_hexes(pool: PgPool) -> anyhow::Result<()> { let (mobile_rewards_client, mut mobile_rewards) = common::create_file_sink(); @@ -1143,100 +1338,24 @@ async fn seed_heartbeats_v1( ts: DateTime, txn: &mut Transaction<'_, Postgres>, ) -> anyhow::Result<()> { - for n in 0..24 { - let hotspot_key1: PublicKeyBinary = HOTSPOT_1.to_string().parse().unwrap(); - let cov_obj_1 = create_coverage_object( - ts + ChronoDuration::hours(n), - None, - hotspot_key1.clone(), - 0x8a1fb466d2dffff_u64, - true, - ); - let wifi_heartbeat1 = ValidatedHeartbeat { - heartbeat: Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot_key1, - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(cov_obj_1.coverage_object.uuid), - location_validation_timestamp: Some(ts - ChronoDuration::hours(24)), - timestamp: ts + ChronoDuration::hours(n), - }, - cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(10), - coverage_meta: None, - location_trust_score_multiplier: dec!(1.0), - validity: HeartbeatValidity::Valid, - }; - - let hotspot_key2: PublicKeyBinary = HOTSPOT_2.to_string().parse().unwrap(); - let cov_obj_2 = create_coverage_object( - ts + ChronoDuration::hours(n), - None, - hotspot_key2.clone(), - 0x8a1fb49642dffff_u64, - true, - ); - let wifi_heartbeat2 = ValidatedHeartbeat { - heartbeat: Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot_key2, - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(cov_obj_2.coverage_object.uuid), - location_validation_timestamp: Some(ts - ChronoDuration::hours(24)), - timestamp: ts + ChronoDuration::hours(n), - }, - cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(10), - coverage_meta: None, - location_trust_score_multiplier: dec!(1.0), - validity: HeartbeatValidity::Valid, - }; - - let hotspot_key3: PublicKeyBinary = HOTSPOT_3.to_string().parse().unwrap(); - let cov_obj_3 = create_coverage_object( - ts + ChronoDuration::hours(n), - None, - hotspot_key3.clone(), - 0x8c2681a306607ff_u64, - true, - ); - let wifi_heartbeat3 = ValidatedHeartbeat { - heartbeat: Heartbeat { - hb_type: HbType::Wifi, - hotspot_key: hotspot_key3, - cbsd_id: None, - operation_mode: true, - lat: 0.0, - lon: 0.0, - coverage_object: Some(cov_obj_3.coverage_object.uuid), - location_validation_timestamp: Some(ts - ChronoDuration::hours(24)), - timestamp: ts + ChronoDuration::hours(n), - }, - cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(10), - coverage_meta: None, - location_trust_score_multiplier: dec!(1.0), - validity: HeartbeatValidity::Valid, - }; - - save_seniority_object(ts + ChronoDuration::hours(n), &wifi_heartbeat1, txn).await?; - save_seniority_object(ts + ChronoDuration::hours(n), &wifi_heartbeat2, txn).await?; - save_seniority_object(ts + ChronoDuration::hours(n), &wifi_heartbeat3, txn).await?; - - wifi_heartbeat1.save(txn).await?; - wifi_heartbeat2.save(txn).await?; - wifi_heartbeat3.save(txn).await?; + seed_heartbeats_with_location_trust( + ts, + txn, + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + HotspotLocationTrust { + meters: 10, + multiplier: dec!(1.0), + }, + ) + .await?; - cov_obj_1.save(txn).await?; - cov_obj_2.save(txn).await?; - cov_obj_3.save(txn).await?; - } Ok(()) } @@ -1342,13 +1461,18 @@ async fn seed_heartbeats_v2( Ok(()) } -async fn seed_heartbeats_v3( +struct HotspotLocationTrust { + meters: i64, + multiplier: Decimal, +} + +async fn seed_heartbeats_with_location_trust( ts: DateTime, txn: &mut Transaction<'_, Postgres>, + hs_1_location: HotspotLocationTrust, + hs_2_location: HotspotLocationTrust, + hs_3_location: HotspotLocationTrust, ) -> anyhow::Result<()> { - // HOTSPOT 1 has full location trust score - // HOTSPOT 2 has full location trust score - // HOTSPOT 3 has reduced location trust score for n in 0..24 { let hotspot_key1: PublicKeyBinary = HOTSPOT_1.to_string().parse().unwrap(); let cov_obj_1 = create_coverage_object( @@ -1371,9 +1495,9 @@ async fn seed_heartbeats_v3( timestamp: ts + ChronoDuration::hours(n), }, cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(10), + distance_to_asserted: Some(hs_1_location.meters), coverage_meta: None, - location_trust_score_multiplier: dec!(1.0), + location_trust_score_multiplier: hs_1_location.multiplier, validity: HeartbeatValidity::Valid, }; @@ -1398,9 +1522,9 @@ async fn seed_heartbeats_v3( timestamp: ts + ChronoDuration::hours(n), }, cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(10), + distance_to_asserted: Some(hs_2_location.meters), coverage_meta: None, - location_trust_score_multiplier: dec!(1.0), + location_trust_score_multiplier: hs_2_location.multiplier, validity: HeartbeatValidity::Valid, }; @@ -1425,9 +1549,9 @@ async fn seed_heartbeats_v3( timestamp: ts + ChronoDuration::hours(n), }, cell_type: CellType::NovaGenericWifiIndoor, - distance_to_asserted: Some(300), + distance_to_asserted: Some(hs_3_location.meters), coverage_meta: None, - location_trust_score_multiplier: dec!(0.25), + location_trust_score_multiplier: hs_3_location.multiplier, validity: HeartbeatValidity::Valid, }; diff --git a/mobile_verifier/tests/integrations/last_location.rs b/mobile_verifier/tests/integrations/last_location.rs index c5ba6a150..5ba87e932 100644 --- a/mobile_verifier/tests/integrations/last_location.rs +++ b/mobile_verifier/tests/integrations/last_location.rs @@ -42,7 +42,7 @@ impl GatewayResolver for AllOwnersValid { } #[sqlx::test] -async fn heatbeat_uses_last_good_location_when_invalid_location( +async fn heartbeat_uses_last_good_location_when_invalid_location( pool: PgPool, ) -> anyhow::Result<()> { let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; @@ -63,7 +63,6 @@ async fn heatbeat_uses_last_good_location_when_invalid_location( &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -82,7 +81,6 @@ async fn heatbeat_uses_last_good_location_when_invalid_location( &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -108,7 +106,7 @@ async fn heatbeat_uses_last_good_location_when_invalid_location( } #[sqlx::test] -async fn heatbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::Result<()> { +async fn heartbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::Result<()> { let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; let epoch_start = Utc::now() - Duration::days(1); let epoch_end = epoch_start + Duration::days(2); @@ -127,7 +125,6 @@ async fn heatbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::R &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -151,7 +148,6 @@ async fn heatbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::R &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -177,7 +173,7 @@ async fn heatbeat_will_use_last_good_location_from_db(pool: PgPool) -> anyhow::R } #[sqlx::test] -async fn heatbeat_does_not_use_last_good_location_when_more_than_12_hours( +async fn heartbeat_does_not_use_last_good_location_when_more_than_12_hours( pool: PgPool, ) -> anyhow::Result<()> { let hotspot = PublicKeyBinary::from_str(PUB_KEY)?; @@ -199,7 +195,6 @@ async fn heatbeat_does_not_use_last_good_location_when_more_than_12_hours( &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -218,7 +213,6 @@ async fn heatbeat_does_not_use_last_good_location_when_more_than_12_hours( &AllOwnersValid, &coverage_objects, &location_cache, - 1, u32::MAX, &(epoch_start..epoch_end), &MockGeofence, @@ -227,7 +221,7 @@ async fn heatbeat_does_not_use_last_good_location_when_more_than_12_hours( assert_eq!( validated_heartbeat_2.location_trust_score_multiplier, - dec!(0.25) + dec!(0.00) ); Ok(()) diff --git a/mobile_verifier/tests/integrations/modeled_coverage.rs b/mobile_verifier/tests/integrations/modeled_coverage.rs index 4d5c64432..0a66fb322 100644 --- a/mobile_verifier/tests/integrations/modeled_coverage.rs +++ b/mobile_verifier/tests/integrations/modeled_coverage.rs @@ -6,6 +6,7 @@ use file_store::{ wifi_heartbeat::{WifiHeartbeat, WifiHeartbeatIngestReport}, }; use futures::stream::{self, StreamExt}; +use h3o::{CellIndex, LatLng}; use helium_crypto::PublicKeyBinary; use helium_proto::services::{ mobile_config::NetworkKeyRole, @@ -416,7 +417,6 @@ async fn process_input( &coverage_objects, &location_cache, 2000, - 2000, epoch, &MockGeofence, )); @@ -1382,26 +1382,15 @@ async fn ensure_lower_trust_score_for_distant_heartbeats(pool: PgPool) -> anyhow coverage_object.save(&mut transaction).await?; transaction.commit().await?; - let hb_1 = WifiHeartbeatIngestReport { - report: WifiHeartbeat { - pubkey: owner_1.clone(), - lon: -105.2715848904, - lat: 40.0194278140, - timestamp: DateTime::::MIN_UTC, - location_validation_timestamp: Some(DateTime::::MIN_UTC), - operation_mode: true, - coverage_object: Vec::from(coverage_object_uuid.into_bytes()), - }, - received_timestamp: Utc::now(), - }; - - let hb_1: Heartbeat = hb_1.into(); + let max_covered_distance = 5_000; + let coverage_object_cache = CoverageObjectCache::new(&pool); + let location_cache = LocationCache::new(&pool); - let hb_2 = WifiHeartbeatIngestReport { + let mk_heartbeat = |latlng: LatLng| WifiHeartbeatIngestReport { report: WifiHeartbeat { pubkey: owner_1.clone(), - lon: -105.2344693282443, - lat: 40.033526907035935, + lon: latlng.lng(), + lat: latlng.lat(), timestamp: DateTime::::MIN_UTC, location_validation_timestamp: Some(DateTime::::MIN_UTC), operation_mode: true, @@ -1410,70 +1399,54 @@ async fn ensure_lower_trust_score_for_distant_heartbeats(pool: PgPool) -> anyhow received_timestamp: Utc::now(), }; - let hb_2: Heartbeat = hb_2.into(); - - let coverage_object_cache = CoverageObjectCache::new(&pool); - let location_cache = LocationCache::new(&pool); - - let validated_hb_1 = ValidatedHeartbeat::validate( - hb_1, - &AllOwnersValid, - &coverage_object_cache, - &location_cache, - 2000, - 2000, - &(DateTime::::MIN_UTC..DateTime::::MAX_UTC), - &MockGeofence, - ) - .await - .unwrap(); - - assert_eq!(validated_hb_1.location_trust_score_multiplier, dec!(1.0)); - - let validated_hb_2 = ValidatedHeartbeat::validate( - hb_2.clone(), - &AllOwnersValid, - &coverage_object_cache, - &location_cache, - 1000000, - 2000, - &(DateTime::::MIN_UTC..DateTime::::MAX_UTC), - &MockGeofence, - ) - .await - .unwrap(); + let validate = |latlng: LatLng| { + ValidatedHeartbeat::validate( + mk_heartbeat(latlng).into(), + &AllOwnersValid, + &coverage_object_cache, + &location_cache, + max_covered_distance, + &(DateTime::::MIN_UTC..DateTime::::MAX_UTC), + &MockGeofence, + ) + }; - assert_eq!(validated_hb_2.location_trust_score_multiplier, dec!(0.25)); + let covered_cell_index: CellIndex = "8c2681a3064d9ff".parse()?; + let covered_latlng = LatLng::from(covered_cell_index); - let validated_hb_2 = ValidatedHeartbeat::validate( - hb_2.clone(), - &AllOwnersValid, - &coverage_object_cache, - &location_cache, - 2000, - 1000000, - &(DateTime::::MIN_UTC..DateTime::::MAX_UTC), - &MockGeofence, - ) - .await - .unwrap(); + // Constrain distances by only moving vertically + let near_latlng = LatLng::new(40.0194278140, -105.272)?; // 35m + let med_latlng = LatLng::new(40.0194278140, -105.274)?; // 205m + let far_latlng = LatLng::new(40.0194278140, -105.3)?; // 2,419m + let past_latlng = LatLng::new(40.0194278140, 105.2715848904)?; // 10,591,975m - assert_eq!(validated_hb_2.location_trust_score_multiplier, dec!(0.25)); + // It's easy to gloss over floats, let make sure the distances are within the ranges we expect. + assert!((0.0..=200.0).contains(&covered_latlng.distance_m(near_latlng))); // Indoor low distance <= 200 + assert!((200.0..=300.0).contains(&covered_latlng.distance_m(med_latlng))); // Indoor Medium distance <= 300 + assert!(covered_latlng.distance_m(far_latlng) > 300.0); // Indoor Over Distance => 300 + assert!(covered_latlng.distance_m(past_latlng) > max_covered_distance as f64); // Indoor past max distance => max_distance - let validated_hb_2 = ValidatedHeartbeat::validate( - hb_2.clone(), - &AllOwnersValid, - &coverage_object_cache, - &location_cache, - 1000000, - 1000000, - &(DateTime::::MIN_UTC..DateTime::::MAX_UTC), - &MockGeofence, - ) - .await - .unwrap(); + let low_dist_validated = validate(near_latlng).await?; + let med_dist_validated = validate(med_latlng).await?; + let far_dist_validated = validate(far_latlng).await?; + let past_dist_validated = validate(past_latlng).await?; - assert_eq!(validated_hb_2.location_trust_score_multiplier, dec!(1.0)); + assert_eq!( + low_dist_validated.location_trust_score_multiplier, + dec!(1.0) + ); + assert_eq!( + med_dist_validated.location_trust_score_multiplier, + dec!(0.25) + ); + assert_eq!( + far_dist_validated.location_trust_score_multiplier, + dec!(0.00) + ); + assert_eq!( + past_dist_validated.location_trust_score_multiplier, + dec!(0.00) + ); Ok(()) }