From 4fc60cdcc4fd6a3e35d1ceffe77e1970ad7556ca Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Tue, 25 Aug 2026 13:21:15 -0700 Subject: [PATCH 1/7] HIP-150 Decision 3: contribute service provider rewards to deployers (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nova Labs contributes its Service Provider Rewards to the Deployer Data Reward Pool, moving the Mobile data bucket from 70% to 94% of the Mobile sub-DAO slice and the Service Provider allocation to zero. Set SERVICE_PROVIDER_PERCENT to 0. `emissions_split::split` already makes data transfer the residual of `hnt_rewards_issued`, so the whole issued amount now flows to the data pool with no change to the split mechanism — the `service_provider + data_transfer == floor(hnt_rewards_issued)` invariant and its proptests hold untouched. `reward_service_providers` writes no reward at all rather than one with `amount: 0`, so consumers see no service-provider rows for a suspended epoch rather than rows claiming an award of nothing. Guarded on the amount rather than deleted: the contribution runs to 2027-07-31 and may be extended once by a year, so this is a suspension and not a retirement. Restoring the allocation is SERVICE_PROVIDER_PERCENT alone. The two service-provider integration tests took a PgPool they never used, which made them require a live Postgres; they exercise the pool split and the file sink only, so they move to #[tokio::test]. Decision 2 (direct minting, 80% target, and the on-chain bucket move) is handled upstream and must deploy in the same window as this change. --- mobile_verifier/src/reward_shares.rs | 15 ++- .../src/reward_shares/emissions_split.rs | 117 +++++++++++------- mobile_verifier/src/rewarder.rs | 22 +++- .../tests/integrations/reward_dc.rs | 45 +++---- .../tests/integrations/rewarder_sp_rewards.rs | 99 ++++++++------- 5 files changed, 178 insertions(+), 120 deletions(-) diff --git a/mobile_verifier/src/reward_shares.rs b/mobile_verifier/src/reward_shares.rs index 3d0649a7e..1eb7ad37d 100644 --- a/mobile_verifier/src/reward_shares.rs +++ b/mobile_verifier/src/reward_shares.rs @@ -13,8 +13,19 @@ const DC_USD_PRICE: Decimal = dec!(0.00001); /// Default precision used for rounding pub const DEFAULT_PREC: u32 = 15; -// Percent of total emissions allocated for service provider rewards -const SERVICE_PROVIDER_PERCENT: Decimal = dec!(0.24); +// Percent of total emissions allocated for service provider rewards. +// +// HIP-150 Decision 3: Nova Labs contributes its Service Provider Rewards to the +// Deployer Data Reward Pool, moving the Mobile data bucket from 70% to 94% and +// this allocation to zero. The contribution runs to 2027-07-31 and may be +// extended once by a year; ending it earlier takes a later HIP. It is a +// suspension, not a retirement — the Service Provider role stays defined +// throughout, and unrewarded only while the contribution lasts. +// +// Restoring the allocation is this constant and nothing else: the split in +// `emissions_split` and the guard in `rewarder::reward_service_providers` both +// key off the resulting amount. +const SERVICE_PROVIDER_PERCENT: Decimal = dec!(0); /// Returns the equivalent amount of Hnt bones for a specified amount of Data Credits pub fn dc_to_hnt_bones(dc_amount: Decimal, hnt_bone_price: Decimal) -> Decimal { diff --git a/mobile_verifier/src/reward_shares/emissions_split.rs b/mobile_verifier/src/reward_shares/emissions_split.rs index 4f71535ff..831a258b8 100644 --- a/mobile_verifier/src/reward_shares/emissions_split.rs +++ b/mobile_verifier/src/reward_shares/emissions_split.rs @@ -1,4 +1,4 @@ -//! HIP-149 sub-DAO reward split. +//! Sub-DAO reward split. //! //! Each epoch the chain hands the rewarder two figures via [`EpochRewardInfo`]: //! `epoch_emissions` (the 100% total) and `hnt_rewards_issued` (the slice this @@ -7,26 +7,31 @@ //! //! The rewarder splits `hnt_rewards_issued` into two pools: //! -//! * **Service providers** — a flat 24% of *total* emissions. HIP-149 keeps this -//! fixed regardless of the cap/backstop. +//! * **Service providers** — a flat [`SERVICE_PROVIDER_PERCENT`] of *total* +//! emissions, fixed regardless of the cap/backstop. //! * **Data transfer** — the *residual*: `hnt_rewards_issued − service_provider`. //! -//! Making data transfer the residual (rather than a fixed 70%) is what keeps the -//! split exact under HIP-149. The 3× cap moves HNT out of the data bucket and +//! **Under HIP-150 the service-provider percent is zero**, so data transfer is +//! the whole issued amount. The split is kept rather than collapsed because the +//! contribution is a suspension with an end date (2027-07-31, extendable once), +//! not a retirement — see [`SERVICE_PROVIDER_PERCENT`]. Everything below +//! describes the mechanism that governs both states. +//! +//! Making data transfer the residual (rather than a fixed percentage of its own) +//! is what keeps the split exact. The 3× cap moves HNT out of the data bucket and //! into delegation, shrinking `hnt_rewards_issued`; the backstop re-emits HNT -//! into it, growing `hnt_rewards_issued`. A fixed `0.70 × emissions` would +//! into it, growing `hnt_rewards_issued`. A fixed percentage of emissions would //! over-allocate when earnings run over the cap (minting HNT that was moved to //! delegators) and under-allocate when they fall under the backstop (stranding -//! the re-emitted HNT). The residual -//! instead absorbs the shift — and the sub-bone dropped when flooring the -//! service-provider 24% — so +//! the re-emitted HNT). The residual instead absorbs the shift — and the sub-bone +//! dropped when flooring the service-provider share — so //! //! ```text //! service_provider + data_transfer == floor(hnt_rewards_issued) //! ``` //! -//! holds exactly, every epoch. The proptests below pin that invariant across the -//! full input range. +//! holds exactly, every epoch, at any percent. The proptests below pin that +//! invariant across the full input range. use crate::rewarder::EpochRewardInfo; use rust_decimal::Decimal; @@ -43,6 +48,10 @@ pub struct RewardPools { /// Split an epoch's `hnt_rewards_issued` into the service-provider and /// data-transfer pools (see module docs). +/// +/// The split mechanism is HIP-149's and is unchanged; HIP-150 sets +/// [`SERVICE_PROVIDER_PERCENT`] to zero, which makes data transfer the whole +/// issued amount. pub fn hip_149_reward_pools(reward_info: &EpochRewardInfo) -> RewardPools { split(reward_info.epoch_emissions, reward_info.hnt_rewards_issued) } @@ -53,13 +62,14 @@ pub fn hip_149_reward_pools(reward_info: &EpochRewardInfo) -> RewardPools { fn split(total_emissions: Decimal, hnt_rewards_issued: Decimal) -> RewardPools { let hnt = floor_to_u64(hnt_rewards_issued); - // Service providers take a flat 24% of *total* emissions, floored. We never - // round *up* — that is what guarantees the service-provider pool can't push - // the distributed total past `hnt_rewards_issued`. The `.min(hnt)` clamp - // keeps the residual non-negative even if the chain ever reported delegation - // above 76% of total (beyond what the 3× cap can produce, since it can move - // at most the 70% data bucket): in that case service providers simply take - // whatever HNT was issued. + // Service providers take a flat `SERVICE_PROVIDER_PERCENT` of *total* + // emissions, floored. We never round *up* — that is what guarantees the + // service-provider pool can't push the distributed total past + // `hnt_rewards_issued`. The `.min(hnt)` clamp keeps the residual non-negative + // if the chain ever reported delegation so high that the percent exceeds what + // was issued: in that case service providers simply take whatever HNT there + // was. Dormant while the percent is zero (HIP-150), and kept for when the + // contribution ends. let service_provider = floor_to_u64(total_emissions * SERVICE_PROVIDER_PERCENT).min(hnt); // Data transfer is the residual. `service_provider <= hnt`, so this never @@ -90,13 +100,14 @@ mod tests { } #[test] - fn baseline_6pct_delegation_is_70_24() { - // total = 100, delegation = 6 → hnt = 94. SP = 24, data = 70. + fn baseline_6pct_delegation_gives_data_the_whole_94() { + // total = 100, delegation = 6 → hnt = 94. HIP-150: SP = 0, so data takes + // all 94 — the 70 it had under HIP-149 plus the 24 SP contributed. assert_eq!( run(100, 94), RewardPools { - service_provider: 24, - data_transfer: 70 + service_provider: 0, + data_transfer: 94 } ); } @@ -104,12 +115,12 @@ mod tests { #[test] fn cap_shrinks_data_only() { // 3× cap moved 14 from data → delegation: delegation 6+14=20, hnt = 80. - // SP stays at 24% of total; data absorbs the whole 14 (70 → 56). + // Data absorbs the whole cut. assert_eq!( run(100, 80), RewardPools { - service_provider: 24, - data_transfer: 56 + service_provider: 0, + data_transfer: 80 } ); } @@ -117,36 +128,37 @@ mod tests { #[test] fn backstop_grows_data_only() { // Backstop re-emitted HNT into the data bucket: hnt = 98 (> 94). - // SP unchanged; data absorbs the boost (70 → 74). + // Data absorbs the boost. assert_eq!( run(100, 98), RewardPools { - service_provider: 24, - data_transfer: 74 + service_provider: 0, + data_transfer: 98 } ); } #[test] - fn no_delegation_gives_sp_24_data_76() { + fn no_delegation_gives_data_everything() { assert_eq!( run(100, 100), RewardPools { - service_provider: 24, - data_transfer: 76 + service_provider: 0, + data_transfer: 100 } ); } #[test] - fn extreme_cap_clamps_sp_to_issued_hnt() { - // Out-of-spec: only 20 HNT issued though 24% of total would be 24. SP - // takes all 20, data is 0 — still accounts for exactly `hnt`. + fn far_below_baseline_issuance_still_accounts_exactly() { + // Out-of-spec: only 20 HNT issued against 100 emitted. At percent 0 the + // `.min(hnt)` clamp is dormant and data simply takes what was issued. + // (Under a non-zero percent this is the case where SP would be clamped.) assert_eq!( run(100, 20), RewardPools { - service_provider: 20, - data_transfer: 0 + service_provider: 0, + data_transfer: 20 } ); } @@ -198,12 +210,14 @@ mod tests { prop_assert!(pools.data_transfer <= hnt); } - /// Service providers get a flat 24% of *total* emissions, independent of - /// how the cap/backstop split that total between hnt and delegation — - /// except in the out-of-spec regime where less than 24% was issued, where - /// SP is clamped to what's available. + /// Service providers get a flat `SERVICE_PROVIDER_PERCENT` of *total* + /// emissions, independent of how the cap/backstop split that total between + /// hnt and delegation — except in the out-of-spec regime where less than + /// that percent was issued, where SP is clamped to what's available. + /// Percent-agnostic: `expected_sp` reads the same constant, so this keeps + /// holding when the HIP-150 contribution ends and the percent returns. #[test] - fn service_provider_is_24pct_of_total( + fn service_provider_matches_configured_percent( hnt in 0u64..=MAX_BONES, delegation in 0u64..=MAX_BONES, ) { @@ -215,7 +229,8 @@ mod tests { /// Holding total emissions fixed, data transfer tracks issued HNT /// one-for-one — the cap (less hnt) shrinks it, the backstop (more hnt) /// grows it — while the service-provider pool is unchanged, as long as a - /// full 24% of total was issued (clamp not engaged). + /// full `SERVICE_PROVIDER_PERCENT` of total was issued (clamp not + /// engaged). #[test] fn data_tracks_hnt_one_for_one_with_sp_fixed( (total, hnt_a, hnt_b) in (1u64..=MAX_BONES).prop_flat_map(|total| { @@ -231,5 +246,23 @@ mod tests { hnt_a as i128 - hnt_b as i128 ); } + + /// HIP-150 Decision 3: with the service-provider contribution active, the + /// data-transfer pool is the *entire* issued amount and nothing is held + /// back. Distinct from `never_over_or_under_allocates_hnt`, which only + /// pins that the two pools sum to it — this pins which pool gets it. + /// + /// This test is specific to the suspension: when the contribution ends and + /// `SERVICE_PROVIDER_PERCENT` returns to a non-zero value, delete it. The + /// invariant tests above are the ones that hold in both states. + #[test] + fn hip_150_data_transfer_takes_the_whole_issued_amount( + hnt in 0u64..=MAX_BONES, + delegation in 0u64..=MAX_BONES, + ) { + let pools = run(hnt + delegation, hnt); + prop_assert_eq!(pools.service_provider, 0); + prop_assert_eq!(pools.data_transfer, hnt); + } } } diff --git a/mobile_verifier/src/rewarder.rs b/mobile_verifier/src/rewarder.rs index d4cc5981a..14a8b65d8 100644 --- a/mobile_verifier/src/rewarder.rs +++ b/mobile_verifier/src/rewarder.rs @@ -335,9 +335,10 @@ pub async fn reward_dc( ) .await?; - // HIP-149: data transfer is the residual of `hnt_rewards_issued` after the - // flat 24% service-provider cut, so the cap/backstop shift is absorbed here - // rather than over-/under-allocating. See `reward_shares::emissions_split`. + // Data transfer is the residual of `hnt_rewards_issued` after the flat + // service-provider cut, so the cap/backstop shift is absorbed here rather + // than over-/under-allocating. Under HIP-150 that cut is zero and the pool is + // the whole issued amount. See `reward_shares::emissions_split`. let pool = Decimal::from(hip_149_reward_pools(reward_info).data_transfer); // Demand is the HNT-bone value of the burned DC at the epoch price — a // telemetry input only (the payout rate is `pool / total_dc`, price-free). @@ -403,9 +404,22 @@ pub async fn reward_service_providers( reward_info: &EpochRewardInfo, reward_ctx: Option<(&iceberg::RewardWriters, &str)>, ) -> anyhow::Result<()> { - // HIP-149: a flat 24% of total emissions (see `reward_shares::emissions_split`). + // A flat percent of total emissions (see `reward_shares::emissions_split`). let sp_reward_amount = hip_149_reward_pools(reward_info).service_provider; + // HIP-150 Decision 3: Nova Labs contributes its Service Provider Rewards to + // the Deployer Data Reward Pool, so the pool is zero and *no reward is + // written at all* — not a reward of zero. Consumers see no service-provider + // rows for a suspended epoch rather than rows that claim an award of nothing. + // + // Guarded on the amount rather than deleted: the contribution runs to + // 2027-07-31 and may be extended once, so this path comes back. Restoring it + // is `SERVICE_PROVIDER_PERCENT` alone. + if sp_reward_amount == 0 { + tracing::info!("service provider pool is zero, skipping service provider rewards"); + return Ok(()); + } + // The entire service-provider pool goes to the HeliumMobile Network Wallet. let network_share = proto::ServiceProviderReward { service_provider_id: ServiceProvider::HeliumMobile.into(), diff --git a/mobile_verifier/tests/integrations/reward_dc.rs b/mobile_verifier/tests/integrations/reward_dc.rs index de4f10c90..beb1af9cf 100644 --- a/mobile_verifier/tests/integrations/reward_dc.rs +++ b/mobile_verifier/tests/integrations/reward_dc.rs @@ -268,9 +268,10 @@ async fn test_single_hotspot_takes_whole_pool() -> anyhow::Result<()> { Ok(()) } -// HIP-149 sizes data transfer as the residual of `hnt_rewards_issued` after a flat -// 24% service-provider cut, so the 3× cap (which moves HNT into delegation) and the -// backstop (which re-emits it) land entirely on the data-transfer pool. The two +// Data transfer is the residual of `hnt_rewards_issued` after the flat +// service-provider cut — zero under HIP-150 — so the 3× cap (which moves HNT into +// delegation) and the backstop (which re-emits it) land entirely on the +// data-transfer pool. The two // tests below drive a capped and a backstopped epoch end-to-end and assert the // distributed pool shrinks / grows accordingly — the only place that wiring is // exercised at the integration level (the split math itself is unit-tested in @@ -278,18 +279,17 @@ async fn test_single_hotspot_takes_whole_pool() -> anyhow::Result<()> { // independently of `hip_149_reward_pools` so they can't pass by mirroring a bug in // the code under test. -/// A round 1e12-bone pool. The 24% SP cut (240e9) clears the 45e9 subscriber floor, -/// and the residual data-transfer pools land on clean numbers. +/// A round 1e12-bone pool, so the data-transfer pools land on clean numbers. const SPLIT_TEST_EMISSIONS: u64 = 1_000_000_000_000; /// Baseline (6% delegation) data-transfer pool at [`SPLIT_TEST_EMISSIONS`]: -/// 94% issued (940e9) − 24% SP (240e9). The cap shrinks below this; the backstop -/// grows above it. -const BASELINE_DATA_POOL: u64 = 700_000_000_000; +/// 94% issued (940e9), all of it (HIP-150: the SP allocation is contributed to +/// this pool). The cap shrinks below this; the backstop grows above it. +/// Pre-HIP-150 this was 700e9 — 940e9 less a 24% SP cut of 240e9. +const BASELINE_DATA_POOL: u64 = 940_000_000_000; /// [`reward_info_24_hours`] with the on-chain split overridden. `hnt_issued` is what /// the chain handed this rewarder; `delegation` is paid to veHNT holders on-chain. -/// Emissions are their sum, so the service-provider pool (a flat 24% of emissions) -/// stays fixed while only the issued/delegation split moves. +/// Emissions are their sum, so only the issued/delegation split moves. fn reward_info_with_split(hnt_issued: u64, delegation: u64) -> EpochRewardInfo { let mut reward_info = reward_info_24_hours(); reward_info.epoch_emissions = Decimal::from(hnt_issued + delegation); @@ -299,13 +299,14 @@ fn reward_info_with_split(hnt_issued: u64, delegation: u64) -> EpochRewardInfo { } /// Data-transfer pool computed *independently* of `hip_149_reward_pools` (the code -/// the production path uses), via plain integer math: SP takes a flat floored 24% -/// of total emissions, data transfer is the rest of the issued HNT. +/// the production path uses). +/// +/// HIP-150 Decision 3: the service-provider allocation is contributed to the +/// deployer pool, so data transfer is the entire issued HNT. Before HIP-150 this +/// subtracted a floored 24% of total emissions; when the contribution ends that +/// subtraction comes back. fn expected_data_transfer_pool(reward_info: &EpochRewardInfo) -> u64 { - let emissions = reward_info.epoch_emissions.to_u64().unwrap(); - let hnt_issued = reward_info.hnt_rewards_issued.to_u64().unwrap(); - let service_provider = (emissions as u128 * 24 / 100) as u64; - hnt_issued - service_provider + reward_info.hnt_rewards_issued.to_u64().unwrap() } #[tokio::test] @@ -313,8 +314,8 @@ async fn test_cap_shrinks_data_transfer_pool() -> anyhow::Result<()> { let (mobile_rewards_client, mobile_rewards) = common::create_file_sink(); // 3× cap moved 14% of emissions out of the data bucket into delegation: - // delegation 6%+14%=20%, issued HNT 80%. SP holds at 24%, so data transfer - // absorbs the whole cut (70% → 56%). + // delegation 6%+14%=20%, issued HNT 80%. Data transfer takes the whole issued + // amount (HIP-150), so it absorbs the entire cut (94% → 80%). let reward_info = reward_info_with_split( SPLIT_TEST_EMISSIONS * 80 / 100, SPLIT_TEST_EMISSIONS * 20 / 100, @@ -339,7 +340,7 @@ async fn test_cap_shrinks_data_transfer_pool() -> anyhow::Result<()> { // The whole pool is distributed, and it is the cap-shrunk residual. let realized = rewards.dc_transfer_sum() + rewards.unallocated_sum(); assert_eq!(realized, expected_data_transfer_pool(&reward_info)); - assert_eq!(realized, 560_000_000_000, "80% issued − 24% SP"); + assert_eq!(realized, 800_000_000_000, "80% issued, no SP cut"); assert!( realized < BASELINE_DATA_POOL, "cap must shrink the data-transfer pool below baseline" @@ -353,8 +354,8 @@ async fn test_backstop_grows_data_transfer_pool() -> anyhow::Result<()> { let (mobile_rewards_client, mobile_rewards) = common::create_file_sink(); // Backstop re-emitted HNT into the data bucket: issued HNT rises to 98% - // (delegation 2%). SP still holds at 24%, so data transfer absorbs the boost - // (70% → 74%). + // (delegation 2%). Data transfer takes the whole issued amount (HIP-150), so + // it absorbs the entire boost (94% → 98%). let reward_info = reward_info_with_split( SPLIT_TEST_EMISSIONS * 98 / 100, SPLIT_TEST_EMISSIONS * 2 / 100, @@ -379,7 +380,7 @@ async fn test_backstop_grows_data_transfer_pool() -> anyhow::Result<()> { // The whole pool is distributed, and it is the backstop-grown residual. let realized = rewards.dc_transfer_sum() + rewards.unallocated_sum(); assert_eq!(realized, expected_data_transfer_pool(&reward_info)); - assert_eq!(realized, 740_000_000_000, "98% issued − 24% SP"); + assert_eq!(realized, 980_000_000_000, "98% issued, no SP cut"); assert!( realized > BASELINE_DATA_POOL, "backstop must grow the data-transfer pool above baseline" diff --git a/mobile_verifier/tests/integrations/rewarder_sp_rewards.rs b/mobile_verifier/tests/integrations/rewarder_sp_rewards.rs index f82d2d405..654616d21 100644 --- a/mobile_verifier/tests/integrations/rewarder_sp_rewards.rs +++ b/mobile_verifier/tests/integrations/rewarder_sp_rewards.rs @@ -1,66 +1,66 @@ +//! HIP-150 Decision 3: Nova Labs contributes its Service Provider Rewards to the +//! Deployer Data Reward Pool, so the service-provider pool is zero and the +//! rewarder emits nothing for it. +//! +//! These tests pin the *suspension*. The contribution runs to 2027-07-31 and may +//! be extended once by a year — when it ends and `SERVICE_PROVIDER_PERCENT` +//! returns to a non-zero value, these become wrong and the pre-HIP-150 versions +//! (asserting one reward at the configured percent) should come back. + use crate::common::{self, reward_info_24_hours}; -use helium_proto::{services::poc_mobile::UnallocatedRewardType, ServiceProvider}; -use mobile_verifier::reward_shares::RewardableEntityKey; use mobile_verifier::{reward_shares, rewarder}; -use rust_decimal::prelude::*; -use rust_decimal_macros::dec; -use sqlx::PgPool; +use rust_decimal::Decimal; -#[sqlx::test] -async fn test_service_provider_rewards(_pool: PgPool) -> anyhow::Result<()> { +// No database involved: these exercise the pool split and the file sink only. +// (The pre-HIP-150 versions took an unused `PgPool` via `#[sqlx::test]`, which +// made them require a live Postgres to run.) +#[tokio::test] +async fn test_no_service_provider_rewards_while_contribution_active() -> anyhow::Result<()> { let (mobile_rewards_client, mobile_rewards) = common::create_file_sink(); let reward_info = reward_info_24_hours(); + // The pool itself is zero... + assert_eq!( + reward_shares::hip_149_reward_pools(&reward_info).service_provider, + 0 + ); + rewarder::reward_service_providers(mobile_rewards_client, &reward_info, None).await?; let rewards = mobile_rewards.finish().await?; - // The entire service-provider pool goes to the HeliumMobile Network wallet. - assert_eq!(rewards.sp_rewards.len(), 1); - - let network_reward = rewards.sp_rewards.first().expect("sp reward"); - assert_eq!( - network_reward.service_provider_id, - ServiceProvider::HeliumMobile as i32 + // ...and no reward is written at all — not a reward of zero. A consumer sees + // no service-provider rows for the epoch rather than a row claiming an award + // of nothing. + assert!( + rewards.sp_rewards.is_empty(), + "expected no service provider rewards, got {:?}", + rewards.sp_rewards ); - assert_eq!( - network_reward.rewardable_entity_key, - RewardableEntityKey::Network.to_string() - ); - - // confirm the total rewards allocated matches the full 24% pool - let expected_sum = reward_shares::hip_149_reward_pools(&reward_info).service_provider; - assert_eq!(expected_sum, network_reward.amount); - // confirm the rewarded percentage amount matches expectations - let percent = (Decimal::from(network_reward.amount) / reward_info.epoch_emissions) - .round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven); - assert_eq!(percent, dec!(0.24)); - - // Verify no unallocated service provider rewards - assert_eq!( - rewards - .unallocated - .iter() - .filter(|r| r.reward_type == UnallocatedRewardType::ServiceProvider as i32) - .count(), - 0 + // Nor is the suspended pool reported as unallocated: there is no pool to + // leave unallocated, because it was never carved out of the issued HNT. + // `emissions_split` gives the whole issued amount to data transfer. + assert!( + rewards.unallocated.is_empty(), + "expected no unallocated rewards, got {:?}", + rewards.unallocated ); Ok(()) } -/// HIP-149: the service-provider pool is a flat 24% of *total* emissions, so the -/// 3× cap / backstop — which shifts HNT between issued and delegation — must leave -/// it untouched. Run a capped and a backstopped epoch at the same emissions and -/// confirm the emitted SP reward is identical. Complements the data-transfer -/// cap/backstop tests, which show the data pool absorbing the whole shift. -#[sqlx::test] -async fn test_service_provider_flat_across_cap_and_backstop(_pool: PgPool) -> anyhow::Result<()> { +/// The 3× cap and the backstop shift HNT between issued and delegation. Under +/// HIP-149 that had to leave the flat service-provider pool untouched; under +/// HIP-150 there is no pool to move, so the rewarder stays silent in both +/// regimes. Complements the data-transfer cap/backstop tests, which show the data +/// pool absorbing the whole shift. +#[tokio::test] +async fn test_no_service_provider_rewards_across_cap_and_backstop() -> anyhow::Result<()> { const EMISSIONS: u64 = 1_000_000_000_000; - async fn sp_total(hnt_issued: u64, delegation: u64) -> anyhow::Result { + async fn sp_reward_count(hnt_issued: u64, delegation: u64) -> anyhow::Result { let (client, sink) = common::create_file_sink(); let mut reward_info = reward_info_24_hours(); reward_info.epoch_emissions = Decimal::from(hnt_issued + delegation); @@ -69,19 +69,18 @@ async fn test_service_provider_flat_across_cap_and_backstop(_pool: PgPool) -> an rewarder::reward_service_providers(client, &reward_info, None).await?; let rewards = sink.finish().await?; - Ok(rewards.sp_rewards.iter().map(|r| r.amount).sum()) + Ok(rewards.sp_rewards.len()) } // Cap: issued 80%, delegation 20%. Backstop: issued 98%, delegation 2%. - let capped = sp_total(EMISSIONS * 80 / 100, EMISSIONS * 20 / 100).await?; - let backstopped = sp_total(EMISSIONS * 98 / 100, EMISSIONS * 2 / 100).await?; + let capped = sp_reward_count(EMISSIONS * 80 / 100, EMISSIONS * 20 / 100).await?; + let backstopped = sp_reward_count(EMISSIONS * 98 / 100, EMISSIONS * 2 / 100).await?; + assert_eq!(capped, 0, "cap must not produce a service provider reward"); assert_eq!( - capped, backstopped, - "SP pool must not move with the cap/backstop" + backstopped, 0, + "backstop must not produce a service provider reward" ); - // Independent of `hip_149_reward_pools`: a flat 24% of total emissions. - assert_eq!(capped, 240_000_000_000); Ok(()) } From b397eb84ad45a966bfa2ba3a5bcc53de9bc2c825 Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Wed, 26 Aug 2026 10:29:27 -0700 Subject: [PATCH 2/7] HIP-150: accept data transfer multiplier tickets in ingest (#1242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * HIP-150: accept data transfer multiplier tickets in ingest Adds submit_data_transfer_multiplier_ticket. A ticket grants one hotspot a multiplier on the data credits derived from its rewardable bytes. Ingest verifies the signer and the timestamp, then persists the ticket verbatim. It does not parse the multiplier — the packet verifier decides whether one is acceptable and records that verdict in a verified report, so a rejection stays as auditable as a grant. Notes: - Ticket signers get their own allow-list and is_ticket_signer() check, rather than a NetworkKeyRole variant: that enum belongs to the decommissioned mobile-config. A carrier key must not be able to grant a multiplier. - The list may be empty and defaults to empty, since the release ships before any ticket can be issued. Empty rejects every ticket and warns at startup. - Tickets older than data_transfer_multiplier_ticket_max_age (default 10 minutes) or dated in the future are refused. Signatures never expire, so without this a captured ticket is replayable forever. - valid_data_transfer_session gained a multiplier field with the proto bump; set to None here, so behaviour is unchanged. Cargo.toml patches helium-proto to proto's mj/hip-150 (helium/proto#483). REVERT BEFORE MERGING mj/hip-150 TO main. * HIP-150: tolerate client clock drift on multiplier tickets A client does not share a clock with ingest, so a ticket signed at what the client believes is "now" can arrive stamped slightly ahead of us. Those were refused as post-dated, which is a confusing failure for an honest client with a drifting clock. Tickets up to MAX_CLOCK_DRIFT (1 minute) in the future are now treated as current. Beyond that they are still refused: post-dating must not buy an attacker a longer replay window than an honest client gets, and a ticket inside the allowance still ages out of the freshness window at the same rate, it just starts a minute earlier. The allowance is a shared constant in file-store-oracles rather than a setting in each service. The packet verifier checks freshness too, and measures a ticket's age against the timestamp ingest stamped on it — so if ingest tolerated drift the verifier did not, every ticket ingest accepted from a fast client would be refused downstream. One value, not two that can be configured apart. --- Cargo.lock | 38 ++-- Cargo.toml | 10 +- file_store_oracles/src/file_type.rs | 1 + .../src/mobile/data_transfer_multiplier.rs | 21 ++ .../src/mobile/mobile_transfer.rs | 5 + file_store_oracles/src/mobile/mod.rs | 1 + .../src/traits/file_sink_write.rs | 5 + ingest/pkg/settings-template.toml | 21 ++ ingest/src/authorization.rs | 50 ++++- ingest/src/server_mobile.rs | 127 +++++++++++- ingest/src/settings.rs | 65 ++++-- ingest/tests/common/mod.rs | 94 ++++++++- ingest/tests/mobile_ingest.rs | 194 +++++++++++++++++- 13 files changed, 582 insertions(+), 50 deletions(-) create mode 100644 file_store_oracles/src/mobile/data_transfer_multiplier.rs diff --git a/Cargo.lock b/Cargo.lock index a3b128c9a..a1482e714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,17 +1715,17 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "beacon" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#e81cec790eca9a5bd3bebc8df78b4eb190949013" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "byteorder", "helium-proto", "prost", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand 0.7.3", + "rand_chacha 0.2.2", "rust_decimal", "serde", - "sha2 0.10.9", + "sha2 0.9.9", "thiserror 1.0.69", ] @@ -1767,7 +1767,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -3344,7 +3344,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "beacon", "blake3", "bs58", @@ -3974,7 +3974,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d292d4e2852445cbb610b515543a56b10d4a6fad90cfd6d281fe870f628573e" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "bs58", "byteorder", "ed25519-compact", @@ -4051,7 +4051,7 @@ dependencies = [ "angry-purple-tiger", "async-trait", "backon", - "base64 0.22.1", + "base64 0.21.7", "bincode", "bytemuck", "chrono", @@ -4089,7 +4089,7 @@ dependencies = [ [[package]] name = "helium-proto" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "bytes", "msg-signature", @@ -5467,7 +5467,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "blake3", "bs58", "chrono", @@ -5524,7 +5524,7 @@ version = "0.1.0" dependencies = [ "angry-purple-tiger", "anyhow", - "base64 0.22.1", + "base64 0.21.7", "clap", "custom-tracing", "dialoguer", @@ -5594,7 +5594,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "chrono", "clap", "config", @@ -5670,7 +5670,7 @@ dependencies = [ [[package]] name = "msg-signature" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "msg-signature-macro", ] @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "msg-signature-macro" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "quote", "syn 2.0.106", @@ -6587,7 +6587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", - "itertools 0.14.0", + "itertools 0.12.1", "log", "multimap", "once_cell", @@ -6609,7 +6609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.106", @@ -7225,7 +7225,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "bs58", "chrono", "clap", @@ -12548,7 +12548,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 22c9614e6..5c71a43a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,10 @@ anchor-lang = { git = "https://github.com/madninja/anchor.git", branch = "madnin # beacon = { path = "../proto/beacon" } [patch.'https://github.com/helium/proto'] -# helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } -# beacon = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } -# msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } +# HIP-150: the data transfer multiplier ticket messages live on proto's +# mj/hip-150 (helium/proto#483) and are not on master yet. +# REVERT BEFORE MERGING mj/hip-150 TO main — a feature-branch patch must not +# reach main. Tracked on the pre-deploy checklist. +helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +beacon = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } diff --git a/file_store_oracles/src/file_type.rs b/file_store_oracles/src/file_type.rs index 1c18573ca..c5b779074 100644 --- a/file_store_oracles/src/file_type.rs +++ b/file_store_oracles/src/file_type.rs @@ -153,5 +153,6 @@ make_string_mapped_enum! { EntityOwnershipChangeReport => "entity_ownership_change_report", EntityRewardDestinationChangeReport => "entity_reward_destination_change_report", EnabledCarriersInfoReport => "enabled_carriers_report", + DataTransferMultiplierTicketIngestReport => "data_transfer_multiplier_ticket_ingest_report", } } diff --git a/file_store_oracles/src/mobile/data_transfer_multiplier.rs b/file_store_oracles/src/mobile/data_transfer_multiplier.rs new file mode 100644 index 000000000..5b29c507c --- /dev/null +++ b/file_store_oracles/src/mobile/data_transfer_multiplier.rs @@ -0,0 +1,21 @@ +//! HIP-150 data transfer multipliers, and the tickets that grant them. + +use std::time::Duration; + +/// How far ahead of the receiving oracle's clock a ticket may be stamped. +/// +/// Clients do not share a clock with ingest, so a ticket signed at what the +/// client believes is "now" can arrive stamped slightly in the future. Without +/// some tolerance those are rejected as post-dated, which is a confusing failure +/// for an honest client with a drifting clock. +/// +/// One minute is enough for ordinary NTP-less drift and short enough that it +/// buys an attacker nothing: a post-dated ticket still ages out of the freshness +/// window at the same rate, it just starts a minute earlier. +/// +/// **Shared deliberately.** Ingest and the packet verifier both check freshness, +/// and the verifier measures a ticket's age against the timestamp *ingest* +/// stamped on it. If ingest tolerated drift the verifier did not, every ticket +/// ingest accepted from a fast client would then be refused downstream — so the +/// two must use one value, not two settings that can be configured apart. +pub const MAX_CLOCK_DRIFT: Duration = Duration::from_secs(60); diff --git a/file_store_oracles/src/mobile/mobile_transfer.rs b/file_store_oracles/src/mobile/mobile_transfer.rs index 52893ef81..aab9fb89d 100644 --- a/file_store_oracles/src/mobile/mobile_transfer.rs +++ b/file_store_oracles/src/mobile/mobile_transfer.rs @@ -57,6 +57,11 @@ impl From for proto::ValidDataTransferSession { last_timestamp: v.last_timestamp.encode_timestamp_millis(), rewardable_bytes: v.rewardable_bytes, burn_timestamp: v.burn_timestamp.encode_timestamp_millis(), + // HIP-150: populated once mobile-packet-verifier applies multipliers. + // Absent means no multiplier was in force, which is what every + // session is until then — so this preserves current behaviour + // exactly rather than asserting a 1x that was never looked up. + multiplier: None, } } } diff --git a/file_store_oracles/src/mobile/mod.rs b/file_store_oracles/src/mobile/mod.rs index 7d623d097..25f16c293 100644 --- a/file_store_oracles/src/mobile/mod.rs +++ b/file_store_oracles/src/mobile/mod.rs @@ -1,4 +1,5 @@ pub mod coverage; +pub mod data_transfer_multiplier; pub mod hex_boost; pub mod mobile_ban; pub mod mobile_radio_invalidated_threshold; diff --git a/file_store_oracles/src/traits/file_sink_write.rs b/file_store_oracles/src/traits/file_sink_write.rs index cf0b33948..4db2ef651 100644 --- a/file_store_oracles/src/traits/file_sink_write.rs +++ b/file_store_oracles/src/traits/file_sink_write.rs @@ -337,3 +337,8 @@ impl_file_sink!( FileType::EnabledCarriersInfoReport.to_str(), "enabled_carriers_report" ); +impl_file_sink!( + poc_mobile::DataTransferMultiplierTicketIngestReportV1, + FileType::DataTransferMultiplierTicketIngestReport.to_str(), + "data_transfer_multiplier_ticket_ingest_report" +); diff --git a/ingest/pkg/settings-template.toml b/ingest/pkg/settings-template.toml index ca0a07f5d..2895dcd79 100644 --- a/ingest/pkg/settings-template.toml +++ b/ingest/pkg/settings-template.toml @@ -31,6 +31,27 @@ network = "mainnet" # Ignored in "chain" mode. carrier_authorized_keys = "key1,key2" +# HIP-150. Comma-separated b58 public keys authorized to issue data transfer +# multiplier tickets. Deliberately separate from carrier_authorized_keys, so a +# carrier key cannot grant a hotspot a reward multiplier. +# +# Unlike carrier_authorized_keys this may be empty, and defaults to empty: the +# oracle release ships before any ticket can be issued. Empty fails closed — +# every ticket is rejected — and warns at startup. +# +# data_transfer_multiplier_authorized_keys = "key1,key2" + +# HIP-150. How old a ticket's signed timestamp may be before ingest refuses it. +# A signature never expires, so this is what stops a ticket captured off the +# wire from being replayed later. Defaults to 10 minutes. +# +# A ticket stamped slightly in the future is treated as current rather than +# refused, since clients do not share a clock with ingest. That allowance is a +# constant shared with the packet verifier, not a setting: the two check +# freshness against the same timestamp and must not be configured apart. +# +# data_transfer_multiplier_ticket_max_age = "10 minutes" + [output] # Output bucket for ingested data diff --git a/ingest/src/authorization.rs b/ingest/src/authorization.rs index 368119216..554554d9b 100644 --- a/ingest/src/authorization.rs +++ b/ingest/src/authorization.rs @@ -17,11 +17,21 @@ use helium_proto::services::mobile_config::NetworkKeyRole; #[derive(Debug, Clone, Default)] pub struct AuthorizedKeys { carrier: HashSet, + /// HIP-150 ticket issuers. Kept separate from `carrier` so that holding a + /// carrier key does not confer the ability to grant a hotspot a reward + /// multiplier. May be empty, in which case no ticket is accepted. + data_transfer_multiplier: HashSet, } impl AuthorizedKeys { - pub fn new(carrier: HashSet) -> Self { - Self { carrier } + pub fn new( + carrier: HashSet, + data_transfer_multiplier: HashSet, + ) -> Self { + Self { + carrier, + data_transfer_multiplier, + } } } @@ -29,6 +39,15 @@ impl AuthorizedKeys { /// [`AuthorizedKeys`] directly at call sites) so tests can substitute a mock. pub trait AuthorizationVerifier: Send + Sync + 'static { fn is_authorized(&self, address: &PublicKeyBinary, role: NetworkKeyRole) -> bool; + + /// HIP-150: may this key issue data transfer multiplier tickets? + /// + /// Deliberately not a `NetworkKeyRole` arm. That enum comes from + /// `mobile_config.proto`, and mobile-config is decommissioned in this + /// stack — it survives only as this trait's role parameter. Adding a + /// variant would grow a dead service's enum, so ticket authorization gets + /// its own check instead. + fn is_ticket_signer(&self, address: &PublicKeyBinary) -> bool; } impl AuthorizationVerifier for AuthorizedKeys { @@ -39,6 +58,10 @@ impl AuthorizationVerifier for AuthorizedKeys { _ => false, } } + + fn is_ticket_signer(&self, address: &PublicKeyBinary) -> bool { + self.data_transfer_multiplier.contains(address) + } } #[cfg(test)] @@ -51,7 +74,7 @@ mod tests { #[test] fn authorizes_only_configured_keys_per_role() { - let keys = AuthorizedKeys::new(HashSet::from([key(1)])); + let keys = AuthorizedKeys::new(HashSet::from([key(1)]), HashSet::new()); assert!(keys.is_authorized(&key(1), NetworkKeyRole::MobileCarrier)); assert!(!keys.is_authorized(&key(2), NetworkKeyRole::MobileCarrier)); @@ -60,4 +83,25 @@ mod tests { assert!(!keys.is_authorized(&key(1), NetworkKeyRole::Banning)); assert!(!keys.is_authorized(&key(1), NetworkKeyRole::MobileRouter)); } + + #[test] + fn ticket_signing_is_separate_from_the_carrier_allow_list() { + let keys = AuthorizedKeys::new(HashSet::from([key(1)]), HashSet::from([key(2)])); + + // A carrier key cannot grant a multiplier... + assert!(keys.is_authorized(&key(1), NetworkKeyRole::MobileCarrier)); + assert!(!keys.is_ticket_signer(&key(1))); + + // ...and a ticket signer is not thereby a carrier. + assert!(keys.is_ticket_signer(&key(2))); + assert!(!keys.is_authorized(&key(2), NetworkKeyRole::MobileCarrier)); + } + + #[test] + fn empty_ticket_allow_list_rejects_every_signer() { + let keys = AuthorizedKeys::new(HashSet::from([key(1)]), HashSet::new()); + + assert!(!keys.is_ticket_signer(&key(1))); + assert!(!keys.is_ticket_signer(&key(2))); + } } diff --git a/ingest/src/server_mobile.rs b/ingest/src/server_mobile.rs index ce29cc0b6..74a8a0124 100644 --- a/ingest/src/server_mobile.rs +++ b/ingest/src/server_mobile.rs @@ -2,15 +2,18 @@ use crate::{authorization::AuthorizationVerifier, Settings}; use anyhow::{bail, Error, Result}; use chrono::Utc; use file_store::{file_sink::FileSinkClient, file_upload}; +use file_store_oracles::mobile::data_transfer_multiplier::MAX_CLOCK_DRIFT; use file_store_oracles::traits::{FileSinkCommitStrategy, FileSinkRollTime, FileSinkWriteExt}; use futures_util::TryFutureExt; use helium_crypto::{Network, PublicKey, PublicKeyBinary}; use helium_proto::services::poc_mobile::{ self, BanIngestReportV1, BanReqV1, BanRespV1, CellHeartbeatReqV1, CellHeartbeatRespV1, CoverageObjectIngestReportV1, CoverageObjectReqV1, CoverageObjectRespV1, - DataTransferRadioAccessTechnology, DataTransferSessionIngestReportV1, DataTransferSessionReqV1, - DataTransferSessionRespV1, EnabledCarriersInfoReportV1, EnabledCarriersInfoReqV1, - EnabledCarriersInfoRespV1, HexUsageStatsIngestReportV1, HexUsageStatsReqV1, HexUsageStatsResV1, + DataTransferMultiplierTicketIngestReportV1, DataTransferMultiplierTicketReqV1, + DataTransferMultiplierTicketRespV1, DataTransferRadioAccessTechnology, + DataTransferSessionIngestReportV1, DataTransferSessionReqV1, DataTransferSessionRespV1, + EnabledCarriersInfoReportV1, EnabledCarriersInfoReqV1, EnabledCarriersInfoRespV1, + HexUsageStatsIngestReportV1, HexUsageStatsReqV1, HexUsageStatsResV1, InvalidatedRadioThresholdIngestReportV1, InvalidatedRadioThresholdReportReqV1, InvalidatedRadioThresholdReportRespV1, RadioThresholdIngestReportV1, RadioThresholdReportReqV1, RadioThresholdReportRespV1, RadioUsageStatsIngestReportV1, RadioUsageStatsIngestReportV2, @@ -29,7 +32,7 @@ use helium_proto::services::{ poc_mobile::{UniqueConnectionsReqV1, UniqueConnectionsRespV1}, }; use helium_proto_crypto::MsgVerify; -use std::net::SocketAddr; +use std::{net::SocketAddr, time::Duration}; use task_manager::{ManagedTask, TaskManager}; use tonic::{ metadata::{Ascii, MetadataValue}, @@ -58,6 +61,10 @@ pub struct GrpcServer { subscriber_mapping_activity_sink: FileSinkClient, ban_sink: FileSinkClient, enabled_carriers_sink: FileSinkClient, + data_transfer_multiplier_ticket_sink: + FileSinkClient, + /// How old a ticket's signed timestamp may be before it is refused. + data_transfer_multiplier_ticket_max_age: Duration, required_network: Network, address: SocketAddr, api_token: MetadataValue, @@ -107,6 +114,10 @@ where subscriber_mapping_activity_sink: FileSinkClient, ban_sink: FileSinkClient, enabled_carriers_sink: FileSinkClient, + data_transfer_multiplier_ticket_sink: FileSinkClient< + DataTransferMultiplierTicketIngestReportV1, + >, + data_transfer_multiplier_ticket_max_age: Duration, required_network: Network, address: SocketAddr, api_token: MetadataValue, @@ -129,6 +140,8 @@ where subscriber_mapping_activity_sink, ban_sink, enabled_carriers_sink, + data_transfer_multiplier_ticket_sink, + data_transfer_multiplier_ticket_max_age, required_network, address, api_token, @@ -177,6 +190,62 @@ where Ok((public_key, event)) } + /// HIP-150: is this key authorized to issue data transfer multiplier + /// tickets? Separate from the carrier allow-list, so a carrier key cannot + /// grant a hotspot a reward multiplier. + fn verify_known_ticket_signer(&self, public_key: PublicKey) -> VerifyResult<()> { + let public_key_bin = PublicKeyBinary::from(public_key); + if !self + .authorization_verifier + .is_ticket_signer(&public_key_bin) + { + tracing::error!(%public_key_bin, "unauthorized multiplier ticket signer"); + return Err(Status::permission_denied("unauthorized ticket signer")); + } + Ok(()) + } + + /// HIP-150: refuse a ticket older than the configured window, or dated + /// further ahead than a client's clock could plausibly drift. + /// + /// A signature never expires, so without this a ticket captured off the + /// wire stays usable forever — including after the grant it carries has + /// been superseded. The packet verifier checks freshness again when it + /// verifies; this keeps replays out of the pipeline in the first place. + /// + /// Clients do not share a clock with us, so a ticket stamped a little ahead + /// is treated as current rather than rejected — see [`MAX_CLOCK_DRIFT`]. + /// Beyond that it is refused: post-dating must not buy an attacker a longer + /// replay window than an honest client gets. + fn verify_ticket_freshness(&self, signed_ms: u64, received_ms: u64) -> VerifyResult<()> { + let age_ms = match received_ms.checked_sub(signed_ms) { + Some(age_ms) => age_ms, + // Stamped ahead of us. Within the drift allowance it counts as + // brand new; the subtraction below is what would have underflowed. + None => { + let drift_ms = signed_ms.saturating_sub(received_ms); + if Duration::from_millis(drift_ms) > MAX_CLOCK_DRIFT { + return Err(Status::invalid_argument(format!( + "ticket is dated {}s in the future, beyond the {}s clock drift allowance", + drift_ms / 1000, + MAX_CLOCK_DRIFT.as_secs() + ))); + } + 0 + } + }; + + let max_age = self.data_transfer_multiplier_ticket_max_age; + if Duration::from_millis(age_ms) > max_age { + return Err(Status::invalid_argument(format!( + "ticket is {}s old, older than the {}s limit", + age_ms / 1000, + max_age.as_secs() + ))); + } + Ok(()) + } + fn verify_known_carrier_key(&self, public_key: PublicKey) -> VerifyResult<()> { let public_key_bin = PublicKeyBinary::from(public_key); if !self @@ -676,6 +745,43 @@ where timestamp_ms: received_timestamp_ms, })) } + + async fn submit_data_transfer_multiplier_ticket( + &self, + request: Request, + ) -> GrpcResult { + let received_timestamp_ms = Utc::now().timestamp_millis() as u64; + let event = request.into_inner(); + + custom_tracing::record_b58("pub_key", &event.hotspot_pubkey); + + // Cheapest check first, before any signature work. + self.verify_ticket_freshness(event.timestamp_ms, received_timestamp_ms)?; + + let (signer, event) = self + .verify_public_key(&event.signer_pubkey) + .and_then(|public_key| self.verify_network(public_key)) + .and_then(|public_key| self.verify_signature(public_key, event))?; + self.verify_known_ticket_signer(signer)?; + + // The multiplier itself is not parsed here. Ingest verifies who sent + // this and when, then persists it verbatim; the packet verifier owns + // whether the multiplier is acceptable and records that verdict in a + // verified report, so a rejection is as auditable as a grant. + let report = DataTransferMultiplierTicketIngestReportV1 { + received_timestamp_ms, + report: Some(event), + }; + + _ = self + .data_transfer_multiplier_ticket_sink + .write(report, []) + .await; + + Ok(Response::new(DataTransferMultiplierTicketRespV1 { + timestamp_ms: received_timestamp_ms, + })) + } } fn is_data_transfer_for_cbrs(event: &DataTransferSessionReqV1) -> bool { @@ -838,6 +944,16 @@ pub async fn grpc_server(settings: &Settings) -> Result<()> { ) .await?; + let (data_transfer_multiplier_ticket_sink, data_transfer_multiplier_ticket_server) = + DataTransferMultiplierTicketIngestReportV1::file_sink( + &settings.cache, + file_upload.clone(), + FileSinkCommitStrategy::Automatic, + FileSinkRollTime::Duration(settings.roll_time), + env!("CARGO_PKG_NAME"), + ) + .await?; + let (subscriber_mapping_activity_sink, subscriber_mapping_activity_server) = SubscriberMappingActivityIngestReportV1::file_sink( &settings.cache, @@ -873,6 +989,8 @@ pub async fn grpc_server(settings: &Settings) -> Result<()> { subscriber_mapping_activity_sink, ban_sink, enabled_carriers_sink, + data_transfer_multiplier_ticket_sink, + settings.data_transfer_multiplier_ticket_max_age, settings.network, settings.listen_addr, api_token, @@ -903,6 +1021,7 @@ pub async fn grpc_server(settings: &Settings) -> Result<()> { .add_task(subscriber_mapping_activity_server) .add_task(ban_server) .add_task(enabled_carriers_server) + .add_task(data_transfer_multiplier_ticket_server) .add_task(grpc_server) .build() .start() diff --git a/ingest/src/settings.rs b/ingest/src/settings.rs index 369a0a8f2..5d84c0583 100644 --- a/ingest/src/settings.rs +++ b/ingest/src/settings.rs @@ -57,6 +57,25 @@ pub struct Settings { pub carrier_authorized_keys: String, /// Key that can sign Chain Rewardable Entities messages pub chain_rewardable_entities_auth_key: Option, + /// HIP-150: public keys authorized to issue data transfer multiplier + /// tickets. Comma-separated b58 keys. Ignored in "chain" mode. + /// + /// Unlike `carrier_authorized_keys` this may be empty, and is by default. + /// The oracle release ships before any ticket can be issued, so requiring a + /// key here would block the release on provisioning one. Empty fails + /// closed — every ticket is rejected — and logs a warning at startup so an + /// unconfigured deployment is visible. + #[serde(default)] + pub data_transfer_multiplier_authorized_keys: String, + /// HIP-150: how old a data transfer multiplier ticket's signed timestamp + /// may be before ingest refuses it. + /// + /// A signature never expires, so without this a captured ticket is + /// replayable forever. The packet verifier checks freshness again when it + /// verifies; this is the cheap boundary check that keeps replayed tickets + /// out of the pipeline entirely. + #[serde(with = "humantime_serde", default = "default_ticket_max_age")] + pub data_transfer_multiplier_ticket_max_age: Duration, } fn default_network() -> Network { @@ -67,6 +86,10 @@ fn default_cache() -> PathBuf { PathBuf::from("/opt/ingest/data") } +fn default_ticket_max_age() -> Duration { + humantime::parse_duration("10 minutes").unwrap() +} + fn default_roll_time() -> Duration { humantime::parse_duration("15 minutes").unwrap() } @@ -129,10 +152,22 @@ impl Settings { /// so a misconfiguration fails at startup rather than silently rejecting /// every carrier report. pub fn authorized_keys(&self) -> anyhow::Result { - Ok(AuthorizedKeys::new(parse_authorized_keys( - "carrier_authorized_keys", - &self.carrier_authorized_keys, - )?)) + let carrier = + parse_authorized_keys("carrier_authorized_keys", &self.carrier_authorized_keys)?; + + // HIP-150 ticket signers, unlike carrier keys, are optional — see the + // field docs. Warn rather than fail so the gap is visible in logs. + let data_transfer_multiplier = + parse_optional_authorized_keys(&self.data_transfer_multiplier_authorized_keys) + .context("settings parsing data_transfer_multiplier_authorized_keys")?; + if data_transfer_multiplier.is_empty() { + tracing::warn!( + "no data_transfer_multiplier_authorized_keys configured; \ + all data transfer multiplier tickets will be rejected" + ); + } + + Ok(AuthorizedKeys::new(carrier, data_transfer_multiplier)) } } @@ -140,15 +175,8 @@ impl Settings { /// entries are ignored; a list that yields no keys is an error, since each /// authorized-key role must be configured. fn parse_authorized_keys(setting: &str, keys: &str) -> anyhow::Result> { - let parsed: HashSet = keys - .split(',') - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(|key| { - PublicKeyBinary::from_str(key) - .with_context(|| format!("settings parsing {setting}: {key}")) - }) - .collect::>()?; + let parsed = parse_optional_authorized_keys(keys) + .with_context(|| format!("settings parsing {setting}"))?; if parsed.is_empty() { anyhow::bail!("no keys provided in settings for {setting}"); @@ -156,6 +184,17 @@ fn parse_authorized_keys(setting: &str, keys: &str) -> anyhow::Result anyhow::Result> { + keys.split(',') + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(|key| PublicKeyBinary::from_str(key).with_context(|| format!("invalid key: {key}"))) + .collect() +} + #[cfg(test)] mod tests { use super::parse_authorized_keys; diff --git a/ingest/tests/common/mod.rs b/ingest/tests/common/mod.rs index 45d1ee046..aadbd5cb1 100644 --- a/ingest/tests/common/mod.rs +++ b/ingest/tests/common/mod.rs @@ -6,12 +6,13 @@ use file_store_oracles::mobile_ban::proto::{BanAction, BanDetailsV1, BanReason}; use helium_crypto::{KeyTag, Keypair, Network, PublicKeyBinary, Sign}; use helium_proto::services::poc_mobile::{ BanIngestReportV1, BanReqV1, BanRespV1, CarrierIdV2, CellHeartbeatReqV1, CellHeartbeatRespV1, - DataTransferEvent, DataTransferRadioAccessTechnology, DataTransferSessionIngestReportV1, - DataTransferSessionReqV1, DataTransferSessionRespV1, EnabledCarriersInfoReportV1, - EnabledCarriersInfoReqV1, EnabledCarriersInfoRespV1, HexUsageStatsIngestReportV1, - HexUsageStatsReqV1, HexUsageStatsResV1, RadioUsageCarrierTransferInfo, - RadioUsageStatsIngestReportV1, RadioUsageStatsIngestReportV2, RadioUsageStatsReqV1, - RadioUsageStatsReqV2, RadioUsageStatsResV1, RadioUsageStatsResV2, + DataTransferEvent, DataTransferMultiplierTicketIngestReportV1, + DataTransferMultiplierTicketReqV1, DataTransferMultiplierTicketRespV1, + DataTransferRadioAccessTechnology, DataTransferSessionIngestReportV1, DataTransferSessionReqV1, + DataTransferSessionRespV1, EnabledCarriersInfoReportV1, EnabledCarriersInfoReqV1, + EnabledCarriersInfoRespV1, HexUsageStatsIngestReportV1, HexUsageStatsReqV1, HexUsageStatsResV1, + RadioUsageCarrierTransferInfo, RadioUsageStatsIngestReportV1, RadioUsageStatsIngestReportV2, + RadioUsageStatsReqV1, RadioUsageStatsReqV2, RadioUsageStatsResV1, RadioUsageStatsResV2, UniqueConnectionsIngestReportV1, UniqueConnectionsReqV1, UniqueConnectionsRespV1, }; use helium_proto::services::{ @@ -36,12 +37,21 @@ use tonic::{ }; use triggered::Trigger; +/// Freshness window the test server runs with. Wide enough that a ticket +/// stamped "now" always passes; tests that exercise the window set an explicit +/// timestamp rather than sleeping. +pub const TICKET_MAX_AGE: Duration = Duration::from_secs(600); + struct MockAuthorizationClient; impl AuthorizationVerifier for MockAuthorizationClient { fn is_authorized(&self, _pubkey: &PublicKeyBinary, _role: NetworkKeyRole) -> bool { true } + + fn is_ticket_signer(&self, _pubkey: &PublicKeyBinary) -> bool { + true + } } pub async fn setup_mobile() -> anyhow::Result<(TestClient, Trigger)> { setup_mobile_with_verifier(MockAuthorizationClient).await @@ -84,6 +94,7 @@ pub async fn setup_mobile_with_verifier( tokio::sync::mpsc::channel(10); let (ban_tx, ban_rx) = tokio::sync::mpsc::channel(10); let (enabled_carriers_tx, enabled_carriers_rx) = tokio::sync::mpsc::channel(10); + let (multiplier_ticket_tx, multiplier_ticket_rx) = tokio::sync::mpsc::channel(10); tokio::spawn(async move { let grpc_server = GrpcServer::new( @@ -103,6 +114,8 @@ pub async fn setup_mobile_with_verifier( FileSinkClient::new(subscriber_mapping_activity_tx, "noop"), FileSinkClient::new(ban_tx, "noop"), FileSinkClient::new(enabled_carriers_tx, "enabled_carriers_sink"), + FileSinkClient::new(multiplier_ticket_tx, "multiplier_ticket_sink"), + TICKET_MAX_AGE, Network::MainNet, socket_addr, api_token, @@ -124,6 +137,7 @@ pub async fn setup_mobile_with_verifier( ban_rx, data_transfer_rx, enabled_carriers_rx, + multiplier_ticket_rx, ) .await; @@ -147,6 +161,8 @@ pub struct TestClient { ban_file_sink_rx: Receiver>, data_transfer_rx: Receiver>, enabled_carriers_rx: Receiver>, + multiplier_ticket_rx: + Receiver>, } impl TestClient { @@ -175,6 +191,9 @@ impl TestClient { file_store::file_sink::Message, >, enabled_carriers_rx: Receiver>, + multiplier_ticket_rx: Receiver< + file_store::file_sink::Message, + >, ) -> TestClient { let client = (|| PocMobileClient::connect(format!("http://{socket_addr}"))) .retry(&ExponentialBuilder::default()) @@ -193,6 +212,7 @@ impl TestClient { ban_file_sink_rx, data_transfer_rx, enabled_carriers_rx, + multiplier_ticket_rx, } } @@ -378,6 +398,68 @@ impl TestClient { } } + /// The public key this client signs with, for tests that build a request + /// by hand instead of going through a `submit_*` helper. + pub fn signer_pubkey(&self) -> Vec { + self.key_pair.public_key().into() + } + + pub async fn multiplier_ticket_recv( + mut self, + ) -> anyhow::Result { + match timeout(Duration::from_secs(2), self.multiplier_ticket_rx.recv()).await { + Ok(Some(msg)) => match msg { + file_store::file_sink::Message::Commit(_) => bail!("got Commit"), + file_store::file_sink::Message::Rollback(_) => bail!("got Rollback"), + file_store::file_sink::Message::Data(_, data) => Ok(data), + }, + Ok(None) => bail!("got none"), + Err(reason) => bail!("got error {reason}"), + } + } + + /// Submit a ticket signed by the client's keypair. `timestamp_ms` is + /// explicit so freshness tests can stamp a ticket in the past or future + /// without sleeping. + pub async fn submit_multiplier_ticket( + &mut self, + hotspot_pubkey: Vec, + multiplier: &str, + timestamp_ms: u64, + ) -> anyhow::Result { + let mut req = DataTransferMultiplierTicketReqV1 { + hotspot_pubkey, + multiplier: Some(helium_proto::Decimal { + value: multiplier.to_string(), + }), + timestamp_ms, + message: "test ticket".to_string(), + signer_pubkey: self.key_pair.public_key().into(), + signature: vec![], + }; + req.signature = self.key_pair.sign(&req.encode_to_vec()).expect("sign"); + + self.send_multiplier_ticket(req).await + } + + /// Submit a ticket verbatim, without re-signing — for tests that need a + /// tampered or deliberately mis-signed request. + pub async fn send_multiplier_ticket( + &mut self, + req: DataTransferMultiplierTicketReqV1, + ) -> anyhow::Result { + let mut request = Request::new(req); + request + .metadata_mut() + .insert("authorization", self.authorization.clone()); + + let res = self + .client + .submit_data_transfer_multiplier_ticket(request) + .await?; + Ok(res.into_inner()) + } + pub async fn submit_ban(&mut self, hotspot_pubkey: Vec) -> anyhow::Result { use helium_proto::services::poc_mobile::BanType; let mut req = BanReqV1 { diff --git a/ingest/tests/mobile_ingest.rs b/ingest/tests/mobile_ingest.rs index 69ab92bc9..d3a39cc4b 100644 --- a/ingest/tests/mobile_ingest.rs +++ b/ingest/tests/mobile_ingest.rs @@ -1,9 +1,11 @@ use chrono::{TimeZone, Utc}; use common::generate_keypair; +use file_store_oracles::mobile::data_transfer_multiplier::MAX_CLOCK_DRIFT; use helium_crypto::PublicKeyBinary; use helium_proto::services::poc_mobile::{ - CarrierIdV2, DataTransferRadioAccessTechnology, RadioUsageCarrierDataTransferInfoV2, - RadioUsageCarrierTransferInfo, RadioUsageSamplingCarrierDataTransferInfoV1, + CarrierIdV2, DataTransferMultiplierTicketReqV1, DataTransferRadioAccessTechnology, + RadioUsageCarrierDataTransferInfoV2, RadioUsageCarrierTransferInfo, + RadioUsageSamplingCarrierDataTransferInfoV1, }; use ingest::AuthorizedKeys; use std::str::FromStr; @@ -468,3 +470,191 @@ async fn cbrs_data_transfer_after() -> anyhow::Result<()> { trigger.trigger(); Ok(()) } + +// ── HIP-150: data transfer multiplier tickets ─────────────────────────────── + +#[tokio::test] +async fn submit_data_transfer_multiplier_ticket() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let now = Utc::now().timestamp_millis() as u64; + let response = client + .submit_multiplier_ticket(pubkey.clone().into(), "1.5", now) + .await?; + + let report = client.multiplier_ticket_recv().await?; + assert_eq!(report.received_timestamp_ms, response.timestamp_ms); + + let ticket = report.report.expect("inner report"); + assert_eq!(PublicKeyBinary::from(ticket.hotspot_pubkey), pubkey); + assert_eq!(ticket.timestamp_ms, now); + + // Ingest persists the multiplier verbatim — it does not parse, normalize or + // range-check it. That is the packet verifier's job, so that a rejection is + // recorded in a verified report rather than vanishing at the boundary. + assert_eq!(ticket.multiplier.expect("multiplier").value, "1.5"); + + trigger.trigger(); + Ok(()) +} + +/// The multiplier allow-list is separate from the carrier one, so a permissive +/// carrier verifier must not let a ticket through. `AuthorizedKeys::default()` +/// has empty sets for both. +#[tokio::test] +async fn multiplier_ticket_rejects_unauthorized_signer() -> anyhow::Result<()> { + let (mut client, trigger) = + common::setup_mobile_with_verifier(AuthorizedKeys::default()).await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let now = Utc::now().timestamp_millis() as u64; + let res = client + .submit_multiplier_ticket(pubkey.into(), "1.5", now) + .await; + + assert!( + res.is_err(), + "ticket from an unauthorized signer must be rejected" + ); + + trigger.trigger(); + Ok(()) +} + +#[tokio::test] +async fn multiplier_ticket_rejects_bad_signature() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let req = DataTransferMultiplierTicketReqV1 { + hotspot_pubkey: pubkey.into(), + multiplier: Some(helium_proto::Decimal { + value: "1.5".to_string(), + }), + timestamp_ms: Utc::now().timestamp_millis() as u64, + message: "unsigned".to_string(), + signer_pubkey: client.signer_pubkey(), + signature: vec![1, 2, 3], + }; + + let res = client.send_multiplier_ticket(req).await; + assert!(res.is_err(), "ticket with a bad signature must be rejected"); + + trigger.trigger(); + Ok(()) +} + +/// A signature never expires, so a ticket captured off the wire stays valid +/// forever without a freshness window. Ingest refuses one older than the +/// configured limit. +#[tokio::test] +async fn multiplier_ticket_rejects_stale_timestamp() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let stale = (Utc::now() + - chrono::Duration::from_std(common::TICKET_MAX_AGE)? + - chrono::Duration::minutes(1)) + .timestamp_millis() as u64; + + let res = client + .submit_multiplier_ticket(pubkey.into(), "1.5", stale) + .await; + + assert!( + res.is_err(), + "ticket older than the window must be rejected" + ); + + trigger.trigger(); + Ok(()) +} + +/// A ticket dated further ahead than a clock could plausibly drift is refused — +/// otherwise post-dating would buy an attacker an arbitrarily long replay +/// window. +#[tokio::test] +async fn multiplier_ticket_rejects_future_timestamp() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let future = (Utc::now() + chrono::Duration::hours(1)).timestamp_millis() as u64; + + let res = client + .submit_multiplier_ticket(pubkey.into(), "1.5", future) + .await; + + assert!(res.is_err(), "future-dated ticket must be rejected"); + + trigger.trigger(); + Ok(()) +} + +/// Clients do not share a clock with ingest. A ticket stamped slightly ahead is +/// an honest client with a drifting clock, not an attack, and must be accepted. +#[tokio::test] +async fn multiplier_ticket_tolerates_client_clock_drift() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let drifted = (Utc::now() + chrono::Duration::from_std(MAX_CLOCK_DRIFT)? + - chrono::Duration::seconds(5)) + .timestamp_millis() as u64; + + client + .submit_multiplier_ticket(pubkey.into(), "1.5", drifted) + .await?; + + let report = client.multiplier_ticket_recv().await?; + assert_eq!(report.report.expect("inner").timestamp_ms, drifted); + + trigger.trigger(); + Ok(()) +} + +/// The far side of the allowance. Without this the drift test above would pass +/// just as well if the tolerance were unbounded. +#[tokio::test] +async fn multiplier_ticket_rejects_drift_beyond_the_allowance() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let too_far = + (Utc::now() + chrono::Duration::from_std(MAX_CLOCK_DRIFT)? + chrono::Duration::minutes(1)) + .timestamp_millis() as u64; + + let res = client + .submit_multiplier_ticket(pubkey.into(), "1.5", too_far) + .await; + + assert!( + res.is_err(), + "drift beyond the allowance must still be rejected" + ); + + trigger.trigger(); + Ok(()) +} + +/// A ticket right at the edge of the window is still accepted — pins that the +/// comparison is inclusive and that the window is not accidentally zero. +#[tokio::test] +async fn multiplier_ticket_accepts_timestamp_inside_window() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let inside = (Utc::now() - chrono::Duration::from_std(common::TICKET_MAX_AGE)? + + chrono::Duration::minutes(1)) + .timestamp_millis() as u64; + + client + .submit_multiplier_ticket(pubkey.into(), "1.5", inside) + .await?; + + let report = client.multiplier_ticket_recv().await?; + assert_eq!(report.report.expect("inner").timestamp_ms, inside); + + trigger.trigger(); + Ok(()) +} From de9dee35113c63f06a9d2760526e2c1e2279526a Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Thu, 27 Aug 2026 10:08:30 -0700 Subject: [PATCH 3/7] HIP-150: ingest data transfer multiplier tickets in the packet verifier (#1243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads ticket files from s3, rules on each ticket, writes the verdict to a verified report and to data_transfer.multiplier_ticket_history, and keeps data_transfer.multiplier_ticket_inventory merged out of that history. No behaviour change: nothing applies a multiplier yet. The burn path is untouched, and get_multipliers has no caller outside tests. A ticket can be issued, verified and recorded, and it affects nothing until the burn is wired up in a following change. Ruling here rather than at ingest means a refused ticket is recorded: every ticket produces a verified report and a history row, so the record shows why a hotspot is not multiplied as well as why it is. That includes a multiplier outside HIP-150's 1-to-5 range, which is refused here rather than at decode — the file poller discards records that fail to decode, so a ticket has to survive decoding in order to be refused on the record. Nothing is stored in Postgres. History and inventory: - multiplier_ticket_history is the append-only log of every ticket, refusals included. It answers "what happened". - multiplier_ticket_inventory is one row per hotspot holding what is currently in force, refreshed by a periodic Trino MERGE. Valid tickets only, so a refusal neither takes effect nor revokes the last grant it followed. - The merge follows the shape network-dbt uses for enabled_carriers_inventory over enabled_carriers_history — latest-per-key by a row_number() window, merged on the key — but the SQL is ours, issued from the periodic task. MERGE rather than the iceberg writer because that writer is append-only and cannot update a row in place; hence the inventory table is unpartitioned. - The burn will read the inventory: a session is multiplied by whatever is current when it is accumulated, not by what was in force when the data moved. Reconstructing the latter would be false precision — sessions arrive batched behind an ingest roll, tickets arrive on their own schedule, and burns run hourly, so that boundary is already fuzzy by minutes. The cost is that the refresh interval becomes burn-visible, and that replaying a backlog applies today's multipliers to old data. Ticket handling: - Latest-per-hotspot is ordered on the issuer's signed timestamp, not on arrival. A ticket is a correctly signed message that never expires, so a captured one can be resubmitted after the grant it carries was revoked; ordered by arrival it would win. Ordered by signed timestamp it sorts below the ticket that superseded it, which a replay cannot change without the signing key. - Signer and freshness are checked again even though ingest checked them. The two services are configured separately, and it is this verdict that lands on the record. Freshness is measured against when ingest received the ticket, so replaying a backlog of files does not reject every ticket in it. - Tickets may be stamped up to MAX_CLOCK_DRIFT in the future. A client's clock is not ingest's, and the allowance is a constant shared with ingest so the two cannot be configured to disagree about the same ticket. - The multiplier is stored as decimal(9,6), never a float: values are negotiated per venue and are not always binary-representable. IcebergDecimal bridges serde (the write path) and the Trino trait (the read path), which no existing type spans. - Ticket signers may be empty and default to empty, matching ingest. - The GatewayResolver is shared with the session path rather than rebuilt. Cloning shares the snapshot, so the one refresher keeps both current; a second resolver would load its own copy and never refresh it. --- Cargo.lock | 52 +- file_store_oracles/src/file_type.rs | 1 + .../src/mobile/data_transfer_multiplier.rs | 546 ++++++++++++- .../src/traits/file_sink_write.rs | 5 + helium_iceberg_oracles/Cargo.toml | 5 + .../src/data_transfer/mod.rs | 10 + .../multiplier_ticket_history.rs | 107 +++ .../multiplier_ticket_inventory.rs | 140 ++++ helium_iceberg_oracles/src/decimal.rs | 156 ++++ helium_iceberg_oracles/src/lib.rs | 3 + mobile_packet_verifier/Cargo.toml | 1 + .../pkg/settings-template.toml | 52 ++ mobile_packet_verifier/src/daemon.rs | 52 +- mobile_packet_verifier/src/gateway.rs | 6 + mobile_packet_verifier/src/iceberg.rs | 45 +- mobile_packet_verifier/src/lib.rs | 1 + .../src/multiplier/ingestor.rs | 218 +++++ .../src/multiplier/inventory.rs | 91 +++ mobile_packet_verifier/src/multiplier/mod.rs | 243 ++++++ .../src/multiplier/trino.rs | 108 +++ mobile_packet_verifier/src/settings.rs | 24 +- .../tests/integrations/common/mod.rs | 2 + .../tests/integrations/gateway_sharing.rs | 164 ++++ .../tests/integrations/main.rs | 2 + .../tests/integrations/multiplier_tickets.rs | 758 ++++++++++++++++++ 25 files changed, 2739 insertions(+), 53 deletions(-) create mode 100644 helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs create mode 100644 helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs create mode 100644 helium_iceberg_oracles/src/decimal.rs create mode 100644 mobile_packet_verifier/src/multiplier/ingestor.rs create mode 100644 mobile_packet_verifier/src/multiplier/inventory.rs create mode 100644 mobile_packet_verifier/src/multiplier/mod.rs create mode 100644 mobile_packet_verifier/src/multiplier/trino.rs create mode 100644 mobile_packet_verifier/tests/integrations/gateway_sharing.rs create mode 100644 mobile_packet_verifier/tests/integrations/multiplier_tickets.rs diff --git a/Cargo.lock b/Cargo.lock index a1482e714..e98bd7b0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,15 +1717,15 @@ name = "beacon" version = "0.1.0" source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "byteorder", "helium-proto", "prost", - "rand 0.7.3", - "rand_chacha 0.2.2", + "rand 0.8.5", + "rand_chacha 0.3.1", "rust_decimal", "serde", - "sha2 0.9.9", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -1767,7 +1767,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -3192,7 +3192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3344,7 +3344,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "beacon", "blake3", "bs58", @@ -3974,7 +3974,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d292d4e2852445cbb610b515543a56b10d4a6fad90cfd6d281fe870f628573e" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "bs58", "byteorder", "ed25519-compact", @@ -4036,7 +4036,10 @@ dependencies = [ "file-store-oracles", "helium-iceberg", "helium-proto", + "rust_decimal", "serde", + "serde_json", + "thiserror 1.0.69", "trino-rust-client", ] @@ -4051,7 +4054,7 @@ dependencies = [ "angry-purple-tiger", "async-trait", "backon", - "base64 0.21.7", + "base64 0.22.1", "bincode", "bytemuck", "chrono", @@ -4426,7 +4429,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration 0.6.1", "tokio", "tower-service", @@ -4725,7 +4728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -4911,7 +4914,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5151,7 +5154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.5", + "windows-targets 0.48.5", ] [[package]] @@ -5467,7 +5470,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "blake3", "bs58", "chrono", @@ -5524,7 +5527,7 @@ version = "0.1.0" dependencies = [ "angry-purple-tiger", "anyhow", - "base64 0.21.7", + "base64 0.22.1", "clap", "custom-tracing", "dialoguer", @@ -5570,6 +5573,7 @@ dependencies = [ "prost", "reqwest 0.12.28", "retainer", + "rust_decimal", "serde", "serde_json", "sha2 0.10.9", @@ -5594,7 +5598,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "chrono", "clap", "config", @@ -6587,7 +6591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -6609,7 +6613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.106", @@ -6733,7 +6737,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -6773,7 +6777,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -7225,7 +7229,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "bs58", "chrono", "clap", @@ -7476,7 +7480,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8092,7 +8096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11386,7 +11390,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/file_store_oracles/src/file_type.rs b/file_store_oracles/src/file_type.rs index c5b779074..c9dcefa6e 100644 --- a/file_store_oracles/src/file_type.rs +++ b/file_store_oracles/src/file_type.rs @@ -154,5 +154,6 @@ make_string_mapped_enum! { EntityRewardDestinationChangeReport => "entity_reward_destination_change_report", EnabledCarriersInfoReport => "enabled_carriers_report", DataTransferMultiplierTicketIngestReport => "data_transfer_multiplier_ticket_ingest_report", + VerifiedDataTransferMultiplierTicketReport => "verified_data_transfer_multiplier_ticket_report", } } diff --git a/file_store_oracles/src/mobile/data_transfer_multiplier.rs b/file_store_oracles/src/mobile/data_transfer_multiplier.rs index 5b29c507c..7f2c5416a 100644 --- a/file_store_oracles/src/mobile/data_transfer_multiplier.rs +++ b/file_store_oracles/src/mobile/data_transfer_multiplier.rs @@ -1,6 +1,60 @@ //! HIP-150 data transfer multipliers, and the tickets that grant them. +//! +//! A multiplier applies to the data credits derived from a hotspot's rewardable +//! bytes, not to the bytes themselves — a rewardable-byte count is a +//! measurement and does not change. It raises what a payer burns for that +//! hotspot's data and what its deployer earns, in the same proportion. +//! +//! A hotspot with no ticket is at [`DataTransferMultiplier::DEFAULT`] (1), and +//! there is no ticket meaning "no multiplier": returning a hotspot to 1 means +//! issuing a ticket that grants exactly 1. +//! +//! # Parsing and validating are separate steps +//! +//! The wire type is `helium.Decimal`, a string. [`parse_multiplier`] turns it +//! into a plain `Decimal` without judging it, and that is what a decoded +//! [`DataTransferMultiplierTicket`] carries — deliberately, because the file +//! poller discards records that fail to decode, and a ticket has to survive +//! decoding in order to be refused on the record. +//! +//! Validation is [`DataTransferMultiplier::new`], applied by the packet +//! verifier when it rules on a ticket. Everything downstream of that point — +//! the burn, the reward record — holds a [`DataTransferMultiplier`], so "is +//! this multiplier in range" is answered by the type rather than re-checked at +//! each call site. -use std::time::Duration; +use chrono::{DateTime, Utc}; +use file_store::traits::{MsgDecode, TimestampDecode, TimestampDecodeError, TimestampEncode}; +use helium_crypto::PublicKeyBinary; +use rust_decimal::{prelude::ToPrimitive, Decimal, RoundingStrategy}; + +use crate::prost_enum; + +pub mod proto { + pub use helium_proto::services::poc_mobile::{ + DataTransferMultiplierTicketIngestReportV1, DataTransferMultiplierTicketReqV1, + VerifiedDataTransferMultiplierTicketReportV1, VerifiedDataTransferMultiplierTicketStatus, + }; + pub use helium_proto::Decimal; +} + +pub use proto::VerifiedDataTransferMultiplierTicketStatus; + +/// Smallest multiplier the oracles accept. +/// +/// Operating policy, not wire format: HIP-150's 1-to-5 figures are starting +/// values, and the proto deliberately does not encode a range. Widening the +/// range later — a sub-1 multiplier, or a ceiling above 5 — is editing these +/// constants, with no schema change and no migration. +pub const MIN_MULTIPLIER: Decimal = Decimal::ONE; +/// Largest multiplier the oracles accept. See [`MIN_MULTIPLIER`]. +pub const MAX_MULTIPLIER: Decimal = Decimal::from_parts(5, 0, 0, false, 0); +/// Most fractional digits a multiplier may carry. +/// +/// Bounds how much precision reaches the stored record, whose column is +/// `decimal(9,6)`. Values are negotiated per venue, so this is generous rather +/// than tight. +pub const MAX_SCALE: u32 = 6; /// How far ahead of the receiving oracle's clock a ticket may be stamped. /// @@ -18,4 +72,492 @@ use std::time::Duration; /// stamped on it. If ingest tolerated drift the verifier did not, every ticket /// ingest accepted from a fast client would then be refused downstream — so the /// two must use one value, not two settings that can be configured apart. -pub const MAX_CLOCK_DRIFT: Duration = Duration::from_secs(60); +pub const MAX_CLOCK_DRIFT: std::time::Duration = std::time::Duration::from_secs(60); + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum MultiplierError { + #[error("unparseable multiplier: {0}")] + Unparseable(String), + #[error("multiplier {0} out of range, must be between {MIN_MULTIPLIER} and {MAX_MULTIPLIER}")] + OutOfRange(Decimal), + #[error("multiplier {0} has more than {MAX_SCALE} fractional digits")] + TooPrecise(Decimal), + /// Applying the multiplier overflowed. + /// + /// Unreachable for any real burn — a multiplier is bounded by + /// [`MAX_MULTIPLIER`], so this needs a data credit count within a few + /// multiples of `u64::MAX`. It is an error rather than a saturating value + /// because the only saturating value available is `u64::MAX`, and the + /// number this produces is what a payer is charged. + #[error("multiplier {multiplier} applied to {data_credits} data credits overflows")] + Overflow { + data_credits: u64, + multiplier: Decimal, + }, +} + +/// A validated HIP-150 data transfer multiplier. +/// +/// The inner value is private and every constructor validates, so holding one +/// *is* the proof it is in range — callers never re-check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct DataTransferMultiplier(Decimal); + +impl DataTransferMultiplier { + /// The multiplier every hotspot without a ticket is at. + /// + /// Where "no ticket means 1" is written down: a lookup that misses returns + /// this, and an absent multiplier on a *burned session* resolves to it. + /// + /// An absent multiplier on a *ticket* is a different thing — a grant that + /// grants nothing — and is refused rather than defaulted. See + /// `mobile_packet_verifier::multiplier::ingestor::ticket_status`. + pub const DEFAULT: Self = Self(Decimal::ONE); + + /// Validate and normalize a multiplier. + /// + /// Normalizing means `1.5`, `1.50` and `1.5e0` all become the same value, so + /// a difference in spelling cannot become a difference in multiplier. + pub fn new(value: Decimal) -> Result { + if value.scale() > MAX_SCALE { + return Err(MultiplierError::TooPrecise(value)); + } + if value < MIN_MULTIPLIER || value > MAX_MULTIPLIER { + return Err(MultiplierError::OutOfRange(value)); + } + Ok(Self(value.normalize())) + } + + /// Data credits after the multiplier, rounded **down**. + /// + /// HIP-150: "Applied to a data credit count it is rounded down, so a payer + /// never burns more than the multiplier earns." This is the only place the + /// multiplication happens. + /// + /// # Why this returns an error + /// + /// Overflow is unreachable for any real burn: a multiplier is bounded by + /// [`MAX_MULTIPLIER`], so reaching it needs a data credit count within a few + /// multiples of `u64::MAX`. It is still an error rather than a fallback + /// value, because the number returned here is what a payer is charged, and + /// the only fallback `u64` offers is `u64::MAX` — burn everything. A default + /// that fails towards charging the most possible is the wrong default for + /// money, however unreachable. Stopping forces someone to look at why an + /// impossible thing happened before any DC moves. + pub fn apply(&self, data_credits: u64) -> Result { + Decimal::from(data_credits) + .checked_mul(self.0) + .and_then(|scaled| { + scaled + .round_dp_with_strategy(0, RoundingStrategy::ToZero) + .to_u64() + }) + .ok_or(MultiplierError::Overflow { + data_credits, + multiplier: self.0, + }) + } + + /// True when this is the default, i.e. the hotspot is effectively unmultiplied. + pub fn is_default(&self) -> bool { + *self == Self::DEFAULT + } + + pub fn as_decimal(&self) -> Decimal { + self.0 + } +} + +impl std::fmt::Display for DataTransferMultiplier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for DataTransferMultiplier { + type Error = MultiplierError; + + fn try_from(value: proto::Decimal) -> Result { + // The Decimal proto permits an exponent ("2.5e8") as well as plain + // notation, and says services should normalize rather than reject. + // `rust_decimal`'s `FromStr` handles only the plain form, so fall back + // to the scientific parser rather than calling a legal value garbage. + let parsed = value + .value + .parse::() + .or_else(|_| Decimal::from_scientific(&value.value)) + .map_err(|_| MultiplierError::Unparseable(value.value))?; + Self::new(parsed) + } +} + +impl From for proto::Decimal { + fn from(value: DataTransferMultiplier) -> Self { + Self { + value: value.0.to_string(), + } + } +} + +// ── Ticket reports ────────────────────────────────────────────────────────── + +#[derive(thiserror::Error, Debug)] +pub enum TicketReportError { + #[error("invalid timestamp: {0}")] + Timestamp(#[from] TimestampDecodeError), + #[error("missing field: {0}")] + MissingField(&'static str), + #[error("invalid multiplier: {0}")] + Multiplier(#[from] MultiplierError), + #[error("unsupported status: {0}")] + Status(prost::UnknownEnumValue), +} + +/// A ticket as submitted, after validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataTransferMultiplierTicket { + pub hotspot_pubkey: PublicKeyBinary, + /// The multiplier as submitted: parsed, but **not** range-checked. + /// + /// `None` means absent or unparseable. Deliberately not a + /// [`DataTransferMultiplier`]: validating at decode would drop a bad ticket + /// on the floor, because the file poller silently discards records that fail + /// to decode. HIP-150 wants refusals on the record, so the ticket has to + /// survive decoding in order to be refused — see + /// `mobile_packet_verifier::multiplier::ingestor::ticket_status`. + pub multiplier: Option, + /// When the issuer signed it. Authoritative for which ticket is current, + /// and the key that makes a replayed ticket a no-op. + pub timestamp: DateTime, + pub message: String, + pub signer_pubkey: PublicKeyBinary, + pub signature: Vec, +} + +/// A ticket as ingest recorded it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataTransferMultiplierTicketReport { + /// When ingest received it. Bounds which files the ticket may apply to; it + /// is deliberately *not* what decides which ticket is current. + pub received_timestamp: DateTime, + pub report: DataTransferMultiplierTicket, +} + +/// A ticket after the verifier ruled on it. Rejections are written too, so a +/// refused grant is as auditable as an accepted one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedDataTransferMultiplierTicketReport { + pub verified_timestamp: DateTime, + pub report: DataTransferMultiplierTicketReport, + pub status: VerifiedDataTransferMultiplierTicketStatus, +} + +impl DataTransferMultiplierTicketReport { + pub fn hotspot_pubkey(&self) -> &PublicKeyBinary { + &self.report.hotspot_pubkey + } +} + +impl VerifiedDataTransferMultiplierTicketReport { + pub fn is_valid(&self) -> bool { + matches!( + self.status, + VerifiedDataTransferMultiplierTicketStatus::Valid + ) + } + + pub fn hotspot_pubkey(&self) -> &PublicKeyBinary { + self.report.hotspot_pubkey() + } +} + +impl MsgDecode for DataTransferMultiplierTicketReport { + type Msg = proto::DataTransferMultiplierTicketIngestReportV1; +} + +impl MsgDecode for VerifiedDataTransferMultiplierTicketReport { + type Msg = proto::VerifiedDataTransferMultiplierTicketReportV1; +} + +// === Conversion :: proto -> struct + +/// Parse without judging. `None` for absent or unparseable, so the caller can +/// record a refusal rather than the record vanishing at decode. +pub fn parse_multiplier(value: Option) -> Option { + let value = value?; + // The Decimal proto permits an exponent ("2.5e8") as well as plain notation, + // and says services should normalize rather than reject. + value + .value + .parse::() + .or_else(|_| Decimal::from_scientific(&value.value)) + .ok() +} + +impl TryFrom for DataTransferMultiplierTicket { + type Error = TicketReportError; + + fn try_from(value: proto::DataTransferMultiplierTicketReqV1) -> Result { + Ok(Self { + hotspot_pubkey: value.hotspot_pubkey.into(), + multiplier: parse_multiplier(value.multiplier), + timestamp: value.timestamp_ms.to_timestamp_millis()?, + message: value.message, + signer_pubkey: value.signer_pubkey.into(), + signature: value.signature, + }) + } +} + +impl TryFrom + for DataTransferMultiplierTicketReport +{ + type Error = TicketReportError; + + fn try_from( + value: proto::DataTransferMultiplierTicketIngestReportV1, + ) -> Result { + Ok(Self { + received_timestamp: value.received_timestamp_ms.to_timestamp_millis()?, + report: value + .report + .ok_or(TicketReportError::MissingField("ticket_report.report"))? + .try_into()?, + }) + } +} + +impl TryFrom + for VerifiedDataTransferMultiplierTicketReport +{ + type Error = TicketReportError; + + fn try_from( + value: proto::VerifiedDataTransferMultiplierTicketReportV1, + ) -> Result { + Ok(Self { + verified_timestamp: value.verified_timestamp_ms.to_timestamp_millis()?, + report: value + .report + .ok_or(TicketReportError::MissingField( + "verified_ticket_report.report", + ))? + .try_into()?, + status: prost_enum(value.status, TicketReportError::Status)?, + }) + } +} + +// === Conversion :: struct -> proto + +impl From for proto::DataTransferMultiplierTicketReqV1 { + fn from(value: DataTransferMultiplierTicket) -> Self { + Self { + hotspot_pubkey: value.hotspot_pubkey.into(), + multiplier: value.multiplier.map(|m| proto::Decimal { + value: m.to_string(), + }), + timestamp_ms: value.timestamp.encode_timestamp_millis(), + message: value.message, + signer_pubkey: value.signer_pubkey.into(), + signature: value.signature, + } + } +} + +impl From + for proto::DataTransferMultiplierTicketIngestReportV1 +{ + fn from(value: DataTransferMultiplierTicketReport) -> Self { + Self { + received_timestamp_ms: value.received_timestamp.encode_timestamp_millis(), + report: Some(value.report.into()), + } + } +} + +impl From + for proto::VerifiedDataTransferMultiplierTicketReportV1 +{ + fn from(value: VerifiedDataTransferMultiplierTicketReport) -> Self { + Self { + verified_timestamp_ms: value.verified_timestamp.encode_timestamp_millis(), + report: Some(value.report.into()), + status: value.status.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::dec; + + fn dec_proto(s: &str) -> proto::Decimal { + proto::Decimal { + value: s.to_string(), + } + } + + fn parse(s: &str) -> Result { + DataTransferMultiplier::try_from(dec_proto(s)) + } + + #[test] + fn accepts_and_normalizes_valid_multipliers() { + // The Decimal spec permits a leading sign and an exponent, so these are + // legal input and are canonicalized rather than rejected. + for input in [ + "1", "1.0", "1.00", "1.5", "1.50", "1.5e0", "+1.5", "5", "5.0", + ] { + assert!(parse(input).is_ok(), "{input} should parse"); + } + + // Every spelling of the same value must produce the same value, so a + // difference in formatting cannot become a difference in multiplier. + assert_eq!(parse("1.5").unwrap(), parse("1.50").unwrap()); + assert_eq!(parse("1.5").unwrap(), parse("1.5e0").unwrap()); + assert_eq!(parse("1.5").unwrap(), parse("+1.5").unwrap()); + assert_eq!(parse("1").unwrap(), DataTransferMultiplier::DEFAULT); + assert_eq!(parse("1.000").unwrap(), DataTransferMultiplier::DEFAULT); + } + + #[test] + fn rejects_invalid_multipliers() { + assert_eq!( + parse("").unwrap_err(), + MultiplierError::Unparseable("".into()) + ); + assert_eq!( + parse("abc").unwrap_err(), + MultiplierError::Unparseable("abc".into()) + ); + assert_eq!( + parse("NaN").unwrap_err(), + MultiplierError::Unparseable("NaN".into()) + ); + + // Below the floor: negative, zero, and just under 1. + assert!(matches!( + parse("-1").unwrap_err(), + MultiplierError::OutOfRange(_) + )); + assert!(matches!( + parse("0").unwrap_err(), + MultiplierError::OutOfRange(_) + )); + assert!(matches!( + parse("0.999999").unwrap_err(), + MultiplierError::OutOfRange(_) + )); + + // Above the ceiling, by the smallest representable step. + assert!(matches!( + parse("5.000001").unwrap_err(), + MultiplierError::OutOfRange(_) + )); + + // More precision than we will carry. + assert!(matches!( + parse("1.5000001").unwrap_err(), + MultiplierError::TooPrecise(_) + )); + + // A value far outside `Decimal`'s range fails at the parse step. + assert!(matches!( + parse("1e400").unwrap_err(), + MultiplierError::Unparseable(_) + )); + } + + #[test] + fn bounds_are_inclusive() { + assert!(parse("1").is_ok()); + assert!(parse("5").is_ok()); + } + + #[test] + fn default_is_the_identity() { + let default = DataTransferMultiplier::DEFAULT; + assert!(default.is_default()); + for dc in [0, 1, 2, 7, 100_000, u32::MAX as u64] { + assert_eq!( + default.apply(dc).unwrap(), + dc, + "default must not change {dc}" + ); + } + } + + #[test] + fn apply_rounds_down() { + let one_and_a_half = DataTransferMultiplier::new(dec!(1.5)).unwrap(); + + // Exact. + assert_eq!(one_and_a_half.apply(2).unwrap(), 3); + assert_eq!(one_and_a_half.apply(10).unwrap(), 15); + // 3 * 1.5 = 4.5 -> 4, not 5. A payer never burns more than the + // multiplier earns. + assert_eq!(one_and_a_half.apply(3).unwrap(), 4); + assert_eq!(one_and_a_half.apply(1).unwrap(), 1); + assert_eq!(one_and_a_half.apply(0).unwrap(), 0); + + let five = DataTransferMultiplier::new(dec!(5)).unwrap(); + assert_eq!(five.apply(2).unwrap(), 10); + } + + /// The reason `apply` returns a `Result`. Saturating would hand back + /// `u64::MAX` — charge the payer everything — for an arithmetic failure. + #[test] + fn overflow_is_an_error_not_a_maximum_charge() { + let five = DataTransferMultiplier::new(dec!(5)).unwrap(); + + let err = five.apply(u64::MAX).unwrap_err(); + assert!( + matches!(err, MultiplierError::Overflow { .. }), + "expected Overflow, got {err:?}" + ); + + // Specifically: it must not come back as the largest possible burn. + assert_ne!(five.apply(u64::MAX).ok(), Some(u64::MAX)); + } + + /// The largest count that still fits, so the error above is a real boundary + /// rather than the multiplier refusing everything large. + #[test] + fn applies_right_up_to_the_boundary() { + let five = DataTransferMultiplier::new(dec!(5)).unwrap(); + let fits = u64::MAX / 5; + + assert_eq!(five.apply(fits).unwrap(), fits * 5); + } + + #[test] + fn apply_never_exceeds_the_exact_value() { + let m = DataTransferMultiplier::new(dec!(1.333333)).unwrap(); + for dc in 0..500u64 { + let exact = Decimal::from(dc) * m.as_decimal(); + assert!( + Decimal::from(m.apply(dc).unwrap()) <= exact, + "apply({dc}) exceeded {exact}" + ); + } + } + + #[test] + fn ticket_round_trips_through_proto() { + let ticket = DataTransferMultiplierTicket { + hotspot_pubkey: PublicKeyBinary::from(vec![1, 2, 3]), + multiplier: Some(dec!(1.5)), + timestamp: DateTime::from_timestamp_millis(1_700_000_000_000).unwrap(), + message: "venue agreement 42".to_string(), + signer_pubkey: PublicKeyBinary::from(vec![4, 5, 6]), + signature: vec![7, 8, 9], + }; + + let proto: proto::DataTransferMultiplierTicketReqV1 = ticket.clone().into(); + let back = DataTransferMultiplierTicket::try_from(proto).unwrap(); + + assert_eq!(ticket, back); + } +} diff --git a/file_store_oracles/src/traits/file_sink_write.rs b/file_store_oracles/src/traits/file_sink_write.rs index 4db2ef651..2de51485a 100644 --- a/file_store_oracles/src/traits/file_sink_write.rs +++ b/file_store_oracles/src/traits/file_sink_write.rs @@ -342,3 +342,8 @@ impl_file_sink!( FileType::DataTransferMultiplierTicketIngestReport.to_str(), "data_transfer_multiplier_ticket_ingest_report" ); +impl_file_sink!( + poc_mobile::VerifiedDataTransferMultiplierTicketReportV1, + FileType::VerifiedDataTransferMultiplierTicketReport.to_str(), + "verified_data_transfer_multiplier_ticket_report" +); diff --git a/helium_iceberg_oracles/Cargo.toml b/helium_iceberg_oracles/Cargo.toml index 0eb600e6a..4dc1b1901 100644 --- a/helium_iceberg_oracles/Cargo.toml +++ b/helium_iceberg_oracles/Cargo.toml @@ -9,9 +9,14 @@ license.workspace = true [dependencies] anyhow = { workspace = true } chrono = { workspace = true } +rust_decimal = { workspace = true } serde = { workspace = true } +thiserror = { workspace = true } helium-proto = { workspace = true } trino-rust-client = { workspace = true } helium-iceberg = { path = "../helium_iceberg", default-features = false } file-store-oracles = { path = "../file_store_oracles" } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/helium_iceberg_oracles/src/data_transfer/mod.rs b/helium_iceberg_oracles/src/data_transfer/mod.rs index 404fae639..91d3860c2 100644 --- a/helium_iceberg_oracles/src/data_transfer/mod.rs +++ b/helium_iceberg_oracles/src/data_transfer/mod.rs @@ -8,13 +8,23 @@ //! - `invalid_sessions` — rejected sessions (same schema plus a `reason` column). //! - `burned_sessions` — sessions whose DC has been burned; the input to mobile //! data-transfer rewards. +//! - `multiplier_ticket_history` — every HIP-150 multiplier ticket seen, +//! accepted or refused. Append-only. +//! - `multiplier_ticket_inventory` — the multiplier currently in force per +//! hotspot, merged from the history on a schedule. Follows the pattern +//! `network-dbt` uses for `enabled_carriers_inventory` over +//! `enabled_carriers_history`, with our own job issuing the SQL. pub mod burned_session; pub mod invalid_session; +pub mod multiplier_ticket_history; +pub mod multiplier_ticket_inventory; pub mod session; pub use burned_session::IcebergBurnedDataTransferSession; pub use invalid_session::IcebergInvalidDataTransferSession; +pub use multiplier_ticket_history::IcebergMultiplierTicket; +pub use multiplier_ticket_inventory::IcebergMultiplierInventory; pub use session::IcebergDataTransferSession; pub const NAMESPACE: &str = "data_transfer"; diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs new file mode 100644 index 000000000..741f77547 --- /dev/null +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs @@ -0,0 +1,107 @@ +//! `data_transfer.multiplier_ticket_history` — every HIP-150 ticket ever seen. +//! +//! Append-only, one row per ticket received, accepted or not. HIP-150 requires +//! every multiplier in force to be externally auditable; recording refusals too +//! means the record answers "why is this hotspot not multiplied" as well as +//! "why is it". +//! +//! This is the log. For "what is current", see +//! [`super::multiplier_ticket_inventory`]. + +use chrono::{DateTime, FixedOffset}; +use helium_iceberg::{FieldDefinition, PartitionDefinition, TableDefinition}; +use serde::{Deserialize, Serialize}; +use trino_rust_client::Trino; + +use file_store_oracles::mobile::data_transfer_multiplier::VerifiedDataTransferMultiplierTicketReport; + +use crate::IcebergDecimal; + +pub use super::NAMESPACE; +pub const TABLE_NAME: &str = "multiplier_ticket_history"; + +/// Precision and scale of the stored multiplier. +/// +/// `decimal`, not `double`. Multipliers are negotiated per venue, so they are +/// not always binary-representable — 1.5 and 5 survive a float exactly, 1.3 does +/// not. A public record that has to explain floating-point artifacts is a worse +/// record, and the exact type costs nothing. +pub const MULTIPLIER_PRECISION: u32 = 9; +pub const MULTIPLIER_SCALE: u32 = 6; +// The Iceberg field builder takes u32; the Trino type takes const usize +// generics. Same numbers, spelled for each. +pub const MULTIPLIER_PRECISION_USIZE: usize = MULTIPLIER_PRECISION as usize; +pub const MULTIPLIER_SCALE_USIZE: usize = MULTIPLIER_SCALE as usize; + +/// The stored multiplier's type, spelled once. +pub type MultiplierDecimal = IcebergDecimal; + +#[derive(Debug, Clone, Trino, Serialize, Deserialize, PartialEq)] +pub struct IcebergMultiplierTicket { + pub hotspot_pubkey: String, + /// When the issuer signed the ticket — what decides which ticket is current. + pub signed_timestamp: DateTime, + /// When ingest received it. + pub received_timestamp: DateTime, + /// When this oracle ruled on it. + pub verified_timestamp: DateTime, + /// `None` when the ticket carried no usable multiplier — absent, + /// unparseable, or too large for the column. `status` records that it was + /// refused, but not which of the three it was; all three are + /// `invalid_multiplier`. A value that is merely out of range *is* stored, + /// so the record shows what was asked for. + pub multiplier: Option, + pub signer: String, + pub message: String, + /// The verdict, as the proto enum's string name. Rejected tickets are kept. + pub status: String, +} + +pub fn table_definition() -> helium_iceberg::Result { + TableDefinition::builder(NAMESPACE, TABLE_NAME) + .with_fields([ + FieldDefinition::required_string("hotspot_pubkey"), + FieldDefinition::required_timestamptz("signed_timestamp"), + FieldDefinition::required_timestamptz("received_timestamp"), + FieldDefinition::required_timestamptz("verified_timestamp"), + FieldDefinition::required_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), + FieldDefinition::required_string("signer"), + FieldDefinition::required_string("message"), + FieldDefinition::required_string("status"), + ]) + .with_partition(PartitionDefinition::day( + "received_timestamp", + "received_timestamp_day", + )) + .build() +} + +pub async fn get_all( + trino: &trino_rust_client::Client, +) -> anyhow::Result> { + let all = trino + .get_all(format!("SELECT * from {NAMESPACE}.{TABLE_NAME}")) + .await? + .into_vec(); + Ok(all) +} + +impl From<&VerifiedDataTransferMultiplierTicketReport> for IcebergMultiplierTicket { + fn from(verified: &VerifiedDataTransferMultiplierTicketReport) -> Self { + let ticket = &verified.report.report; + Self { + hotspot_pubkey: ticket.hotspot_pubkey.to_string(), + signed_timestamp: ticket.timestamp.into(), + received_timestamp: verified.report.received_timestamp.into(), + verified_timestamp: verified.verified_timestamp.into(), + // A refused ticket may carry a value too large for the column, or + // none at all; the row still records the refusal. + multiplier: ticket + .multiplier + .and_then(|m| MultiplierDecimal::try_from(m).ok()), + signer: ticket.signer_pubkey.to_string(), + message: ticket.message.clone(), + status: verified.status.as_str_name().to_string(), + } + } +} diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs new file mode 100644 index 000000000..c7b438fcf --- /dev/null +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs @@ -0,0 +1,140 @@ +//! `data_transfer.multiplier_ticket_inventory` — the multiplier currently in +//! force per hotspot. +//! +//! One row per ticketed hotspot, refreshed from +//! [`super::multiplier_ticket_history`] by a periodic `MERGE`. Follows the +//! pattern `network-dbt` uses for `enabled_carriers_inventory` over +//! `enabled_carriers_history` — latest-per-key picked with a `row_number()` +//! window, merged on the key — but it is our own job issuing the SQL, not a dbt +//! model. +//! +//! **Unlike the history table it holds only valid tickets.** History records +//! every ticket including refusals, so it answers "what happened"; this answers +//! "what is in force", and a refused ticket is not in force. Note that a refusal +//! does not revoke an earlier grant: a hotspot whose newest ticket was rejected +//! keeps the last valid one, which is what excluding refusals from the source +//! achieves. +//! +//! **Not written by the Rust iceberg writer.** It is maintained in place by +//! `MERGE`, which the append-only writer cannot express — hence the table being +//! unpartitioned, and hence the DDL existing only so the merge has a target. +//! +//! Readers: the burn path, the ticket CLI, and anyone asking what a hotspot's +//! multiplier is. The burn deliberately takes whatever is current here rather +//! than reconstructing what was in force when the data moved — see +//! `mobile_packet_verifier::multiplier::trino`. + +use chrono::{DateTime, FixedOffset}; +use helium_iceberg::{FieldDefinition, TableDefinition}; +use serde::{Deserialize, Serialize}; +use trino_rust_client::Trino; + +use super::multiplier_ticket_history::{MultiplierDecimal, MULTIPLIER_PRECISION, MULTIPLIER_SCALE}; +pub use super::NAMESPACE; +pub const TABLE_NAME: &str = "multiplier_ticket_inventory"; + +#[derive(Debug, Clone, Trino, Serialize, Deserialize, PartialEq)] +pub struct IcebergMultiplierInventory { + pub hotspot_pubkey: String, + pub multiplier: MultiplierDecimal, + /// When the issuer signed the ticket that granted this multiplier — the + /// value that decided it wins. + pub signed_timestamp: DateTime, + /// When ingest received that ticket. + pub received_timestamp: DateTime, + /// When the packet verifier accepted it. + pub verified_timestamp: DateTime, + pub signer: String, + pub message: String, +} + +/// Deliberately unpartitioned: rows are updated in place by `MERGE`, so there is +/// no append dimension to partition on. +pub fn table_definition() -> helium_iceberg::Result { + TableDefinition::builder(NAMESPACE, TABLE_NAME) + .with_fields([ + FieldDefinition::required_string("hotspot_pubkey"), + FieldDefinition::required_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), + FieldDefinition::required_timestamptz("signed_timestamp"), + FieldDefinition::required_timestamptz("received_timestamp"), + FieldDefinition::required_timestamptz("verified_timestamp"), + FieldDefinition::required_string("signer"), + FieldDefinition::required_string("message"), + ]) + .build() +} + +/// The `MERGE` that brings the inventory up to date with the history. +/// +/// `valid_status` is passed in rather than written here so it stays the proto +/// enum's own `as_str_name()`, and cannot drift from what the writer stored. +/// +/// Rebuilds the source from the whole history each run rather than tracking a +/// watermark. Tickets are rare — HIP-150 expects a small number of granted +/// hotspots — so the scan is cheap and a full recompute cannot drift from the +/// history the way an incremental one can. Revisit if ticket volume ever makes +/// that untrue. +pub fn merge_statement(history_table: &str, inventory_table: &str, valid_status: &str) -> String { + format!( + r#" + MERGE INTO {inventory_table} AS t + USING ( + SELECT + hotspot_pubkey, + multiplier, + signed_timestamp, + received_timestamp, + verified_timestamp, + signer, + message + FROM ( + SELECT + *, + row_number() OVER ( + PARTITION BY hotspot_pubkey + ORDER BY signed_timestamp DESC, received_timestamp DESC + ) AS rn + FROM {history_table} + WHERE status = '{valid_status}' + AND multiplier IS NOT NULL + ) + WHERE rn = 1 + ) AS s + ON t.hotspot_pubkey = s.hotspot_pubkey + WHEN MATCHED THEN UPDATE SET + multiplier = s.multiplier, + signed_timestamp = s.signed_timestamp, + received_timestamp = s.received_timestamp, + verified_timestamp = s.verified_timestamp, + signer = s.signer, + message = s.message + WHEN NOT MATCHED THEN INSERT ( + hotspot_pubkey, + multiplier, + signed_timestamp, + received_timestamp, + verified_timestamp, + signer, + message + ) VALUES ( + s.hotspot_pubkey, + s.multiplier, + s.signed_timestamp, + s.received_timestamp, + s.verified_timestamp, + s.signer, + s.message + ) + "# + ) +} + +pub async fn get_all( + trino: &trino_rust_client::Client, +) -> anyhow::Result> { + let all = trino + .get_all(format!("SELECT * from {NAMESPACE}.{TABLE_NAME}")) + .await? + .into_vec(); + Ok(all) +} diff --git a/helium_iceberg_oracles/src/decimal.rs b/helium_iceberg_oracles/src/decimal.rs new file mode 100644 index 000000000..73c7d8c2c --- /dev/null +++ b/helium_iceberg_oracles/src/decimal.rs @@ -0,0 +1,156 @@ +//! Exact decimals for Iceberg `decimal(P, S)` columns. +//! +//! Bridges two APIs that don't quite meet. The write path serializes a row with +//! serde and hands the JSON to `arrow-json`, whose `DecimalArrayDecoder` +//! accepts a decimal *string*. The read path uses `trino-rust-client`'s `Trino` +//! trait, whose `Decimal` implements `DeserializeSeed` but neither +//! `Serialize` nor `Deserialize` — so a row struct containing one cannot derive +//! the serde impls the writer needs. +//! +//! [`IcebergDecimal`] implements all three, so a row type can carry an exact +//! decimal and still `#[derive(Trino, Serialize, Deserialize)]` like every other +//! table in this crate. +//! +//! Why not `double`: values that are not binary-representable — 1.3, 2.7 — pick +//! up artifacts a float cannot shed, and these columns are a public record. + +use std::str::FromStr; + +use serde::{de::DeserializeSeed, Deserialize, Deserializer, Serialize, Serializer}; +use trino_rust_client::{ + types::{Context, Decimal as TrinoDecimal}, + Trino, +}; + +/// An exact decimal stored in an Iceberg `decimal(P, S)` column. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct IcebergDecimal(TrinoDecimal); + +#[derive(thiserror::Error, Debug)] +#[error("invalid decimal: {0}")] +pub struct ParseDecimalError(String); + +impl IcebergDecimal { + pub fn as_string(&self) -> String { + self.0.clone().into_bigdecimal().to_string() + } +} + +impl FromStr for IcebergDecimal { + type Err = ParseDecimalError; + + fn from_str(s: &str) -> Result { + TrinoDecimal::from_str(s) + .map(Self) + .map_err(|_| ParseDecimalError(s.to_string())) + } +} + +impl TryFrom for IcebergDecimal { + type Error = ParseDecimalError; + + fn try_from(value: rust_decimal::Decimal) -> Result { + Self::from_str(&value.to_string()) + } +} + +impl std::fmt::Display for IcebergDecimal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.as_string()) + } +} + +/// Serialized as a string, which is what `arrow-json` decodes into a +/// `Decimal128` column. A JSON number would work for small values and lose +/// precision for large ones. +impl Serialize for IcebergDecimal { + fn serialize(&self, serializer: T) -> Result { + serializer.serialize_str(&self.as_string()) + } +} + +impl<'de, const P: usize, const S: usize> Deserialize<'de> for IcebergDecimal { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +/// Wraps the inner seed so deserialization yields an [`IcebergDecimal`] rather +/// than the type it delegates to. +pub struct IcebergDecimalSeed<'a, 'de, const P: usize, const S: usize>( + as Trino>::Seed<'a, 'de>, +); + +impl<'de, 'a, const P: usize, const S: usize> DeserializeSeed<'de> + for IcebergDecimalSeed<'a, 'de, P, S> +{ + type Value = IcebergDecimal; + + fn deserialize>(self, deserializer: D) -> Result { + self.0.deserialize(deserializer).map(IcebergDecimal) + } +} + +/// Delegates to the wrapped type, so Trino still sees a `decimal(P, S)`. +impl Trino for IcebergDecimal { + type ValueType<'a> = as Trino>::ValueType<'a>; + type Seed<'a, 'de> = IcebergDecimalSeed<'a, 'de, P, S>; + + fn value(&self) -> Self::ValueType<'_> { + self.0.value() + } + + fn ty() -> trino_rust_client::types::TrinoTy { + TrinoDecimal::::ty() + } + + fn seed<'a, 'de>(ctx: &'a Context) -> Self::Seed<'a, 'de> { + IcebergDecimalSeed(TrinoDecimal::::seed(ctx)) + } + + fn empty() -> Self { + Self(TrinoDecimal::empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::dec; + + type Multiplier = IcebergDecimal<9, 6>; + + #[test] + fn round_trips_through_serde_as_a_string() { + for value in [dec!(1), dec!(1.5), dec!(1.3), dec!(5), dec!(2.718281)] { + let decimal = Multiplier::try_from(value).expect("in range"); + + let json = serde_json::to_string(&decimal).expect("serialize"); + assert!( + json.starts_with('"'), + "must serialize as a string for arrow-json, got {json}" + ); + + let back: Multiplier = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decimal, back); + } + } + + /// The case that a `double` column would get wrong: 1.3 has no exact binary + /// representation, so a float round trip yields 1.3000000000000000444. + #[test] + fn keeps_values_a_float_would_mangle() { + let decimal = Multiplier::try_from(dec!(1.3)).expect("in range"); + assert_eq!(decimal.as_string(), "1.3"); + + let json = serde_json::to_string(&decimal).expect("serialize"); + assert_eq!(json, "\"1.3\""); + } + + #[test] + fn rejects_nonsense() { + assert!(Multiplier::from_str("").is_err()); + assert!(Multiplier::from_str("abc").is_err()); + } +} diff --git a/helium_iceberg_oracles/src/lib.rs b/helium_iceberg_oracles/src/lib.rs index 4c164c337..ede309d55 100644 --- a/helium_iceberg_oracles/src/lib.rs +++ b/helium_iceberg_oracles/src/lib.rs @@ -9,3 +9,6 @@ //! `mobile-verifier`. Single-owner tables stay in their owning crate. pub mod data_transfer; +pub mod decimal; + +pub use decimal::IcebergDecimal; diff --git a/mobile_packet_verifier/Cargo.toml b/mobile_packet_verifier/Cargo.toml index 00ddb1aaf..dd15c5b00 100644 --- a/mobile_packet_verifier/Cargo.toml +++ b/mobile_packet_verifier/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } sha2 = { workspace = true } solana = { path = "../solana" } sqlx = { workspace = true } +rust_decimal = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tonic = { workspace = true } diff --git a/mobile_packet_verifier/pkg/settings-template.toml b/mobile_packet_verifier/pkg/settings-template.toml index e1a50fb05..1880ffcdc 100644 --- a/mobile_packet_verifier/pkg/settings-template.toml +++ b/mobile_packet_verifier/pkg/settings-template.toml @@ -151,3 +151,55 @@ bucket = "helium-mainnet-mobile-verified" # Region for bucket. Defaults to below # region = "us-west-2" + +# HIP-150 data transfer multiplier tickets +# +# Verified tickets are written to s3 and to the append-only +# data_transfer.multiplier_ticket_history table. No ticket data is stored in +# Postgres. +# A periodic job merges that history into +# data_transfer.multiplier_ticket_inventory, which is what is currently in +# force. +# +[multiplier] + +# Comma-separated b58 public keys authorized to issue tickets. +# +# Checked here as well as at ingest: the two services are deployed and +# configured separately, and it is this verdict that lands on the record. +# +# May be empty, and is by default — no ticket can be issued until a key is +# provisioned. Empty rejects every ticket and warns at startup. +# +# authorized_keys = "key1,key2" + +# How old a ticket's signed timestamp may be, measured against when ingest +# received it, before it is refused. A second check of what ingest already +# checked, configured separately so a mistake in ingest's window does not +# silently widen the replay window everywhere. +# Default: 10 minutes +# +# ticket_max_age = "10 minutes" + +# How often to merge the history into the inventory table. Nothing in the burn +# depends on the inventory, so a slow cadence is fine. +# Default: 15 minutes +# +# inventory_refresh_interval = "15 minutes" + +# How far back do we read ticket files? +# Default: UNIX_EPOCH +# +# start_after = "2024-12-15 01:00:00Z" + +# Ingest bucket details for data transfer multiplier tickets +# +[multiplier.input_bucket] + +# Name of bucket to access ingest data. Required +# +bucket = "helium-mainnet-mobile-ingest" + +# Region for bucket. Defaults to below +# +region = "us-west-2" diff --git a/mobile_packet_verifier/src/daemon.rs b/mobile_packet_verifier/src/daemon.rs index db526f5cd..c649b54da 100644 --- a/mobile_packet_verifier/src/daemon.rs +++ b/mobile_packet_verifier/src/daemon.rs @@ -5,7 +5,7 @@ use crate::{ event_ids::EventIdPurger, gateway::GatewayResolver, iceberg::{self, DataTransferWriter, InvalidDataTransferWriter}, - pending_burns, + multiplier, pending_burns, routing::RoutingKeys, settings::Settings, }; @@ -312,16 +312,27 @@ impl Cmd { ) .await?; - let (session_writer, invalid_session_writer, burned_session_writer) = - if let Some(ref iceberg_settings) = settings.iceberg_settings { - tracing::info!("iceberg settings provided, connecting..."); - let (session, invalid_session, burned) = - iceberg::get_writers(iceberg_settings).await?; - (Some(session), Some(invalid_session), Some(burned)) - } else { - tracing::info!("no iceberg settings provided"); - (None, None, None) - }; + let writers = if let Some(ref iceberg_settings) = settings.iceberg_settings { + tracing::info!("iceberg settings provided, connecting..."); + Some(iceberg::get_writers(iceberg_settings).await?) + } else { + tracing::info!("no iceberg settings provided"); + None + }; + let ( + session_writer, + invalid_session_writer, + burned_session_writer, + multiplier_ticket_writer, + ) = match writers { + Some(w) => ( + Some(w.session), + Some(w.invalid_session), + Some(w.burned_session), + Some(w.multiplier_ticket), + ), + None => (None, None, None, None), + }; let burner = Burner::new( valid_sessions, @@ -345,6 +356,10 @@ impl Cmd { ) .await; let gw_refresher = gw_resolver.refresher(); + // Shared, not a second resolver: clones point at the same snapshot, so + // the refresher above keeps the ticket path current too. Building + // another would load its own copy of the inventory and never refresh it. + let ticket_resolver = gw_resolver.clone(); let ingest_reports = IngestReports::new( pool.clone(), @@ -366,7 +381,19 @@ impl Cmd { ); let event_id_purger = EventIdPurger::from_settings(pool.clone(), settings); - let banning = banning::create_managed_task(pool, &settings.banning).await?; + let banning = banning::create_managed_task(pool.clone(), &settings.banning).await?; + + let multipliers = multiplier::create_managed_task( + pool, + file_upload, + &settings.multiplier, + settings.ticket_signers()?, + ticket_resolver, + &settings.cache, + multiplier_ticket_writer, + trino_client::Client::from_settings(&settings.trino)?, + ) + .await?; TaskManager::builder() .add_task(file_upload_server) @@ -374,6 +401,7 @@ impl Cmd { .add_task(verified_sessions_server) .add_task(reports_server) .add_task(banning) + .add_task(multipliers) .add_task(task_manager::periodic(event_id_purger)) .add_task(task_manager::periodic(gw_refresher)) .add_task(daemon) diff --git a/mobile_packet_verifier/src/gateway.rs b/mobile_packet_verifier/src/gateway.rs index 2920fc89e..43f22c58a 100644 --- a/mobile_packet_verifier/src/gateway.rs +++ b/mobile_packet_verifier/src/gateway.rs @@ -36,6 +36,12 @@ const GATEWAY_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(60); /// that misses the snapshot — an unknown gateway, or one onboarded since the /// last refresh — falls back to a per-pubkey Trino query, cached to avoid /// re-querying. +/// Cloning shares the snapshot rather than copying it: `known_gateways` and +/// `fallback_cache` are behind `Arc`, so every clone sees the same set and the +/// single [`GatewaySnapshotRefresher`] keeps all of them current. Build one per +/// process and clone it for each consumer — a second `new()` would load its own +/// copy of the whole inventory and then never refresh it. +#[derive(Clone)] pub struct GatewayResolver { trino_client: trino_client::Client, inventory_table: String, diff --git a/mobile_packet_verifier/src/iceberg.rs b/mobile_packet_verifier/src/iceberg.rs index 21928e83b..1db4e9815 100644 --- a/mobile_packet_verifier/src/iceberg.rs +++ b/mobile_packet_verifier/src/iceberg.rs @@ -5,8 +5,9 @@ use serde::Serialize; // `data_transfer` schemas live in `helium-iceberg-oracles`; re-exported here so // existing `iceberg::*` paths keep resolving. pub use helium_iceberg_oracles::data_transfer::{ - burned_session, invalid_session, session, IcebergBurnedDataTransferSession, - IcebergDataTransferSession, IcebergInvalidDataTransferSession, NAMESPACE, REASON_COLUMN, + burned_session, invalid_session, multiplier_ticket_history, multiplier_ticket_inventory, + session, IcebergBurnedDataTransferSession, IcebergDataTransferSession, + IcebergInvalidDataTransferSession, IcebergMultiplierTicket, NAMESPACE, REASON_COLUMN, }; // Valid sessions go to `data_transfer.sessions`; rejected sessions go to the @@ -15,14 +16,18 @@ pub use helium_iceberg_oracles::data_transfer::{ pub type DataTransferWriter = BoxedDataWriter; pub type InvalidDataTransferWriter = BoxedDataWriter; pub type BurnedDataTransferWriter = BoxedDataWriter; +/// HIP-150: every multiplier ticket seen, accepted or refused. +pub type MultiplierTicketWriter = BoxedDataWriter; -pub async fn get_writers( - settings: &helium_iceberg::Settings, -) -> anyhow::Result<( - DataTransferWriter, - InvalidDataTransferWriter, - BurnedDataTransferWriter, -)> { +/// Every Iceberg writer this service uses. +pub struct Writers { + pub session: DataTransferWriter, + pub invalid_session: InvalidDataTransferWriter, + pub burned_session: BurnedDataTransferWriter, + pub multiplier_ticket: MultiplierTicketWriter, +} + +pub async fn get_writers(settings: &helium_iceberg::Settings) -> anyhow::Result { let catalog = settings.connect().await.context("connecting to catalog")?; catalog.create_namespace_if_not_exists(NAMESPACE).await?; @@ -36,12 +41,24 @@ pub async fn get_writers( let burned_session_writer = catalog .create_table_if_not_exists(burned_session::table_definition()?) .await?; + let multiplier_ticket_writer = catalog + .create_table_if_not_exists(multiplier_ticket_history::table_definition()?) + .await?; - Ok(( - session_writer.boxed(), - invalid_session_writer.boxed(), - burned_session_writer.boxed(), - )) + // The inventory is maintained in place by a Trino MERGE, not by a writer — + // created here only so the merge has a target. The returned writer is + // deliberately dropped. + let _ = catalog + .create_table_if_not_exists::( + multiplier_ticket_inventory::table_definition()?, + ) + .await?; + Ok(Writers { + session: session_writer.boxed(), + invalid_session: invalid_session_writer.boxed(), + burned_session: burned_session_writer.boxed(), + multiplier_ticket: multiplier_ticket_writer.boxed(), + }) } /// Optional idempotent append — no-op when `writer` is `None` (iceberg diff --git a/mobile_packet_verifier/src/lib.rs b/mobile_packet_verifier/src/lib.rs index 0efd1cfd2..e6e3a244e 100644 --- a/mobile_packet_verifier/src/lib.rs +++ b/mobile_packet_verifier/src/lib.rs @@ -7,6 +7,7 @@ pub mod daemon; pub mod event_ids; pub mod gateway; pub mod iceberg; +pub mod multiplier; pub mod pending_burns; pub mod pending_txns; pub mod routing; diff --git a/mobile_packet_verifier/src/multiplier/ingestor.rs b/mobile_packet_verifier/src/multiplier/ingestor.rs new file mode 100644 index 000000000..c8636d395 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/ingestor.rs @@ -0,0 +1,218 @@ +//! Reads ticket files from s3, rules on each ticket, records the verdict. +//! +//! Every ticket produces a verified report — accepted or rejected — so the +//! public record shows refusals as well as grants. Both also land in the +//! append-only history table, tagged with the verdict. +//! +//! Nothing here holds mutable state: this module only appends. What is +//! *currently* in force is derived from those rows separately, by +//! [`super::inventory`]. + +use std::{ops::ControlFlow, time::Duration}; + +use chrono::Utc; +use file_store::file_info_poller::FileInfoStream; +use file_store_oracles::mobile::data_transfer_multiplier::{ + proto::VerifiedDataTransferMultiplierTicketReportV1, DataTransferMultiplier, + DataTransferMultiplierTicketReport, VerifiedDataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketStatus as Status, MAX_CLOCK_DRIFT, +}; +use futures::StreamExt; +use sqlx::PgPool; +use task_manager::ChannelConsumer; +use tokio::sync::mpsc::Receiver; + +use crate::{ + gateway::GatewayResolver, + iceberg::{IcebergMultiplierTicket, MultiplierTicketWriter}, +}; + +use super::{TicketSigners, VerifiedTicketSink}; + +pub struct TicketIngestor { + /// Only for the file poller's own "which files have I processed" bookkeeping + /// — no ticket data is stored in Postgres. + pool: PgPool, + report_rx: Receiver>, + verified_sink: VerifiedTicketSink, + signers: TicketSigners, + resolver: GatewayResolver, + ticket_max_age: Duration, + history_writer: Option, +} + +impl ChannelConsumer for TicketIngestor { + type Item = FileInfoStream; + type Error = anyhow::Error; + + async fn recv(&mut self) -> Option { + self.report_rx.recv().await + } + + async fn handle(&mut self, file_info_stream: Self::Item) -> anyhow::Result<()> { + self.process_file(file_info_stream).await + } + + async fn on_receiver_closed(&mut self) -> anyhow::Result> { + Err(anyhow::anyhow!( + "data transfer multiplier ticket FileInfoPoller sender was dropped unexpectedly" + )) + } +} + +impl TicketIngestor { + pub fn new( + pool: PgPool, + report_rx: Receiver>, + verified_sink: VerifiedTicketSink, + signers: TicketSigners, + resolver: GatewayResolver, + ticket_max_age: Duration, + history_writer: Option, + ) -> Self { + Self { + pool, + report_rx, + verified_sink, + signers, + resolver, + ticket_max_age, + history_writer, + } + } + + async fn process_file( + &self, + file_info_stream: FileInfoStream, + ) -> anyhow::Result<()> { + let file = file_info_stream.file_info.key.clone(); + tracing::info!(%file, "processing data transfer multiplier tickets"); + + // The transaction records only that this file was processed; the + // tickets themselves go to s3 and Iceberg. + let mut txn = self.pool.begin().await?; + let mut stream = file_info_stream.into_stream(&mut txn).await?; + + let mut history = Vec::new(); + + while let Some(report) = stream.next().await { + let verified = self.verify(report).await?; + + // Every ticket gets a history row, refusals included — a value the + // column cannot hold lands as NULL, with the status saying why. + if self.history_writer.is_some() { + history.push(IcebergMultiplierTicket::from(&verified)); + } + + let status = verified.status.as_str_name(); + let proto = VerifiedDataTransferMultiplierTicketReportV1::from(verified); + self.verified_sink + .write(proto, &[("status", status)]) + .await?; + } + + // Keyed on the file, so reprocessing one cannot duplicate its rows. + if let Some(writer) = self.history_writer.as_ref() { + writer.write_idempotent(&file, history).await?; + } + + txn.commit().await?; + self.verified_sink.commit().await?; + + Ok(()) + } + + /// Rule on one ticket. + async fn verify( + &self, + report: DataTransferMultiplierTicketReport, + ) -> anyhow::Result { + let status = + ticket_status(&report, &self.signers, self.ticket_max_age, &self.resolver).await; + + let verified = VerifiedDataTransferMultiplierTicketReport { + verified_timestamp: Utc::now(), + report, + status, + }; + + if !verified.is_valid() { + tracing::warn!( + hotspot_pubkey = %verified.hotspot_pubkey(), + status = status.as_str_name(), + "rejecting data transfer multiplier ticket" + ); + } + + Ok(verified) + } +} + +/// The verdict on one ticket. +/// +/// A free function rather than a method: this is the rule that decides whether a +/// hotspot's rewards get multiplied, and it should be testable without a +/// channel, a file poller or a sink. +/// +/// HIP-150 fixes the accepted range at 1 to 5 inclusive, "enforced by the +/// oracles" — and this is that enforcement. It is deliberately here rather than +/// at ingest: ingest keeps the range out of the wire format so policy can move +/// without a schema change, and refusing at the gRPC boundary would leave no +/// record. Refusing here writes a verified report and a history row, so a +/// rejected grant is as auditable as an accepted one. +pub async fn ticket_status( + report: &DataTransferMultiplierTicketReport, + signers: &TicketSigners, + ticket_max_age: Duration, + resolver: &GatewayResolver, +) -> Status { + let ticket = &report.report; + + if !signers.contains(&ticket.signer_pubkey) { + return Status::InvalidSigner; + } + + // Absent, unparseable, out of range, or carrying more precision than we + // store. All four mean the same thing to a submitter: not a multiplier we + // will grant. + match ticket.multiplier { + Some(multiplier) if DataTransferMultiplier::new(multiplier).is_ok() => {} + _ => return Status::InvalidMultiplier, + } + + // A signature never expires, so a ticket is only as trustworthy as it is + // fresh. Measured against when *ingest* received it, not against now: this + // service may be replaying a backlog of files hours old, and every ticket in + // them would otherwise look stale. + // + // A ticket can be stamped slightly ahead of the timestamp ingest gave it, + // because the client's clock is not ingest's. Ingest accepts that drift, so + // this must too — otherwise every ticket ingest let through from a fast + // client would be refused here, and the two would disagree about the same + // ticket. Hence the shared constant rather than two settings. + let age = report.received_timestamp - ticket.timestamp; + let age = if age < chrono::TimeDelta::zero() { + match (-age).to_std() { + Ok(drift) if drift <= MAX_CLOCK_DRIFT => std::time::Duration::ZERO, + _ => return Status::InvalidTimestamp, + } + } else { + match age.to_std() { + Ok(age) => age, + Err(_) => return Status::InvalidTimestamp, + } + }; + + if age > ticket_max_age { + return Status::InvalidTimestamp; + } + + if !resolver + .is_gateway_known(&ticket.hotspot_pubkey, &report.received_timestamp) + .await + { + return Status::InvalidHotspotKey; + } + + Status::Valid +} diff --git a/mobile_packet_verifier/src/multiplier/inventory.rs b/mobile_packet_verifier/src/multiplier/inventory.rs new file mode 100644 index 000000000..80657161c --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/inventory.rs @@ -0,0 +1,91 @@ +//! Keeps `data_transfer.multiplier_ticket_inventory` up to date. +//! +//! The history table is the log; the inventory is what is currently in force. +//! This periodically merges the second out of the first, following the pattern +//! `network-dbt` uses for its `*_inventory` marts — latest-per-key by a +//! `row_number()` window, merged on the key — with the SQL issued by us rather +//! than by dbt. +//! +//! It runs as a `MERGE` through Trino rather than through the Rust iceberg +//! writer, because the writer is append-only and cannot update a row in place. +//! +//! **The burn reads this table**, so a refresh that stops running freezes the +//! multipliers burns apply — at the last merged value, not at 1. Tickets keep +//! landing in the history either way, so a resumed refresh catches up without +//! loss; the exposure is stale multipliers in the meantime, not lost grants. + +use std::time::Duration; + +use file_store_oracles::mobile::data_transfer_multiplier::VerifiedDataTransferMultiplierTicketStatus; +use helium_iceberg_oracles::data_transfer::{ + multiplier_ticket_history, multiplier_ticket_inventory, +}; +use task_manager::Periodic; + +pub struct InventoryRefresher { + trino: trino_client::Client, + interval: Duration, + history_table: String, + inventory_table: String, +} + +impl InventoryRefresher { + pub fn new(trino: trino_client::Client, interval: Duration) -> Self { + Self::new_with_tables( + trino, + interval, + format!( + "{}.{}", + multiplier_ticket_history::NAMESPACE, + multiplier_ticket_history::TABLE_NAME + ), + format!( + "{}.{}", + multiplier_ticket_inventory::NAMESPACE, + multiplier_ticket_inventory::TABLE_NAME + ), + ) + } + + /// Like [`new`](Self::new), with explicit table names so tests can point at + /// a per-test catalog. + pub fn new_with_tables( + trino: trino_client::Client, + interval: Duration, + history_table: String, + inventory_table: String, + ) -> Self { + Self { + trino, + interval, + history_table, + inventory_table, + } + } + + /// Run one refresh. Public so a test can drive it without a scheduler. + pub async fn refresh(&self) -> anyhow::Result<()> { + let sql = multiplier_ticket_inventory::merge_statement( + &self.history_table, + &self.inventory_table, + VerifiedDataTransferMultiplierTicketStatus::Valid.as_str_name(), + ); + + self.trino.execute_raw(sql).await?; + Ok(()) + } +} + +impl Periodic for InventoryRefresher { + type Error = anyhow::Error; + + fn interval(&self) -> Duration { + self.interval + } + + async fn tick(&mut self) -> anyhow::Result<()> { + self.refresh().await?; + tracing::info!("refreshed data transfer multiplier inventory"); + Ok(()) + } +} diff --git a/mobile_packet_verifier/src/multiplier/mod.rs b/mobile_packet_verifier/src/multiplier/mod.rs new file mode 100644 index 000000000..aeac35604 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/mod.rs @@ -0,0 +1,243 @@ +//! HIP-150 data transfer multiplier tickets. +//! +//! A ticket grants one on-chain hotspot a multiplier on the data credits derived +//! from its rewardable bytes. Ingest verifies who sent a ticket and when, then +//! writes it to s3 verbatim; this module reads those files, rules on each +//! ticket, and keeps the result. +//! +//! Deciding validity *here* rather than at ingest is deliberate. HIP-150 wants +//! every multiplier in force to be externally auditable, and a ticket rejected +//! at the gRPC boundary leaves no record — rejected here, it is written to a +//! verified report alongside the accepted ones. +//! +//! Tickets are not stored in Postgres. A verified ticket is written to an s3 +//! report and appended to `data_transfer.multiplier_ticket_history`. +//! +//! Current state lives in `data_transfer.multiplier_ticket_inventory`, which +//! [`inventory`] keeps merged out of that history on a schedule. It follows the +//! shape `network-dbt` uses for `enabled_carriers_inventory` over +//! `enabled_carriers_history`, but the SQL is ours and the job runs here. +//! +//! The burn will read the inventory — whatever is current when a session +//! arrives, not what was in force when the data moved; see [`trino`]. Nothing +//! reads either table yet; no multiplier is applied until the burn is wired up. + +use std::{collections::HashMap, time::Duration}; + +use chrono::{DateTime, Utc}; +use file_store::{file_sink::FileSinkClient, file_upload::FileUpload}; +use file_store_oracles::{ + mobile::data_transfer_multiplier::{ + proto::VerifiedDataTransferMultiplierTicketReportV1, DataTransferMultiplier, + }, + traits::{FileSinkCommitStrategy, FileSinkRollTime, FileSinkWriteExt}, + FileType, +}; +use helium_crypto::PublicKeyBinary; +use humantime_serde::re::humantime; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use task_manager::{ManagedTask, TaskManager}; + +use crate::gateway::GatewayResolver; + +pub mod ingestor; +pub mod inventory; +pub mod trino; + +pub use trino::get_multipliers; + +#[derive(Debug, Deserialize, Serialize)] +pub struct MultiplierSettings { + /// Where to look in s3 for data transfer multiplier ticket files. + pub input_bucket: file_store::BucketSettings, + /// How far back to read ticket files on a cold start. + #[serde(default = "default_ingest_start_after")] + pub start_after: DateTime, + /// Public keys authorized to issue tickets, comma-separated b58. + /// + /// Checked again here even though ingest already checked it: ingest and this + /// verifier are separately deployed and separately configured, and it is + /// this verdict that lands on the record. + /// + /// May be empty, and is by default — no ticket can be issued until a key is + /// provisioned. Empty rejects every ticket. + #[serde(default)] + pub authorized_keys: String, + /// How often the inventory table is merged out of the history. + /// + /// This is burn-visible: a ticket takes effect once the next merge has run, + /// so this interval is the lag between verifying a grant and it being worth + /// anything. It also bounds how stale a burn's multipliers can be if the + /// merge starts failing. + #[serde( + with = "humantime_serde", + default = "default_inventory_refresh_interval" + )] + pub inventory_refresh_interval: Duration, + /// How old a ticket's signed timestamp may be, measured against the time + /// ingest stamped on it, before it is refused. + /// + /// A second check of what ingest already checked, configured separately, so + /// a mistake in ingest's window does not silently widen the replay window + /// everywhere. It is *not* an independent defence: both timestamps in the + /// comparison come from the same file, so it cannot help against anyone able + /// to write that file. + #[serde(with = "humantime_serde", default = "default_ticket_max_age")] + pub ticket_max_age: Duration, +} + +fn default_ingest_start_after() -> DateTime { + DateTime::UNIX_EPOCH +} + +fn default_inventory_refresh_interval() -> Duration { + humantime::parse_duration("15 minutes").unwrap() +} + +fn default_ticket_max_age() -> Duration { + humantime::parse_duration("10 minutes").unwrap() +} + +/// The set of keys allowed to issue tickets. +#[derive(Debug, Clone, Default)] +pub struct TicketSigners(std::collections::HashSet); + +impl TicketSigners { + pub fn contains(&self, signer: &PublicKeyBinary) -> bool { + self.0.contains(signer) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FromIterator for TicketSigners { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +/// The multiplier in force per hotspot at a point in time. +/// +/// Only ticketed hotspots appear. [`Multipliers::get`] is the single place the +/// "no ticket means 1" rule is written down — every other caller just asks. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Multipliers(HashMap); + +impl Multipliers { + pub fn get(&self, hotspot_pubkey: &PublicKeyBinary) -> DataTransferMultiplier { + self.0 + .get(hotspot_pubkey) + .copied() + .unwrap_or(DataTransferMultiplier::DEFAULT) + } + + pub fn insert(&mut self, hotspot_pubkey: PublicKeyBinary, multiplier: DataTransferMultiplier) { + self.0.insert(hotspot_pubkey, multiplier); + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FromIterator<(PublicKeyBinary, DataTransferMultiplier)> for Multipliers { + fn from_iter>( + iter: I, + ) -> Self { + Self(iter.into_iter().collect()) + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_managed_task( + pool: PgPool, + file_upload: FileUpload, + settings: &MultiplierSettings, + signers: TicketSigners, + resolver: GatewayResolver, + store_base_path: &std::path::Path, + history_writer: Option, + trino: trino_client::Client, +) -> anyhow::Result { + if signers.is_empty() { + tracing::warn!( + "no data transfer multiplier ticket signers configured; all tickets will be rejected" + ); + } + + let (verified_sink, verified_sink_server) = + VerifiedDataTransferMultiplierTicketReportV1::file_sink( + store_base_path, + file_upload, + FileSinkCommitStrategy::Manual, + FileSinkRollTime::Default, + env!("CARGO_PKG_NAME"), + ) + .await?; + + let (report_rx, report_server) = file_store::file_source::continuous_source() + .state(pool.clone()) + .bucket_client(settings.input_bucket.connect().await) + .lookback_start_after(settings.start_after) + .prefix(FileType::DataTransferMultiplierTicketIngestReport.to_string()) + .create() + .await?; + + let ingestor = ingestor::TicketIngestor::new( + pool, + report_rx, + verified_sink, + signers, + resolver, + settings.ticket_max_age, + history_writer, + ); + + let inventory = inventory::InventoryRefresher::new(trino, settings.inventory_refresh_interval); + + Ok(TaskManager::builder() + .add_task(report_server) + .add_task(verified_sink_server) + .add_task(task_manager::channel_consumer(ingestor)) + .add_task(task_manager::periodic(inventory)) + .build()) +} + +/// Type alias so the sink type is spelled once. +pub type VerifiedTicketSink = FileSinkClient; + +#[cfg(test)] +mod tests { + use super::*; + + fn key(byte: u8) -> PublicKeyBinary { + PublicKeyBinary::from(vec![byte]) + } + + #[test] + fn unknown_hotspot_gets_the_default_multiplier() { + let multipliers = Multipliers::default(); + assert_eq!( + multipliers.get(&key(1)), + DataTransferMultiplier::DEFAULT, + "a hotspot with no ticket must be unmultiplied" + ); + } + + #[test] + fn ticketed_hotspot_gets_its_multiplier() { + let one_and_a_half = DataTransferMultiplier::new(rust_decimal::dec!(1.5)).expect("valid"); + let multipliers = Multipliers::from_iter([(key(1), one_and_a_half)]); + + assert_eq!(multipliers.get(&key(1)), one_and_a_half); + // ...and its neighbour is unaffected. + assert_eq!(multipliers.get(&key(2)), DataTransferMultiplier::DEFAULT); + } +} diff --git a/mobile_packet_verifier/src/multiplier/trino.rs b/mobile_packet_verifier/src/multiplier/trino.rs new file mode 100644 index 000000000..95f92d399 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/trino.rs @@ -0,0 +1,108 @@ +//! Reading the multiplier currently in force per hotspot. +//! +//! **Nothing calls this yet.** It is the read side the burn path will use once +//! multipliers are applied to data credits; until then a ticket is recorded and +//! affects nothing. +//! +//! # Whatever is current when the session arrives +//! +//! A session is multiplied by whatever is in force when it is accumulated — not +//! by what was in force at the instant the data moved. Reconstructing the +//! latter would mean a point-in-time query against the ticket history, bounded +//! by the file's timestamp. +//! +//! That precision would be false. Sessions reach us batched behind an ingest +//! roll, tickets arrive on their own schedule, and the burn only runs hourly, so +//! the boundary between "before the ticket" and "after" is already fuzzy by +//! minutes. Paying for an exact answer to a question whose inputs are +//! approximate buys nothing, and it costs a scan of the whole history per file. +//! +//! So this reads the inventory table [`super::inventory`] keeps merged from the +//! history — one row per hotspot, already latest-per-key. No window function, +//! no timestamp bound. +//! +//! # What that trades away +//! +//! * **The inventory's refresh interval becomes visible in burns.** A ticket +//! takes effect once the next merge has run, not the moment it is verified. +//! Tune `inventory_refresh_interval` against how promptly a grant should land. +//! * **Replaying a backlog applies today's multipliers to old data.** Files +//! reprocessed long after the fact are multiplied by what is in force now, not +//! by what was in force then. Normal operation processes files promptly, so +//! this shows up only after an outage or a deliberate replay. +//! +//! Both follow from the same decision, taken knowingly: the timing was never +//! exact, and pretending otherwise would cost more than it is worth. + +use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; +use helium_crypto::PublicKeyBinary; +use helium_iceberg_oracles::data_transfer::multiplier_ticket_inventory::{NAMESPACE, TABLE_NAME}; +use serde::{Deserialize, Serialize}; +use trino_rust_client::Trino; + +use super::Multipliers; + +/// The multiplier in force per hotspot, as of the last inventory refresh. +/// +/// Hotspots with no ticket are absent from the result; [`Multipliers::get`] +/// resolves those to [`DataTransferMultiplier::DEFAULT`]. +pub async fn get_multipliers(trino: &trino_client::Client) -> anyhow::Result { + get_multipliers_from(trino, &format!("{NAMESPACE}.{TABLE_NAME}")).await +} + +/// Like [`get_multipliers`], with an explicit table name. +/// +/// Tests point this at a per-test catalog, the same way +/// [`crate::gateway::GatewayResolver::new_with_inventory_table`] does. +pub async fn get_multipliers_from( + trino: &trino_client::Client, + table: &str, +) -> anyhow::Result { + #[derive(Trino, Serialize, Deserialize)] + struct Row { + hotspot_pubkey: String, + multiplier: String, + } + + // The inventory is already one row per hotspot, so this is a plain read. + // + // `cast(... as varchar)` rather than reading a decimal: the value is + // re-parsed into a validated `DataTransferMultiplier` below, and a decimal + // string is the exact representation both sides already agree on. + let stmt = trino_client::Statement::new(format!( + "SELECT hotspot_pubkey, cast(multiplier AS varchar) AS multiplier FROM {table}" + )) + .typed::(); + + let rows = trino.get_all(stmt).await?; + + let mut multipliers = Multipliers::default(); + for row in rows { + let hotspot_pubkey: PublicKeyBinary = match row.hotspot_pubkey.parse() { + Ok(pubkey) => pubkey, + Err(err) => { + tracing::warn!(pub_key = %row.hotspot_pubkey, ?err, "skipping unparseable hotspot"); + continue; + } + }; + + // Values were validated before being written, so a value that no longer + // parses means the accepted range was narrowed since. Skip it rather + // than fail the file: the hotspot falls back to the default, which is + // the conservative direction. + match row + .multiplier + .parse() + .map_err(anyhow::Error::from) + .and_then(|value| -> anyhow::Result<_> { Ok(DataTransferMultiplier::new(value)?) }) + { + Ok(multiplier) => multipliers.insert(hotspot_pubkey, multiplier), + Err(err) => tracing::warn!( + %hotspot_pubkey, multiplier = %row.multiplier, ?err, + "stored multiplier is no longer valid, treating hotspot as unmultiplied" + ), + } + } + + Ok(multipliers) +} diff --git a/mobile_packet_verifier/src/settings.rs b/mobile_packet_verifier/src/settings.rs index 8edf3b350..810d6282e 100644 --- a/mobile_packet_verifier/src/settings.rs +++ b/mobile_packet_verifier/src/settings.rs @@ -11,7 +11,7 @@ use std::{ time::Duration, }; -use crate::{banning, routing::RoutingKeys}; +use crate::{banning, multiplier, routing::RoutingKeys}; #[derive(Debug, Deserialize, Serialize)] pub struct Settings { @@ -69,6 +69,8 @@ pub struct Settings { /// Settings for Banning pub banning: banning::BanSettings, + /// HIP-150 data transfer multiplier tickets. + pub multiplier: multiplier::MultiplierSettings, pub iceberg_settings: Option, } @@ -140,6 +142,26 @@ impl Settings { .and_then(|config| config.try_deserialize()) } + /// Keys authorized to issue HIP-150 multiplier tickets. + /// + /// May be empty, unlike [`Self::routing_keys`]: the release ships before any + /// ticket can be issued. An empty set rejects every ticket, and + /// `multiplier::create_managed_task` warns about it at startup. + pub fn ticket_signers(&self) -> anyhow::Result { + let mut keys = HashSet::new(); + for key in self.multiplier.authorized_keys.split(',') { + let key = key.trim(); + if key.is_empty() { + continue; + } + let key = PublicKeyBinary::from_str(key) + .with_context(|| format!("settings parsing ticket signer: {key}"))?; + keys.insert(key); + } + + Ok(multiplier::TicketSigners::from_iter(keys)) + } + pub fn routing_keys(&self) -> anyhow::Result { if self.routing_keys.is_empty() { anyhow::bail!("No routing keys provided in settings") diff --git a/mobile_packet_verifier/tests/integrations/common/mod.rs b/mobile_packet_verifier/tests/integrations/common/mod.rs index e51b524fc..258e4a2fe 100644 --- a/mobile_packet_verifier/tests/integrations/common/mod.rs +++ b/mobile_packet_verifier/tests/integrations/common/mod.rs @@ -10,6 +10,8 @@ pub async fn setup_iceberg() -> anyhow::Result { iceberg::session::table_definition()?, iceberg::invalid_session::table_definition()?, iceberg::burned_session::table_definition()?, + iceberg::multiplier_ticket_history::table_definition()?, + iceberg::multiplier_ticket_inventory::table_definition()?, hotspot_inventory::table_definition()?, ]) .await?; diff --git a/mobile_packet_verifier/tests/integrations/gateway_sharing.rs b/mobile_packet_verifier/tests/integrations/gateway_sharing.rs new file mode 100644 index 000000000..3efc1fbc4 --- /dev/null +++ b/mobile_packet_verifier/tests/integrations/gateway_sharing.rs @@ -0,0 +1,164 @@ +//! The gateway snapshot is shared, not copied. +//! +//! `GatewayResolver` is built once and cloned for each consumer — the data +//! transfer session path and the HIP-150 ticket path. Building a second one with +//! `new()` would load its own copy of the whole inventory and then never +//! refresh, because only one refresher task is registered. + +use std::time::Duration; + +use chrono::Utc; +use helium_crypto::PublicKeyBinary; +use mobile_packet_verifier::gateway::GatewayResolver; +use task_manager::Periodic; + +use crate::common::{self, hotspot_inventory}; + +/// A resolver whose refresher fires on every tick. +/// +/// `GatewaySnapshotRefresher` only reloads once `refresh_interval` has elapsed +/// since the last load, so a resolver built with the usual hour-long interval +/// would treat `tick()` as a no-op and these tests would pass for the wrong +/// reason. +async fn eager_resolver( + harness: &helium_iceberg::IcebergTestHarness, +) -> anyhow::Result { + Ok(GatewayResolver::new_with_inventory_table( + trino_client::Client::from_client(harness.owned_trino().await?), + hotspot_inventory::RESOLVER_TABLE, + Duration::from_secs(0), + ) + .await) +} + +/// Remove a gateway from the inventory table. +/// +/// The tests below need to tell "answered from the shared snapshot" apart from +/// "answered by a fallback query", and the two agree whenever the row is in the +/// table. Deleting it after a refresh splits them: the snapshot still holds the +/// entry, the fallback finds nothing. +/// +/// This replaces an older trick that asked at a timestamp before the gateway +/// existed, on the reasoning that only the fallback would answer `false`. That +/// stopped working when the snapshot became timestamp-aware (#1230) and both +/// paths started answering `false` together. +async fn remove_from_inventory( + harness: &helium_iceberg::IcebergTestHarness, + gateway: &PublicKeyBinary, +) -> anyhow::Result<()> { + trino_client::Client::from_client(harness.owned_trino().await?) + .execute_raw(format!( + "DELETE FROM {} WHERE pub_key = '{gateway}'", + hotspot_inventory::RESOLVER_TABLE + )) + .await?; + Ok(()) +} + +/// A clone taken *before* a refresh must see gateways that arrive after it. +/// That is the property the daemon depends on: one refresher keeps every +/// consumer current. +#[tokio::test] +async fn a_clone_sees_gateways_added_after_it_was_taken() -> anyhow::Result<()> { + let harness = common::setup_iceberg().await?; + let known_at_startup = PublicKeyBinary::from(vec![1]); + let onboarded_later = PublicKeyBinary::from(vec![2]); + let seen_at = Utc::now() - chrono::Duration::days(1); + + hotspot_inventory::seed( + &harness, + vec![hotspot_inventory::MobileHotspotInventory::known( + &known_at_startup, + seen_at, + )], + ) + .await?; + + let resolver = eager_resolver(&harness).await?; + let clone = resolver.clone(); + let mut refresher = resolver.refresher(); + + // The later gateway lands on chain after both the resolver and its clone + // were built. + hotspot_inventory::seed( + &harness, + vec![hotspot_inventory::MobileHotspotInventory::known( + &onboarded_later, + seen_at, + )], + ) + .await?; + + refresher.tick().await?; + + // Now take it back out of the table. Only the snapshot still knows about it, + // so a clone holding its own stale copy would miss, fall through to a + // per-pubkey query, and find nothing. + remove_from_inventory(&harness, &onboarded_later).await?; + + assert!( + clone.is_gateway_known(&onboarded_later, &Utc::now()).await, + "the clone must see the refreshed snapshot, not its own stale copy" + ); + + Ok(()) +} + +/// Two clones share one fallback cache, so a miss is queried once rather than +/// once per consumer. +#[tokio::test] +async fn clones_share_the_fallback_cache() -> anyhow::Result<()> { + let harness = common::setup_iceberg().await?; + let resolver = common::gateway_resolver(&harness).await?; + let clone = resolver.clone(); + + let unknown = PublicKeyBinary::from(vec![9]); + let now = Utc::now(); + + assert!(!resolver.is_gateway_known(&unknown, &now).await); + // Answered from the shared cache the first call populated. + assert!(!clone.is_gateway_known(&unknown, &now).await); + + Ok(()) +} + +/// Guards the reason clones exist: a resolver built independently starts from +/// its own load and is not updated by anyone else's refresher. +#[tokio::test] +async fn an_independent_resolver_does_not_share_a_snapshot() -> anyhow::Result<()> { + let harness = common::setup_iceberg().await?; + let resolver = eager_resolver(&harness).await?; + let mut refresher = resolver.refresher(); + + // A second resolver, built the way the daemon used to build one for tickets. + let independent = eager_resolver(&harness).await?; + + let onboarded_later = PublicKeyBinary::from(vec![3]); + let seen_at = Utc::now() - chrono::Duration::days(1); + hotspot_inventory::seed( + &harness, + vec![hotspot_inventory::MobileHotspotInventory::known( + &onboarded_later, + seen_at, + )], + ) + .await?; + + refresher.tick().await?; + remove_from_inventory(&harness, &onboarded_later).await?; + + assert!( + resolver + .is_gateway_known(&onboarded_later, &Utc::now()) + .await, + "the refreshed resolver should see it" + ); + assert!( + !independent + .is_gateway_known(&onboarded_later, &Utc::now()) + .await, + "an independently built resolver is not refreshed by someone else's task" + ); + + Ok(()) +} diff --git a/mobile_packet_verifier/tests/integrations/main.rs b/mobile_packet_verifier/tests/integrations/main.rs index 42a70e2ce..717ff68d7 100644 --- a/mobile_packet_verifier/tests/integrations/main.rs +++ b/mobile_packet_verifier/tests/integrations/main.rs @@ -5,4 +5,6 @@ pub mod banning; pub mod burn_metric; pub mod burner; pub mod daemon; +pub mod gateway_sharing; pub mod gateway_trino; +pub mod multiplier_tickets; diff --git a/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs new file mode 100644 index 000000000..110d283b1 --- /dev/null +++ b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs @@ -0,0 +1,758 @@ +//! HIP-150 data transfer multiplier tickets. +//! +//! Two things are tested here, and the first is the one that matters. +//! +//! **Which ticket is in force.** The history table is append-only and holds +//! every ticket a hotspot has ever been issued, so "the multiplier" is whichever +//! row wins an ordering — decided by the merge that builds the inventory. A +//! ticket is a correctly signed message that never expires, so getting that +//! ordering wrong is not cosmetic: it is a way to restore a revoked multiplier +//! by replaying a captured message. +//! +//! These go through the whole path — seed the history, merge, read the +//! inventory — because that is what the burn will do. There is deliberately no +//! test that a ticket applies only to data that post-dates it: multipliers take +//! effect from the next refresh, not from the instant the data moved. +//! +//! **Whether a ticket counts at all** — the `verdict` module at the bottom. + +use chrono::{DateTime, Duration, Utc}; +use file_store_oracles::mobile::data_transfer_multiplier::{ + DataTransferMultiplier, DataTransferMultiplierTicket, DataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketStatus as Status, +}; +use helium_crypto::PublicKeyBinary; +use helium_iceberg::IcebergTestHarness; +use helium_iceberg_oracles::data_transfer::multiplier_ticket_history::{ + IcebergMultiplierTicket, NAMESPACE, TABLE_NAME, +}; +use mobile_packet_verifier::multiplier::{ + inventory::InventoryRefresher, trino::get_multipliers_from, Multipliers, +}; +use rust_decimal::dec; + +use crate::common; + +/// Two-part `schema.table` name, resolved against the per-test catalog the +/// harness registers. +const HISTORY_TABLE: &str = "data_transfer.multiplier_ticket_history"; +const INVENTORY_TABLE: &str = "data_transfer.multiplier_ticket_inventory"; + +fn hotspot(byte: u8) -> PublicKeyBinary { + PublicKeyBinary::from(vec![byte]) +} + +fn multiplier(value: rust_decimal::Decimal) -> DataTransferMultiplier { + DataTransferMultiplier::new(value).expect("valid multiplier") +} + +/// A ticket carrying whatever multiplier is given, valid or not — the range is +/// judged by `ticket_status`, not by construction. +fn ticket_with( + hotspot_pubkey: &PublicKeyBinary, + value: Option, + signed: DateTime, + received: DateTime, +) -> DataTransferMultiplierTicketReport { + let mut report = ticket(hotspot_pubkey, dec!(1), signed, received); + report.report.multiplier = value; + report +} + +/// A ticket signed at `signed` and received at `received`. +fn ticket( + hotspot_pubkey: &PublicKeyBinary, + value: rust_decimal::Decimal, + signed: DateTime, + received: DateTime, +) -> DataTransferMultiplierTicketReport { + DataTransferMultiplierTicketReport { + received_timestamp: received, + report: DataTransferMultiplierTicket { + hotspot_pubkey: hotspot_pubkey.clone(), + multiplier: Some(value), + timestamp: signed, + message: "test ticket".to_string(), + signer_pubkey: hotspot(200), + signature: vec![], + }, + } +} + +/// A history row as the ingestor would have written it. +fn history_row( + report: DataTransferMultiplierTicketReport, + status: Status, +) -> IcebergMultiplierTicket { + let verified = VerifiedDataTransferMultiplierTicketReport { + verified_timestamp: report.received_timestamp, + report, + status, + }; + IcebergMultiplierTicket::from(&verified) +} + +fn valid_row(report: DataTransferMultiplierTicketReport) -> IcebergMultiplierTicket { + history_row(report, Status::Valid) +} + +async fn seed( + harness: &IcebergTestHarness, + rows: Vec, +) -> anyhow::Result<()> { + if rows.is_empty() { + return Ok(()); + } + harness + .get_table_writer_in::(NAMESPACE, TABLE_NAME) + .await? + .write(rows) + .await?; + Ok(()) +} + +/// Seed the history, run the merge, and read what the burn would see. +/// +/// The whole path: a ticket lands in the history, the inventory is merged out of +/// it, and the burn reads the inventory. Ordering and refusal rules live in the +/// merge, so exercising them through this helper tests them where they run. +async fn multipliers_after_refresh( + rows: Vec, +) -> anyhow::Result { + let harness = common::setup_iceberg().await?; + seed(&harness, rows).await?; + let trino = trino_client::Client::from_client(harness.owned_trino().await?); + + InventoryRefresher::new_with_tables( + trino.clone(), + std::time::Duration::from_secs(900), + HISTORY_TABLE.to_string(), + INVENTORY_TABLE.to_string(), + ) + .refresh() + .await?; + + get_multipliers_from(&trino, INVENTORY_TABLE).await +} + +#[tokio::test] +async fn no_tickets_means_every_hotspot_is_unmultiplied() -> anyhow::Result<()> { + let multipliers = multipliers_after_refresh(vec![]).await?; + + assert!(multipliers.is_empty()); + assert_eq!( + multipliers.get(&hotspot(1)), + DataTransferMultiplier::DEFAULT, + "a hotspot with no ticket must be unmultiplied" + ); + + Ok(()) +} + +#[tokio::test] +async fn latest_ticket_by_issue_time_wins() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let multipliers = multipliers_after_refresh(vec![ + valid_row(ticket( + &hotspot, + dec!(5), + now - Duration::hours(2), + now - Duration::hours(2), + )), + valid_row(ticket( + &hotspot, + dec!(1.5), + now - Duration::hours(1), + now - Duration::hours(1), + )), + ]) + .await?; + + assert_eq!(multipliers.get(&hotspot), multiplier(dec!(1.5))); + + Ok(()) +} + +/// **The replay test.** A ticket granting 5x is superseded by one granting 1x. +/// An attacker resubmits the original 5x ticket — a genuine, correctly signed +/// message — hours after it was revoked. +/// +/// With no database there is no primary key to reject the duplicate, so the +/// replay *does* become a row; the history honestly records that a resubmission +/// happened. It changes nothing because the ordering is on the issuer's signed +/// timestamp, which a replay cannot alter without the signing key. Ordering on +/// arrival would hand the attacker the 5x back. +#[tokio::test] +async fn replaying_a_superseded_ticket_does_not_restore_it() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let granted_at = now - Duration::hours(3); + let revoked_at = now - Duration::hours(2); + + let granted = valid_row(ticket(&hotspot, dec!(5), granted_at, granted_at)); + let revoked = valid_row(ticket(&hotspot, dec!(1), revoked_at, revoked_at)); + // The captured 5x ticket, resubmitted now. Same signed timestamp — that is + // what makes it a replay rather than a new grant — but it arrives after the + // revocation. + let replayed = valid_row(ticket(&hotspot, dec!(5), granted_at, now)); + + // Control: before the revocation the grant really was in force. Without + // this the assertion below would pass just as well if the query returned + // nothing at all, since "no ticket" and "revoked to 1" are both the default. + assert_eq!( + multipliers_after_refresh(vec![granted.clone()]) + .await? + .get(&hotspot), + multiplier(dec!(5)), + "the original grant should have been in force before revocation" + ); + + let multipliers = multipliers_after_refresh(vec![granted, revoked, replayed]).await?; + + assert_eq!( + multipliers.get(&hotspot), + DataTransferMultiplier::DEFAULT, + "a replayed ticket must not restore a revoked multiplier" + ); + + Ok(()) +} + +/// A ticket held up in delivery must not leapfrog a newer one that overtook it. +/// Ordering by arrival gets this wrong with no attacker involved at all. +#[tokio::test] +async fn a_delayed_ticket_does_not_supersede_a_newer_one() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let multipliers = multipliers_after_refresh(vec![ + // Signed second, arrived first. + valid_row(ticket( + &hotspot, + dec!(1.5), + now - Duration::hours(1), + now - Duration::minutes(30), + )), + // Signed first, arrived second. + valid_row(ticket( + &hotspot, + dec!(5), + now - Duration::hours(2), + now - Duration::minutes(10), + )), + ]) + .await?; + + assert_eq!( + multipliers.get(&hotspot), + multiplier(dec!(1.5)), + "the more recently *issued* ticket wins, not the more recently received" + ); + + Ok(()) +} + +/// The history table keeps refused tickets, so the read has to exclude them. +/// Without the status filter a rejected grant would take effect. +#[tokio::test] +async fn rejected_tickets_do_not_take_effect() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let multipliers = multipliers_after_refresh(vec![ + valid_row(ticket( + &hotspot, + dec!(1.5), + now - Duration::hours(2), + now - Duration::hours(2), + )), + // Newer, larger, and refused — it must not win despite sorting first. + history_row( + ticket( + &hotspot, + dec!(5), + now - Duration::hours(1), + now - Duration::hours(1), + ), + Status::InvalidSigner, + ), + ]) + .await?; + + assert_eq!( + multipliers.get(&hotspot), + multiplier(dec!(1.5)), + "a refused ticket must not take effect" + ); + + Ok(()) +} + +#[tokio::test] +async fn hotspots_do_not_affect_each_other() -> anyhow::Result<()> { + let now = Utc::now(); + let (a, b, c) = (hotspot(1), hotspot(2), hotspot(3)); + + let multipliers = multipliers_after_refresh(vec![ + valid_row(ticket(&a, dec!(1.5), now, now)), + valid_row(ticket(&b, dec!(5), now, now)), + ]) + .await?; + + assert_eq!(multipliers.len(), 2, "only ticketed hotspots appear"); + assert_eq!(multipliers.get(&a), multiplier(dec!(1.5))); + assert_eq!(multipliers.get(&b), multiplier(dec!(5))); + assert_eq!(multipliers.get(&c), DataTransferMultiplier::DEFAULT); + + Ok(()) +} + +/// The exact decimal must survive the round trip through `decimal(9,6)`. 1.3 is +/// the value that would come back mangled from a float column. +#[tokio::test] +async fn multipliers_round_trip_exactly() -> anyhow::Result<()> { + let now = Utc::now(); + let values = [dec!(1), dec!(1.5), dec!(1.3), dec!(2.718281), dec!(5)]; + + let rows = values + .iter() + .enumerate() + .map(|(i, value)| valid_row(ticket(&hotspot(i as u8 + 1), *value, now, now))) + .collect(); + + let multipliers = multipliers_after_refresh(rows).await?; + + for (i, value) in values.iter().enumerate() { + assert_eq!( + multipliers.get(&hotspot(i as u8 + 1)), + multiplier(*value), + "{value} did not survive storage" + ); + } + + Ok(()) +} + +/// `1.5` and `1.50` are the same multiplier. Normalizing on parse is what keeps +/// a difference in spelling from becoming a difference in value — including +/// through the `decimal(9,6)` column, which returns everything scale-padded. +#[tokio::test] +async fn equivalent_spellings_are_one_multiplier() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let multipliers = + multipliers_after_refresh(vec![valid_row(ticket(&hotspot, dec!(1.50), now, now))]).await?; + + assert_eq!(multipliers.get(&hotspot), multiplier(dec!(1.5))); + + Ok(()) +} + +// ── The inventory table ───────────────────────────────────────────────────── +// +// The history is the log; the inventory is what is currently in force. A +// periodic MERGE builds the second from the first. + +mod inventory { + use super::*; + use helium_iceberg_oracles::data_transfer::multiplier_ticket_inventory::IcebergMultiplierInventory; + use mobile_packet_verifier::multiplier::inventory::InventoryRefresher; + use std::time::Duration as StdDuration; + + /// Compare by value, not by formatting. Trino returns a `decimal(9,6)` + /// scale-padded — 1.5 comes back as "1.500000" — which says nothing about + /// whether the stored value is right. + fn stored(row: &IcebergMultiplierInventory) -> rust_decimal::Decimal { + row.multiplier + .as_string() + .parse() + .expect("stored multiplier parses") + } + + /// Seed history, run the merge, and read the inventory back. + async fn refresh_and_read( + rows: Vec, + ) -> anyhow::Result> { + let harness = common::setup_iceberg().await?; + seed(&harness, rows).await?; + let trino = trino_client::Client::from_client(harness.owned_trino().await?); + + InventoryRefresher::new_with_tables( + trino.clone(), + StdDuration::from_secs(900), + HISTORY_TABLE.to_string(), + INVENTORY_TABLE.to_string(), + ) + .refresh() + .await?; + + let mut rows: Vec = trino + .get_all_raw(format!("SELECT * FROM {INVENTORY_TABLE}")) + .await?; + rows.sort_by(|a, b| a.hotspot_pubkey.cmp(&b.hotspot_pubkey)); + Ok(rows) + } + + #[tokio::test] + async fn merges_the_latest_valid_ticket_per_hotspot() -> anyhow::Result<()> { + let now = Utc::now(); + let (a, b) = (hotspot(1), hotspot(2)); + + let rows = refresh_and_read(vec![ + valid_row(ticket( + &a, + dec!(5), + now - Duration::hours(2), + now - Duration::hours(2), + )), + valid_row(ticket( + &a, + dec!(1.5), + now - Duration::hours(1), + now - Duration::hours(1), + )), + valid_row(ticket( + &b, + dec!(2), + now - Duration::hours(1), + now - Duration::hours(1), + )), + ]) + .await?; + + assert_eq!(rows.len(), 2, "one row per hotspot, not one per ticket"); + assert_eq!(stored(&rows[0]), dec!(1.5), "superseded 5 must not win"); + assert_eq!(stored(&rows[1]), dec!(2)); + + Ok(()) + } + + /// The inventory holds what is in force, so refusals are excluded. A refusal + /// also must not revoke an earlier grant. + #[tokio::test] + async fn refused_tickets_are_excluded() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let rows = refresh_and_read(vec![ + valid_row(ticket( + &hotspot, + dec!(1.5), + now - Duration::hours(2), + now - Duration::hours(2), + )), + // Newer, larger, refused. + history_row( + ticket( + &hotspot, + dec!(5), + now - Duration::hours(1), + now - Duration::hours(1), + ), + Status::InvalidSigner, + ), + ]) + .await?; + + assert_eq!(rows.len(), 1); + assert_eq!( + stored(&rows[0]), + dec!(1.5), + "a refusal must not take effect, nor revoke the last valid grant" + ); + + Ok(()) + } + + /// **The merge test.** Running twice must update in place, not append a + /// second row per hotspot — that is the whole difference between a merge and + /// the append-only writer. + #[tokio::test] + async fn refreshing_twice_updates_in_place() -> anyhow::Result<()> { + let hotspot = hotspot(1); + let now = Utc::now(); + + let harness = common::setup_iceberg().await?; + seed( + &harness, + vec![valid_row(ticket( + &hotspot, + dec!(1.5), + now - Duration::hours(2), + now - Duration::hours(2), + ))], + ) + .await?; + let trino = trino_client::Client::from_client(harness.owned_trino().await?); + let refresher = InventoryRefresher::new_with_tables( + trino.clone(), + StdDuration::from_secs(900), + HISTORY_TABLE.to_string(), + INVENTORY_TABLE.to_string(), + ); + + refresher.refresh().await?; + + // A newer grant arrives, then we refresh again. + seed( + &harness, + vec![valid_row(ticket( + &hotspot, + dec!(5), + now - Duration::hours(1), + now - Duration::hours(1), + ))], + ) + .await?; + refresher.refresh().await?; + + let rows: Vec = trino + .get_all_raw(format!("SELECT * FROM {INVENTORY_TABLE}")) + .await?; + + assert_eq!( + rows.len(), + 1, + "the hotspot must have one row, not one per refresh" + ); + assert_eq!(stored(&rows[0]), dec!(5), "the row must have been updated"); + + Ok(()) + } + + #[tokio::test] + async fn no_tickets_leaves_the_inventory_empty() -> anyhow::Result<()> { + assert!(refresh_and_read(vec![]).await?.is_empty()); + Ok(()) + } +} + +// ── The verdict rule ──────────────────────────────────────────────────────── +// +// Storage above decides which ticket wins. These decide whether a ticket counts +// at all — the second line of defence behind ingest, exercised here because +// ingest and this verifier are configured separately and either could be wrong. + +mod verdict { + use super::*; + use file_store_oracles::mobile::data_transfer_multiplier::MAX_CLOCK_DRIFT; + use mobile_packet_verifier::multiplier::{ingestor::ticket_status, TicketSigners}; + use std::time::Duration as StdDuration; + + const MAX_AGE: StdDuration = StdDuration::from_secs(600); + + fn signer() -> PublicKeyBinary { + hotspot(200) + } + + async fn status_of( + report: &DataTransferMultiplierTicketReport, + signers: TicketSigners, + ) -> anyhow::Result { + let harness = common::setup_iceberg().await?; + // Seed the ticket's hotspot as known on chain, well before the ticket. + common::hotspot_inventory::seed( + &harness, + vec![common::hotspot_inventory::MobileHotspotInventory::known( + &report.report.hotspot_pubkey, + report.received_timestamp - Duration::days(1), + )], + ) + .await?; + let resolver = common::gateway_resolver(&harness).await?; + + Ok(ticket_status(report, &signers, MAX_AGE, &resolver).await) + } + + #[tokio::test] + async fn accepts_a_fresh_ticket_from_a_known_signer() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::Valid); + + Ok(()) + } + + #[tokio::test] + async fn rejects_an_unauthorized_signer() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now, now); + + // A different key is authorized — the ticket's signer is not. + let status = status_of(&report, TicketSigners::from_iter([hotspot(201)])).await?; + assert_eq!(status, Status::InvalidSigner); + + Ok(()) + } + + /// The empty allow-list, which is how this ships. Fails closed. + #[tokio::test] + async fn rejects_every_ticket_when_no_signers_are_configured() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now, now); + + let status = status_of(&report, TicketSigners::default()).await?; + assert_eq!(status, Status::InvalidSigner); + + Ok(()) + } + + /// Signed well before ingest received it — the shape a replayed ticket has. + #[tokio::test] + async fn rejects_a_stale_ticket() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now - Duration::hours(2), now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::InvalidTimestamp); + + Ok(()) + } + + /// Post-dating must not buy an arbitrarily long window. + #[tokio::test] + async fn rejects_a_future_dated_ticket() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now + Duration::hours(1), now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::InvalidTimestamp); + + Ok(()) + } + + /// Ingest tolerates a client whose clock runs slightly fast, so this must + /// too. If it did not, a ticket ingest accepted would be refused here and + /// the two services would disagree about the same ticket. + #[tokio::test] + async fn tolerates_the_same_clock_drift_ingest_does() -> anyhow::Result<()> { + let now = Utc::now(); + // Signed ahead of the timestamp ingest stamped on it. + let signed = now + Duration::from_std(MAX_CLOCK_DRIFT)? - Duration::seconds(5); + let report = ticket(&hotspot(1), dec!(1.5), signed, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::Valid); + + Ok(()) + } + + /// The far side of the allowance, so the test above cannot pass by the + /// tolerance being unbounded. + #[tokio::test] + async fn rejects_drift_beyond_the_allowance() -> anyhow::Result<()> { + let now = Utc::now(); + let signed = now + Duration::from_std(MAX_CLOCK_DRIFT)? + Duration::minutes(1); + let report = ticket(&hotspot(1), dec!(1.5), signed, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::InvalidTimestamp); + + Ok(()) + } + + /// Inside the window is still accepted — without this the rejection tests + /// would pass just as well if the window were zero. + #[tokio::test] + async fn accepts_a_ticket_inside_the_window() -> anyhow::Result<()> { + let now = Utc::now(); + let signed = now - Duration::from_std(MAX_AGE)? + Duration::minutes(1); + let report = ticket(&hotspot(1), dec!(1.5), signed, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::Valid); + + Ok(()) + } + + /// HIP-150 fixes the range at 1 to 5 inclusive, enforced by the oracles. + /// Enforced *here* rather than at ingest so the refusal lands on the record + /// instead of vanishing at the gRPC boundary. + #[tokio::test] + async fn rejects_a_multiplier_outside_the_hip_range() -> anyhow::Result<()> { + let now = Utc::now(); + + for value in [dec!(0), dec!(0.999999), dec!(5.000001), dec!(7), dec!(-1)] { + let report = ticket_with(&hotspot(1), Some(value), now, now); + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!( + status, + Status::InvalidMultiplier, + "{value} is outside 1..=5 and must be refused" + ); + } + + Ok(()) + } + + /// The bounds themselves are accepted, so the test above cannot pass by the + /// range being empty. + #[tokio::test] + async fn accepts_the_bounds_of_the_hip_range() -> anyhow::Result<()> { + let now = Utc::now(); + + for value in [dec!(1), dec!(5), dec!(1.5), dec!(4.999999)] { + let report = ticket_with(&hotspot(1), Some(value), now, now); + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::Valid, "{value} is within 1..=5"); + } + + Ok(()) + } + + /// More precision than the column stores is refused too — same outcome to a + /// submitter, and it keeps the stored value exact. + #[tokio::test] + async fn rejects_a_multiplier_with_too_much_precision() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket_with(&hotspot(1), Some(dec!(1.5000001)), now, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::InvalidMultiplier); + + Ok(()) + } + + /// An absent or unparseable multiplier is a refusal on the record, not a + /// record that never existed. The file poller silently drops anything that + /// fails to decode, so the ticket has to survive decoding to be refused. + #[tokio::test] + async fn rejects_an_absent_multiplier() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket_with(&hotspot(1), None, now, now); + + let status = status_of(&report, TicketSigners::from_iter([signer()])).await?; + assert_eq!(status, Status::InvalidMultiplier); + + Ok(()) + } + + /// A multiplier can only attach to an on-chain hotspot. + #[tokio::test] + async fn rejects_an_unknown_hotspot() -> anyhow::Result<()> { + let now = Utc::now(); + let report = ticket(&hotspot(1), dec!(1.5), now, now); + + let harness = common::setup_iceberg().await?; + // Nothing seeded: the hotspot is not on chain. + let resolver = common::gateway_resolver(&harness).await?; + + let status = ticket_status( + &report, + &TicketSigners::from_iter([signer()]), + MAX_AGE, + &resolver, + ) + .await; + assert_eq!(status, Status::InvalidHotspotKey); + + Ok(()) + } +} From 3ff908f452b682430f1e6fbf63a661d14eda3423 Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Fri, 28 Aug 2026 11:44:01 -0700 Subject: [PATCH 4/7] HIP-150: apply data transfer multipliers to burned data credits (#1244) * HIP-150: apply data transfer multipliers to burned data credits A verified ticket grants a hotspot a multiplier on the data credits its rewardable bytes convert to. HIP-150 explicitly calls out that the multiplier is applied to derived DC count, not rewardable_bytes. So a payer never burns more than the multiplier earns. Gateways with no tickets get a default multiplier of 1. ** Choosing which multiplier applies When we burn, sessions are pulled from the database, and the timestamp on the row is used to find a multiplier (if one exists) that was active at the time the data transfer session was processed. Deciding this on read rather than on arrival allows us to handle a case where a multiplier ticket may come in _after_ a data transfer session it should apply to within the hour. A ticket effective at 1:30 might not be processed until 1:55, and a row from 1:40 still needs it. Rows are now one hotspot per file, keyed (pub_key, payer, last_timestamp), so each has a single instant to be priced at. Before this a row covered a whole burn window, and bytes from 1:15 and 1:45 were already summed together with no way to separate them. The burn then groups by (hotspot, multiplier) and converts bytes_to_dc once per group. ** Where grants live The ticket ingestor writes multipliers up to 3 places. 1. Iceberg history table. 2. S3 verified_data_transfer_multiplier_ticket_report. 3. Postgres to join against data_transfer_sessions for burning. In-flight burns carry their multiplier on the pending rows. The amount is fixed on chain by then, so the confirm path regroups on the stored value rather than asking the history again, and the record cannot disagree with what was charged. ** Also in here Fixes pending_dc_burn. It was incremented by accumulate and decremented by the burn, but the two never counted the same way: accumulate sums bytes per payer and converts once, the burn converts per hotspot, so any payer with more than one hotspot in a file drifted negative every cycle. It is now set from the priced total each burn cycle. Same metric name, so existing alerts carry over, and because it is set before the balance check a payer that cannot pay keeps a gauge showing its real debt. Repartitions multiplier_ticket_history to bucket(hotspot_pubkey, 4) instead of day(received_timestamp). We read this table by hotspot, not by date. The bucket count can be raised later without rewriting what is already there. Adds burned_dc_by_multiplier{payer, multiplier}. Summing it gives back the burned counter; grouping by multiplier shows how much of the burn is coming from ticketed hotspots. ** Deploying Three migrations run on start: - 10_data_transfer_multipliers.sql :: new table - 11_data_transfer_session_buckets.sql :: data_transfer_sessions key -> (pub_key, payer, last_timestamp) - 12_pending_session_multiplier.sql :: pending_data_transfer_sessions: multiplier column, same key change Both key changes widen an existing key so they cannot collide, but they take an ACCESS EXCLUSIVE lock and rebuild the index. Deploy just after a burn, when data_transfer_sessions is smallest. Rolling the binary back after migration 11 will not work. The previous release's ON CONFLICT (pub_key, payer) has no matching unique constraint once the key is widened, and every accumulate fails. Roll forward. *Run by hand before the release:* ALTER TABLE data_transfer.burned_sessions ADD COLUMN multiplier DECIMAL(9, 6) Skipping it is silent. Writes still succeed and the multiplier is dropped, leaving an audit gap that cannot be backfilled. Burns stay correct either way. If multiplier_ticket_history already exists it needs repartitioning too, since create_table_if_not_exists will not alter an existing table: ALTER TABLE data_transfer.multiplier_ticket_history SET PROPERTIES partitioning = ARRAY['bucket(hotspot_pubkey, 4)'] No signer has been provisioned, so it should be empty. Run it with the deploy, not before: changing the spec under a running writer breaks its writes until it restarts. * Update status value stored in iceberg to be simpler --------- Co-authored-by: Brian Balser --- .../src/mobile/data_transfer_multiplier.rs | 53 +- .../src/mobile/mobile_transfer.rs | 23 +- .../src/data_transfer/burned_session.rs | 20 + .../src/data_transfer/mod.rs | 6 - .../multiplier_ticket_history.rs | 39 +- .../multiplier_ticket_inventory.rs | 140 --- .../10_data_transfer_multipliers.sql | 25 + .../11_data_transfer_session_buckets.sql | 24 + .../12_pending_session_multiplier.sql | 22 + .../pkg/settings-template.toml | 26 +- mobile_packet_verifier/src/burner.rs | 41 +- mobile_packet_verifier/src/daemon.rs | 1 - mobile_packet_verifier/src/iceberg.rs | 12 +- mobile_packet_verifier/src/multiplier/db.rs | 67 ++ .../src/multiplier/ingestor.rs | 83 +- .../src/multiplier/inventory.rs | 91 -- mobile_packet_verifier/src/multiplier/mod.rs | 145 +-- .../src/multiplier/trino.rs | 108 --- mobile_packet_verifier/src/pending_burns.rs | 251 ++++-- mobile_packet_verifier/src/pending_txns.rs | 70 +- .../tests/integrations/accumulate_sessions.rs | 2 +- .../tests/integrations/apply_multiplier.rs | 851 ++++++++++++++++++ .../tests/integrations/burn_metric.rs | 289 +++++- .../tests/integrations/burner.rs | 9 +- .../tests/integrations/common/mod.rs | 1 - .../tests/integrations/main.rs | 1 + .../tests/integrations/multiplier_tickets.rs | 530 ++--------- .../tests/integrations/reward_dc.rs | 4 + .../tests/integrations/rewarder_dc_trino.rs | 1 + 29 files changed, 1838 insertions(+), 1097 deletions(-) delete mode 100644 helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs create mode 100644 mobile_packet_verifier/migrations/10_data_transfer_multipliers.sql create mode 100644 mobile_packet_verifier/migrations/11_data_transfer_session_buckets.sql create mode 100644 mobile_packet_verifier/migrations/12_pending_session_multiplier.sql create mode 100644 mobile_packet_verifier/src/multiplier/db.rs delete mode 100644 mobile_packet_verifier/src/multiplier/inventory.rs delete mode 100644 mobile_packet_verifier/src/multiplier/trino.rs create mode 100644 mobile_packet_verifier/tests/integrations/apply_multiplier.rs diff --git a/file_store_oracles/src/mobile/data_transfer_multiplier.rs b/file_store_oracles/src/mobile/data_transfer_multiplier.rs index 7f2c5416a..69f28a04e 100644 --- a/file_store_oracles/src/mobile/data_transfer_multiplier.rs +++ b/file_store_oracles/src/mobile/data_transfer_multiplier.rs @@ -40,6 +40,32 @@ pub mod proto { pub use proto::VerifiedDataTransferMultiplierTicketStatus; +/// The verdict as a short string, for the stored record and for metric labels. +/// +/// prost's own `as_str_name()` gives the full proto enum name — +/// `verified_data_transfer_multiplier_ticket_status_valid` — which is 44 +/// characters restating the column the value sits in. Trino has no enum type, +/// so this string *is* what a person queries against, and +/// `status = 'valid'` beats the alternative. +/// +/// Same reasoning as `carrier_id_string` in `helium_iceberg_oracles`, which +/// already does this for `CarrierIdV2` for the same reason. +/// +/// Spelled out rather than derived by stripping a prefix, so a new variant is a +/// compile error here rather than a surprise in the data. Nothing parses these +/// back into the enum; if that changes, this needs an inverse. +pub fn ticket_status_string(status: VerifiedDataTransferMultiplierTicketStatus) -> &'static str { + use VerifiedDataTransferMultiplierTicketStatus as Status; + + match status { + Status::Valid => "valid", + Status::InvalidSigner => "invalid_signer", + Status::InvalidMultiplier => "invalid_multiplier", + Status::InvalidHotspotKey => "invalid_hotspot_key", + Status::InvalidTimestamp => "invalid_timestamp", + } +} + /// Smallest multiplier the oracles accept. /// /// Operating policy, not wire format: HIP-150's 1-to-5 figures are starting @@ -100,7 +126,11 @@ pub enum MultiplierError { /// /// The inner value is private and every constructor validates, so holding one /// *is* the proof it is in range — callers never re-check. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// +/// Serializes as the underlying decimal, so a record carrying one reads as the +/// number rather than a wrapper. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(transparent)] pub struct DataTransferMultiplier(Decimal); impl DataTransferMultiplier { @@ -393,6 +423,27 @@ mod tests { use super::*; use rust_decimal::dec; + /// These strings are a stored data format, not a display detail: they land + /// in `data_transfer.multiplier_ticket_history.status` and in a metric + /// label. Changing one silently reclassifies history, so pin them exactly + /// rather than asserting a shape. + #[test] + fn status_strings_are_stable() { + use VerifiedDataTransferMultiplierTicketStatus as Status; + + for (status, expected) in [ + (Status::Valid, "valid"), + (Status::InvalidSigner, "invalid_signer"), + (Status::InvalidMultiplier, "invalid_multiplier"), + (Status::InvalidHotspotKey, "invalid_hotspot_key"), + (Status::InvalidTimestamp, "invalid_timestamp"), + ] { + assert_eq!(ticket_status_string(status), expected); + // The point of the mapping: prost's own name restates the column. + assert_ne!(ticket_status_string(status), status.as_str_name()); + } + } + fn dec_proto(s: &str) -> proto::Decimal { proto::Decimal { value: s.to_string(), diff --git a/file_store_oracles/src/mobile/mobile_transfer.rs b/file_store_oracles/src/mobile/mobile_transfer.rs index aab9fb89d..415280992 100644 --- a/file_store_oracles/src/mobile/mobile_transfer.rs +++ b/file_store_oracles/src/mobile/mobile_transfer.rs @@ -2,6 +2,8 @@ use chrono::{DateTime, Utc}; use file_store::traits::{MsgDecode, TimestampDecode, TimestampDecodeError, TimestampEncode}; use helium_crypto::PublicKeyBinary; use helium_proto::services::packet_verifier as proto; + +use crate::mobile::data_transfer_multiplier::DataTransferMultiplier; use serde::Serialize; #[derive(thiserror::Error, Debug)] @@ -18,6 +20,12 @@ pub struct ValidDataTransferSession { pub download_bytes: u64, pub rewardable_bytes: u64, pub num_dcs: u64, + /// HIP-150: the multiplier `num_dcs` was derived with. + /// + /// `num_dcs` is the post-multiplier figure — what the payer actually burned, + /// and what the reward path reads — so this is what makes the + /// pre-multiplier count recoverable. + pub multiplier: DataTransferMultiplier, pub first_timestamp: DateTime, pub last_timestamp: DateTime, pub burn_timestamp: DateTime, @@ -38,6 +46,13 @@ impl TryFrom for ValidDataTransferSession { download_bytes: v.download_bytes, rewardable_bytes: v.rewardable_bytes, num_dcs: v.num_dcs, + // Absent means the record predates HIP-150; every record written + // since carries an explicit multiplier, `1` included. + multiplier: v + .multiplier + .and_then(|m| m.value.parse().ok()) + .and_then(|d| DataTransferMultiplier::new(d).ok()) + .unwrap_or(DataTransferMultiplier::DEFAULT), first_timestamp: v.first_timestamp.to_timestamp_millis()?, last_timestamp: v.last_timestamp.to_timestamp_millis()?, burn_timestamp: v.burn_timestamp.to_timestamp_millis()?, @@ -57,11 +72,9 @@ impl From for proto::ValidDataTransferSession { last_timestamp: v.last_timestamp.encode_timestamp_millis(), rewardable_bytes: v.rewardable_bytes, burn_timestamp: v.burn_timestamp.encode_timestamp_millis(), - // HIP-150: populated once mobile-packet-verifier applies multipliers. - // Absent means no multiplier was in force, which is what every - // session is until then — so this preserves current behaviour - // exactly rather than asserting a 1x that was never looked up. - multiplier: None, + // Always written, `1` included, so absent means only "predates + // HIP-150" rather than being ambiguous with an unmultiplied session. + multiplier: Some(v.multiplier.into()), } } } diff --git a/helium_iceberg_oracles/src/data_transfer/burned_session.rs b/helium_iceberg_oracles/src/data_transfer/burned_session.rs index 6df4191c7..af9eb2e6d 100644 --- a/helium_iceberg_oracles/src/data_transfer/burned_session.rs +++ b/helium_iceberg_oracles/src/data_transfer/burned_session.rs @@ -4,6 +4,9 @@ use helium_iceberg::{FieldDefinition, PartitionDefinition, TableDefinition}; use serde::{Deserialize, Serialize}; use trino_rust_client::Trino; +pub use super::multiplier_ticket_history::{ + MultiplierDecimal, MULTIPLIER_PRECISION, MULTIPLIER_SCALE, +}; pub use super::NAMESPACE; pub const TABLE_NAME: &str = "burned_sessions"; @@ -15,6 +18,16 @@ pub struct IcebergBurnedDataTransferSession { pub download_bytes: u64, pub rewardable_bytes: u64, pub num_dcs: u64, + /// HIP-150: the multiplier `num_dcs` was derived with. + /// + /// `num_dcs` is the post-multiplier figure — what the payer burned, and what + /// the reward path distributes pro-rata of — so this is what makes the + /// pre-multiplier count recoverable and the burn auditable. + /// + /// `None` on rows written before HIP-150. Every row written since carries an + /// explicit value, `1` included, so absent never has to be read as "probably + /// unmultiplied". + pub multiplier: Option, /// Timestamp of the first ingest file we found a data transfer session in pub first_timestamp: DateTime, @@ -33,6 +46,9 @@ pub fn table_definition() -> helium_iceberg::Result { FieldDefinition::required_long("download_bytes"), FieldDefinition::required_long("rewardable_bytes"), FieldDefinition::required_long("num_dcs"), + // Optional so the rows already in this table stay readable: it is + // added to a live table by hand, and nothing backfills them. + FieldDefinition::optional_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), FieldDefinition::required_timestamptz("first_timestamp"), FieldDefinition::required_timestamptz("last_timestamp"), FieldDefinition::required_timestamptz("burn_timestamp"), @@ -66,6 +82,10 @@ impl From for IcebergBurnedDataTransferSession { download_bytes: value.download_bytes, rewardable_bytes: value.rewardable_bytes, num_dcs: value.num_dcs, + // Validated on the way in and bounded well inside decimal(9,6), so + // this cannot realistically fail; `None` would understate the burn + // rather than misstate it. + multiplier: MultiplierDecimal::try_from(value.multiplier.as_decimal()).ok(), first_timestamp: value.first_timestamp.into(), last_timestamp: value.last_timestamp.into(), burn_timestamp: value.burn_timestamp.into(), diff --git a/helium_iceberg_oracles/src/data_transfer/mod.rs b/helium_iceberg_oracles/src/data_transfer/mod.rs index 91d3860c2..6bc751020 100644 --- a/helium_iceberg_oracles/src/data_transfer/mod.rs +++ b/helium_iceberg_oracles/src/data_transfer/mod.rs @@ -10,21 +10,15 @@ //! data-transfer rewards. //! - `multiplier_ticket_history` — every HIP-150 multiplier ticket seen, //! accepted or refused. Append-only. -//! - `multiplier_ticket_inventory` — the multiplier currently in force per -//! hotspot, merged from the history on a schedule. Follows the pattern -//! `network-dbt` uses for `enabled_carriers_inventory` over -//! `enabled_carriers_history`, with our own job issuing the SQL. pub mod burned_session; pub mod invalid_session; pub mod multiplier_ticket_history; -pub mod multiplier_ticket_inventory; pub mod session; pub use burned_session::IcebergBurnedDataTransferSession; pub use invalid_session::IcebergInvalidDataTransferSession; pub use multiplier_ticket_history::IcebergMultiplierTicket; -pub use multiplier_ticket_inventory::IcebergMultiplierInventory; pub use session::IcebergDataTransferSession; pub const NAMESPACE: &str = "data_transfer"; diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs index 741f77547..1b42f5412 100644 --- a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs @@ -1,19 +1,21 @@ //! `data_transfer.multiplier_ticket_history` — every HIP-150 ticket ever seen. //! //! Append-only, one row per ticket received, accepted or not. HIP-150 requires -//! every multiplier in force to be externally auditable; recording refusals too -//! means the record answers "why is this hotspot not multiplied" as well as +//! every multiplier in force to be externally auditable, and recording refusals +//! too means the record answers "why is this hotspot not multiplied" as well as //! "why is it". //! -//! This is the log. For "what is current", see -//! [`super::multiplier_ticket_inventory`]. +//! This is the audit record. What the burn actually charges against is the +//! `data_transfer_multipliers` table in mobile-packet-verifier's Postgres. use chrono::{DateTime, FixedOffset}; use helium_iceberg::{FieldDefinition, PartitionDefinition, TableDefinition}; use serde::{Deserialize, Serialize}; use trino_rust_client::Trino; -use file_store_oracles::mobile::data_transfer_multiplier::VerifiedDataTransferMultiplierTicketReport; +use file_store_oracles::mobile::data_transfer_multiplier::{ + ticket_status_string, VerifiedDataTransferMultiplierTicketReport, +}; use crate::IcebergDecimal; @@ -53,7 +55,10 @@ pub struct IcebergMultiplierTicket { pub multiplier: Option, pub signer: String, pub message: String, - /// The verdict, as the proto enum's string name. Rejected tickets are kept. + /// The verdict: `valid`, `invalid_signer`, `invalid_multiplier`, + /// `invalid_hotspot_key` or `invalid_timestamp`. Rejected tickets are kept. + /// See `ticket_status_string` for why these are not the proto enum's own + /// names. pub status: String, } @@ -69,9 +74,23 @@ pub fn table_definition() -> helium_iceberg::Result { FieldDefinition::required_string("message"), FieldDefinition::required_string("status"), ]) - .with_partition(PartitionDefinition::day( - "received_timestamp", - "received_timestamp_day", + // Bucketed on the hotspot, because that is how this table gets read: + // "what is this hotspot on now", or "what has it ever been granted". + // Both want every row for one key, which a date partition cannot prune. + // + // Four buckets, not more. The eligible population is small -- HIP-150 + // puts candidate venues at 2.7% of earning locations, and enrollment + // needs an agreement and custodial ownership on top of that -- and each + // one gets a handful of tickets. Splitting a table this size further + // buys no pruning worth measuring and costs a file per bucket on every + // write. + // + // The count can be raised later. Iceberg keeps existing files on their + // old spec, reads span both, and `EXECUTE optimize` handles the mix. + .with_partition(PartitionDefinition::bucket( + "hotspot_pubkey", + "hotspot_pubkey_bucket", + 4, )) .build() } @@ -101,7 +120,7 @@ impl From<&VerifiedDataTransferMultiplierTicketReport> for IcebergMultiplierTick .and_then(|m| MultiplierDecimal::try_from(m).ok()), signer: ticket.signer_pubkey.to_string(), message: ticket.message.clone(), - status: verified.status.as_str_name().to_string(), + status: ticket_status_string(verified.status).to_string(), } } } diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs deleted file mode 100644 index c7b438fcf..000000000 --- a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_inventory.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! `data_transfer.multiplier_ticket_inventory` — the multiplier currently in -//! force per hotspot. -//! -//! One row per ticketed hotspot, refreshed from -//! [`super::multiplier_ticket_history`] by a periodic `MERGE`. Follows the -//! pattern `network-dbt` uses for `enabled_carriers_inventory` over -//! `enabled_carriers_history` — latest-per-key picked with a `row_number()` -//! window, merged on the key — but it is our own job issuing the SQL, not a dbt -//! model. -//! -//! **Unlike the history table it holds only valid tickets.** History records -//! every ticket including refusals, so it answers "what happened"; this answers -//! "what is in force", and a refused ticket is not in force. Note that a refusal -//! does not revoke an earlier grant: a hotspot whose newest ticket was rejected -//! keeps the last valid one, which is what excluding refusals from the source -//! achieves. -//! -//! **Not written by the Rust iceberg writer.** It is maintained in place by -//! `MERGE`, which the append-only writer cannot express — hence the table being -//! unpartitioned, and hence the DDL existing only so the merge has a target. -//! -//! Readers: the burn path, the ticket CLI, and anyone asking what a hotspot's -//! multiplier is. The burn deliberately takes whatever is current here rather -//! than reconstructing what was in force when the data moved — see -//! `mobile_packet_verifier::multiplier::trino`. - -use chrono::{DateTime, FixedOffset}; -use helium_iceberg::{FieldDefinition, TableDefinition}; -use serde::{Deserialize, Serialize}; -use trino_rust_client::Trino; - -use super::multiplier_ticket_history::{MultiplierDecimal, MULTIPLIER_PRECISION, MULTIPLIER_SCALE}; -pub use super::NAMESPACE; -pub const TABLE_NAME: &str = "multiplier_ticket_inventory"; - -#[derive(Debug, Clone, Trino, Serialize, Deserialize, PartialEq)] -pub struct IcebergMultiplierInventory { - pub hotspot_pubkey: String, - pub multiplier: MultiplierDecimal, - /// When the issuer signed the ticket that granted this multiplier — the - /// value that decided it wins. - pub signed_timestamp: DateTime, - /// When ingest received that ticket. - pub received_timestamp: DateTime, - /// When the packet verifier accepted it. - pub verified_timestamp: DateTime, - pub signer: String, - pub message: String, -} - -/// Deliberately unpartitioned: rows are updated in place by `MERGE`, so there is -/// no append dimension to partition on. -pub fn table_definition() -> helium_iceberg::Result { - TableDefinition::builder(NAMESPACE, TABLE_NAME) - .with_fields([ - FieldDefinition::required_string("hotspot_pubkey"), - FieldDefinition::required_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), - FieldDefinition::required_timestamptz("signed_timestamp"), - FieldDefinition::required_timestamptz("received_timestamp"), - FieldDefinition::required_timestamptz("verified_timestamp"), - FieldDefinition::required_string("signer"), - FieldDefinition::required_string("message"), - ]) - .build() -} - -/// The `MERGE` that brings the inventory up to date with the history. -/// -/// `valid_status` is passed in rather than written here so it stays the proto -/// enum's own `as_str_name()`, and cannot drift from what the writer stored. -/// -/// Rebuilds the source from the whole history each run rather than tracking a -/// watermark. Tickets are rare — HIP-150 expects a small number of granted -/// hotspots — so the scan is cheap and a full recompute cannot drift from the -/// history the way an incremental one can. Revisit if ticket volume ever makes -/// that untrue. -pub fn merge_statement(history_table: &str, inventory_table: &str, valid_status: &str) -> String { - format!( - r#" - MERGE INTO {inventory_table} AS t - USING ( - SELECT - hotspot_pubkey, - multiplier, - signed_timestamp, - received_timestamp, - verified_timestamp, - signer, - message - FROM ( - SELECT - *, - row_number() OVER ( - PARTITION BY hotspot_pubkey - ORDER BY signed_timestamp DESC, received_timestamp DESC - ) AS rn - FROM {history_table} - WHERE status = '{valid_status}' - AND multiplier IS NOT NULL - ) - WHERE rn = 1 - ) AS s - ON t.hotspot_pubkey = s.hotspot_pubkey - WHEN MATCHED THEN UPDATE SET - multiplier = s.multiplier, - signed_timestamp = s.signed_timestamp, - received_timestamp = s.received_timestamp, - verified_timestamp = s.verified_timestamp, - signer = s.signer, - message = s.message - WHEN NOT MATCHED THEN INSERT ( - hotspot_pubkey, - multiplier, - signed_timestamp, - received_timestamp, - verified_timestamp, - signer, - message - ) VALUES ( - s.hotspot_pubkey, - s.multiplier, - s.signed_timestamp, - s.received_timestamp, - s.verified_timestamp, - s.signer, - s.message - ) - "# - ) -} - -pub async fn get_all( - trino: &trino_rust_client::Client, -) -> anyhow::Result> { - let all = trino - .get_all(format!("SELECT * from {NAMESPACE}.{TABLE_NAME}")) - .await? - .into_vec(); - Ok(all) -} diff --git a/mobile_packet_verifier/migrations/10_data_transfer_multipliers.sql b/mobile_packet_verifier/migrations/10_data_transfer_multipliers.sql new file mode 100644 index 000000000..e75de8b47 --- /dev/null +++ b/mobile_packet_verifier/migrations/10_data_transfer_multipliers.sql @@ -0,0 +1,25 @@ +-- HIP-150: the multiplier granted to a hotspot, and when it took effect. +-- +-- One row per grant, append-only. The burn needs to ask what was in force at a +-- past instant, not just what is in force now, so it needs the history. +-- +-- Lives in Postgres because that is where data_transfer_sessions lives, and +-- pricing a session means joining the two. +-- +-- Only tickets that verified are written here. The Iceberg history table holds +-- every ticket, including refusals, for auditing. +CREATE TABLE data_transfer_multipliers ( + hotspot_pubkey TEXT NOT NULL, + multiplier NUMERIC NOT NULL, + -- The timestamp the issuer signed, which is when the grant starts. The + -- Iceberg history orders on the same value, so both tables agree on which + -- ticket is current for a hotspot. + effective_timestamp TIMESTAMPTZ NOT NULL, + -- Keyed on the grant itself, so reprocessing a ticket file rewrites the same + -- rows instead of adding to them. That is also what makes a replayed ticket + -- a no-op rather than a way to reinstate a superseded multiplier. + PRIMARY KEY (hotspot_pubkey, effective_timestamp) +); + +-- The burn looks up the latest row for a hotspot at or before a given instant. +-- The primary key's index serves that by scanning backwards. diff --git a/mobile_packet_verifier/migrations/11_data_transfer_session_buckets.sql b/mobile_packet_verifier/migrations/11_data_transfer_session_buckets.sql new file mode 100644 index 000000000..2cab8e24f --- /dev/null +++ b/mobile_packet_verifier/migrations/11_data_transfer_session_buckets.sql @@ -0,0 +1,24 @@ +-- HIP-150: accumulate sessions per file, so each row has one timestamp. +-- +-- The burn prices a row by the multiplier in force at its timestamp, which needs +-- the row to have a single timestamp rather than a range. Every report in an +-- ingest file carries that file's timestamp, so keying on it gives exactly that: +-- one row is one hotspot in one file, at one instant. +-- +-- Reports within a file still merge, so a row still collects everything a +-- hotspot did in that file. It just no longer reaches across files. +-- +-- Bytes are still converted to data credits in bulk. The burn groups these rows +-- by the multiplier it resolves for each, and converts once per group, so a +-- hotspot whose multiplier did not change is billed exactly as before. +-- +-- The cost is row count: one row per hotspot per file, for as long as the burn +-- window holds them. +-- +-- Existing rows are unique on (pub_key, payer), so widening the key cannot +-- collide. +ALTER TABLE data_transfer_sessions + DROP CONSTRAINT data_transfer_sessions_pkey; + +ALTER TABLE data_transfer_sessions + ADD PRIMARY KEY (pub_key, payer, last_timestamp); diff --git a/mobile_packet_verifier/migrations/12_pending_session_multiplier.sql b/mobile_packet_verifier/migrations/12_pending_session_multiplier.sql new file mode 100644 index 000000000..7e18ef11d --- /dev/null +++ b/mobile_packet_verifier/migrations/12_pending_session_multiplier.sql @@ -0,0 +1,22 @@ +-- HIP-150: record the multiplier an in-flight burn was priced at. +-- +-- When a burn is submitted its amount is fixed on chain. The records written +-- when the transaction confirms are built from these rows, so they have to carry +-- the multipliers the amount was computed from. The value is written as rows +-- move in and travels with them. +-- +-- Rows moving back out on a failed transaction drop it, and the next burn +-- resolves a fresh one from the ticket history. +-- +-- Rows in flight when this runs were priced before HIP-150, so the default is +-- correct for them. +ALTER TABLE pending_data_transfer_sessions + ADD COLUMN multiplier NUMERIC NOT NULL DEFAULT 1; + +-- These are the per-file rows of migration 11, moved whole, so they carry the +-- same key. +ALTER TABLE pending_data_transfer_sessions + DROP CONSTRAINT pending_data_transfer_sessions_pkey; + +ALTER TABLE pending_data_transfer_sessions + ADD PRIMARY KEY (pub_key, payer, last_timestamp); diff --git a/mobile_packet_verifier/pkg/settings-template.toml b/mobile_packet_verifier/pkg/settings-template.toml index 1880ffcdc..7ade4774a 100644 --- a/mobile_packet_verifier/pkg/settings-template.toml +++ b/mobile_packet_verifier/pkg/settings-template.toml @@ -154,12 +154,11 @@ region = "us-west-2" # HIP-150 data transfer multiplier tickets # -# Verified tickets are written to s3 and to the append-only -# data_transfer.multiplier_ticket_history table. No ticket data is stored in -# Postgres. -# A periodic job merges that history into -# data_transfer.multiplier_ticket_inventory, which is what is currently in -# force. +# Every ticket ruled on is written to s3 and to the append-only +# data_transfer.multiplier_ticket_history table, refusals included. +# +# Granted multipliers are also written to the data_transfer_multipliers table in +# Postgres, which is what the burn joins against when it prices sessions. # [multiplier] @@ -168,25 +167,18 @@ region = "us-west-2" # Checked here as well as at ingest: the two services are deployed and # configured separately, and it is this verdict that lands on the record. # -# May be empty, and is by default — no ticket can be issued until a key is -# provisioned. Empty rejects every ticket and warns at startup. +# Empty by default, which rejects every ticket and warns at startup. Nothing can +# be granted until a signer is provisioned. # # authorized_keys = "key1,key2" # How old a ticket's signed timestamp may be, measured against when ingest -# received it, before it is refused. A second check of what ingest already -# checked, configured separately so a mistake in ingest's window does not -# silently widen the replay window everywhere. +# received it, before it is refused. Configured separately from ingest's own +# window so that a mistake there does not widen the replay window everywhere. # Default: 10 minutes # # ticket_max_age = "10 minutes" -# How often to merge the history into the inventory table. Nothing in the burn -# depends on the inventory, so a slow cadence is fine. -# Default: 15 minutes -# -# inventory_refresh_interval = "15 minutes" - # How far back do we read ticket files? # Default: UNIX_EPOCH # diff --git a/mobile_packet_verifier/src/burner.rs b/mobile_packet_verifier/src/burner.rs index 43a6bb605..3fe0b8d32 100644 --- a/mobile_packet_verifier/src/burner.rs +++ b/mobile_packet_verifier/src/burner.rs @@ -101,8 +101,10 @@ where for payer_pending_burn in pending_burns::get_all_payer_burns(pool).await? { let total_dcs = payer_pending_burn.total_dcs; - let payer = payer_pending_burn.payer; - let sessions = payer_pending_burn.sessions; + let payer = payer_pending_burn.payer.clone(); + // Groups for the record, the per-file rows behind them for the move. + let sessions = payer_pending_burn.sessions(); + let rows = payer_pending_burn.rows; let payer_balance = self.solana.payer_balance(&payer).await?; @@ -118,7 +120,7 @@ where tracing::info!(%total_dcs, %payer, "Burning DC"); let txn = self.solana.make_burn_transaction(&payer, total_dcs).await?; - pending_txns::add_pending_txn(pool, &payer, total_dcs, txn.get_signature()) + pending_txns::add_pending_txn(pool, &payer, total_dcs, txn.get_signature(), &rows) .await .context("adding pending txns and moving sessions")?; match self.solana.submit_transaction(&txn).await { @@ -238,7 +240,10 @@ async fn handle_transaction_success( // Delete from the data transfer session and write out to S3 pending_burns::delete_for_payer(pool, &payer).await?; - pending_burns::decrement_metric(&payer, total_dcs); + // The payer has no rows left, so 0 is exact. Setting rather than + // subtracting the burned amount because that only lands on 0 while the two + // figures agree, and `accumulate` counts differently from the burn. + pending_burns::set_metric(&payer, 0); pending_txns::remove_pending_txn_success(pool, signature).await?; write_burned_data_transfer_sessions(sessions, valid_sessions, iceberg_writer).await?; @@ -251,10 +256,34 @@ async fn write_burned_data_transfer_sessions( file_sink: &FileSinkClient, iceberg_sink: Option<&BurnedDataTransferWriter>, ) -> anyhow::Result<()> { + // Fallible now: a session whose multiplier cannot be applied has no price, + // and a burned record without one would be a lie about what was charged. + // This runs after the burn has settled, so it cannot prevent the spend — it + // surfaces as a write failure, which is the pre-existing behaviour for any + // failure on this path. let sessions = sessions .into_iter() - .map(ValidDataTransferSession::from) - .collect::>(); + .map(ValidDataTransferSession::try_from) + .collect::>>()?; + + // DC burned, split by the multiplier that produced it. + // + // `burned` counts what the transaction charged, which is the right number + // but says nothing about where it came from. This splits the same total, so + // summing it back up gives `burned` again, and grouping by multiplier shows + // how much of the burn is coming from ticketed hotspots. + // + // Both burn paths pass through here, so this counts a burn once, after it + // has settled. The multiplier is normalized, so `1.5` and `1.50` are one + // series rather than two. + for session in &sessions { + metrics::counter!( + "burned_dc_by_multiplier", + "payer" => session.payer.to_string(), + "multiplier" => session.multiplier.to_string(), + ) + .increment(session.num_dcs); + } file_sink.write_all(sessions.clone()).await?; diff --git a/mobile_packet_verifier/src/daemon.rs b/mobile_packet_verifier/src/daemon.rs index c649b54da..617aea389 100644 --- a/mobile_packet_verifier/src/daemon.rs +++ b/mobile_packet_verifier/src/daemon.rs @@ -391,7 +391,6 @@ impl Cmd { ticket_resolver, &settings.cache, multiplier_ticket_writer, - trino_client::Client::from_settings(&settings.trino)?, ) .await?; diff --git a/mobile_packet_verifier/src/iceberg.rs b/mobile_packet_verifier/src/iceberg.rs index 1db4e9815..03bfd5bc2 100644 --- a/mobile_packet_verifier/src/iceberg.rs +++ b/mobile_packet_verifier/src/iceberg.rs @@ -5,8 +5,8 @@ use serde::Serialize; // `data_transfer` schemas live in `helium-iceberg-oracles`; re-exported here so // existing `iceberg::*` paths keep resolving. pub use helium_iceberg_oracles::data_transfer::{ - burned_session, invalid_session, multiplier_ticket_history, multiplier_ticket_inventory, - session, IcebergBurnedDataTransferSession, IcebergDataTransferSession, + burned_session, invalid_session, multiplier_ticket_history, session, + IcebergBurnedDataTransferSession, IcebergDataTransferSession, IcebergInvalidDataTransferSession, IcebergMultiplierTicket, NAMESPACE, REASON_COLUMN, }; @@ -45,14 +45,6 @@ pub async fn get_writers(settings: &helium_iceberg::Settings) -> anyhow::Result< .create_table_if_not_exists(multiplier_ticket_history::table_definition()?) .await?; - // The inventory is maintained in place by a Trino MERGE, not by a writer — - // created here only so the merge has a target. The returned writer is - // deliberately dropped. - let _ = catalog - .create_table_if_not_exists::( - multiplier_ticket_inventory::table_definition()?, - ) - .await?; Ok(Writers { session: session_writer.boxed(), invalid_session: invalid_session_writer.boxed(), diff --git a/mobile_packet_verifier/src/multiplier/db.rs b/mobile_packet_verifier/src/multiplier/db.rs new file mode 100644 index 000000000..7f497a63d --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/db.rs @@ -0,0 +1,67 @@ +//! Which multipliers have been granted, and from when. +//! +//! Verified tickets go two places. The Iceberg history in [`super::ingestor`] is +//! the audit record: every ticket, refusals included, with its verdict. This +//! table is what the burn uses. It holds grants only, and it is in Postgres so +//! that pricing a session is a join against `data_transfer_sessions` rather than +//! a lookup in another store. +//! +//! Append-only, because the burn asks what was in force when the data moved, not +//! what is in force now. See [`crate::pending_burns::get_all`]. + +use chrono::{DateTime, Utc}; +use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; +use helium_crypto::PublicKeyBinary; +use rust_decimal::Decimal; +use sqlx::{Postgres, Transaction}; + +/// One grant: which hotspot, how much, and from when. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrantedMultiplier { + pub hotspot_pubkey: PublicKeyBinary, + pub multiplier: DataTransferMultiplier, + /// The timestamp the issuer signed. See migration 10 for why this rather + /// than the time ingest received the ticket. + pub effective_timestamp: DateTime, +} + +/// Record granted multipliers. +/// +/// Runs in the caller's transaction, next to the file poller's record that the +/// file was processed, so a file is never marked done without its grants. +/// +/// Keyed on `(hotspot_pubkey, effective_timestamp)`, so reprocessing a file +/// rewrites the same rows instead of adding to them. +pub async fn save( + txn: &mut Transaction<'_, Postgres>, + granted: &[GrantedMultiplier], +) -> anyhow::Result<()> { + if granted.is_empty() { + return Ok(()); + } + + let hotspot_pubkeys: Vec = granted + .iter() + .map(|g| g.hotspot_pubkey.to_string()) + .collect(); + let multipliers: Vec = granted.iter().map(|g| g.multiplier.as_decimal()).collect(); + let effective: Vec> = granted.iter().map(|g| g.effective_timestamp).collect(); + + sqlx::query( + r#" + INSERT INTO data_transfer_multipliers (hotspot_pubkey, multiplier, effective_timestamp) + SELECT hotspot_pubkey, multiplier, effective_timestamp + FROM UNNEST($1::text[], $2::numeric[], $3::timestamptz[]) + AS t(hotspot_pubkey, multiplier, effective_timestamp) + ON CONFLICT (hotspot_pubkey, effective_timestamp) DO UPDATE SET + multiplier = EXCLUDED.multiplier + "#, + ) + .bind(hotspot_pubkeys) + .bind(multipliers) + .bind(effective) + .execute(&mut **txn) + .await?; + + Ok(()) +} diff --git a/mobile_packet_verifier/src/multiplier/ingestor.rs b/mobile_packet_verifier/src/multiplier/ingestor.rs index c8636d395..5ac1d3e30 100644 --- a/mobile_packet_verifier/src/multiplier/ingestor.rs +++ b/mobile_packet_verifier/src/multiplier/ingestor.rs @@ -1,20 +1,23 @@ -//! Reads ticket files from s3, rules on each ticket, records the verdict. +//! Reads ticket files from s3, decides whether each ticket is valid, and +//! records the result. //! -//! Every ticket produces a verified report — accepted or rejected — so the -//! public record shows refusals as well as grants. Both also land in the -//! append-only history table, tagged with the verdict. +//! Every ticket produces a verified report, accepted or rejected, so refusals +//! are on the record too. Every ticket also gets a row in the Iceberg history +//! table, tagged with the verdict. //! -//! Nothing here holds mutable state: this module only appends. What is -//! *currently* in force is derived from those rows separately, by -//! [`super::inventory`]. +//! Accepted tickets also go to Postgres, via [`super::db`], which is what the +//! burn joins against. Rejected ones do not, because a refusal grants nothing. +//! +//! Nothing here holds state between files. It reads, decides, and appends. use std::{ops::ControlFlow, time::Duration}; use chrono::Utc; use file_store::file_info_poller::FileInfoStream; use file_store_oracles::mobile::data_transfer_multiplier::{ - proto::VerifiedDataTransferMultiplierTicketReportV1, DataTransferMultiplier, - DataTransferMultiplierTicketReport, VerifiedDataTransferMultiplierTicketReport, + proto::VerifiedDataTransferMultiplierTicketReportV1, ticket_status_string, + DataTransferMultiplier, DataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketReport, VerifiedDataTransferMultiplierTicketStatus as Status, MAX_CLOCK_DRIFT, }; use futures::StreamExt; @@ -27,11 +30,15 @@ use crate::{ iceberg::{IcebergMultiplierTicket, MultiplierTicketWriter}, }; -use super::{TicketSigners, VerifiedTicketSink}; +use super::{ + db::{self, GrantedMultiplier}, + TicketSigners, VerifiedTicketSink, +}; pub struct TicketIngestor { - /// Only for the file poller's own "which files have I processed" bookkeeping - /// — no ticket data is stored in Postgres. + /// Holds both the file poller's bookkeeping and the granted multipliers. + /// They are written in one transaction, so a file is never marked processed + /// without its grants. pool: PgPool, report_rx: Receiver>, verified_sink: VerifiedTicketSink, @@ -88,29 +95,36 @@ impl TicketIngestor { let file = file_info_stream.file_info.key.clone(); tracing::info!(%file, "processing data transfer multiplier tickets"); - // The transaction records only that this file was processed; the - // tickets themselves go to s3 and Iceberg. + // One transaction covers both "this file was processed" and the grants + // it produced, so a crash cannot leave the first without the second. let mut txn = self.pool.begin().await?; let mut stream = file_info_stream.into_stream(&mut txn).await?; let mut history = Vec::new(); + let mut granted = Vec::new(); while let Some(report) = stream.next().await { let verified = self.verify(report).await?; - // Every ticket gets a history row, refusals included — a value the - // column cannot hold lands as NULL, with the status saying why. + // Refusals get a history row too. A multiplier the column cannot + // hold is stored as NULL, and the status says why. if self.history_writer.is_some() { history.push(IcebergMultiplierTicket::from(&verified)); } - let status = verified.status.as_str_name(); + if let Some(grant) = granted_multiplier(&verified) { + granted.push(grant); + } + + let status = ticket_status_string(verified.status); let proto = VerifiedDataTransferMultiplierTicketReportV1::from(verified); self.verified_sink .write(proto, &[("status", status)]) .await?; } + db::save(&mut txn, &granted).await?; + // Keyed on the file, so reprocessing one cannot duplicate its rows. if let Some(writer) = self.history_writer.as_ref() { writer.write_idempotent(&file, history).await?; @@ -139,7 +153,7 @@ impl TicketIngestor { if !verified.is_valid() { tracing::warn!( hotspot_pubkey = %verified.hotspot_pubkey(), - status = status.as_str_name(), + status = ticket_status_string(status), "rejecting data transfer multiplier ticket" ); } @@ -148,6 +162,39 @@ impl TicketIngestor { } } +/// The grant a verified ticket makes, or `None` if it makes none. +/// +/// Only tickets that passed [`ticket_status`] grant anything. That check is what +/// proves the multiplier is present and in range, so this is the one place the +/// `Option` is unwrapped without looking again. +/// +/// A valid ticket whose multiplier will not convert is dropped rather than +/// guessed at. That needs `ticket_status` and `DataTransferMultiplier` to +/// disagree, which they should not, so it is logged as an error. +pub fn granted_multiplier( + verified: &VerifiedDataTransferMultiplierTicketReport, +) -> Option { + if !verified.is_valid() { + return None; + } + + let ticket = &verified.report.report; + match ticket.multiplier.map(DataTransferMultiplier::new) { + Some(Ok(multiplier)) => Some(GrantedMultiplier { + hotspot_pubkey: ticket.hotspot_pubkey.clone(), + multiplier, + effective_timestamp: ticket.timestamp, + }), + _ => { + tracing::error!( + hotspot_pubkey = %ticket.hotspot_pubkey, + "ticket passed verification but its multiplier will not convert" + ); + None + } + } +} + /// The verdict on one ticket. /// /// A free function rather than a method: this is the rule that decides whether a diff --git a/mobile_packet_verifier/src/multiplier/inventory.rs b/mobile_packet_verifier/src/multiplier/inventory.rs deleted file mode 100644 index 80657161c..000000000 --- a/mobile_packet_verifier/src/multiplier/inventory.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Keeps `data_transfer.multiplier_ticket_inventory` up to date. -//! -//! The history table is the log; the inventory is what is currently in force. -//! This periodically merges the second out of the first, following the pattern -//! `network-dbt` uses for its `*_inventory` marts — latest-per-key by a -//! `row_number()` window, merged on the key — with the SQL issued by us rather -//! than by dbt. -//! -//! It runs as a `MERGE` through Trino rather than through the Rust iceberg -//! writer, because the writer is append-only and cannot update a row in place. -//! -//! **The burn reads this table**, so a refresh that stops running freezes the -//! multipliers burns apply — at the last merged value, not at 1. Tickets keep -//! landing in the history either way, so a resumed refresh catches up without -//! loss; the exposure is stale multipliers in the meantime, not lost grants. - -use std::time::Duration; - -use file_store_oracles::mobile::data_transfer_multiplier::VerifiedDataTransferMultiplierTicketStatus; -use helium_iceberg_oracles::data_transfer::{ - multiplier_ticket_history, multiplier_ticket_inventory, -}; -use task_manager::Periodic; - -pub struct InventoryRefresher { - trino: trino_client::Client, - interval: Duration, - history_table: String, - inventory_table: String, -} - -impl InventoryRefresher { - pub fn new(trino: trino_client::Client, interval: Duration) -> Self { - Self::new_with_tables( - trino, - interval, - format!( - "{}.{}", - multiplier_ticket_history::NAMESPACE, - multiplier_ticket_history::TABLE_NAME - ), - format!( - "{}.{}", - multiplier_ticket_inventory::NAMESPACE, - multiplier_ticket_inventory::TABLE_NAME - ), - ) - } - - /// Like [`new`](Self::new), with explicit table names so tests can point at - /// a per-test catalog. - pub fn new_with_tables( - trino: trino_client::Client, - interval: Duration, - history_table: String, - inventory_table: String, - ) -> Self { - Self { - trino, - interval, - history_table, - inventory_table, - } - } - - /// Run one refresh. Public so a test can drive it without a scheduler. - pub async fn refresh(&self) -> anyhow::Result<()> { - let sql = multiplier_ticket_inventory::merge_statement( - &self.history_table, - &self.inventory_table, - VerifiedDataTransferMultiplierTicketStatus::Valid.as_str_name(), - ); - - self.trino.execute_raw(sql).await?; - Ok(()) - } -} - -impl Periodic for InventoryRefresher { - type Error = anyhow::Error; - - fn interval(&self) -> Duration { - self.interval - } - - async fn tick(&mut self) -> anyhow::Result<()> { - self.refresh().await?; - tracing::info!("refreshed data transfer multiplier inventory"); - Ok(()) - } -} diff --git a/mobile_packet_verifier/src/multiplier/mod.rs b/mobile_packet_verifier/src/multiplier/mod.rs index aeac35604..a698ec427 100644 --- a/mobile_packet_verifier/src/multiplier/mod.rs +++ b/mobile_packet_verifier/src/multiplier/mod.rs @@ -1,35 +1,27 @@ //! HIP-150 data transfer multiplier tickets. //! -//! A ticket grants one on-chain hotspot a multiplier on the data credits derived -//! from its rewardable bytes. Ingest verifies who sent a ticket and when, then -//! writes it to s3 verbatim; this module reads those files, rules on each -//! ticket, and keeps the result. +//! A ticket grants one hotspot a multiplier on the data credits its rewardable +//! bytes convert to. Ingest checks who sent it and when, then writes it to s3 +//! unchanged. This module reads those files and rules on each ticket. //! -//! Deciding validity *here* rather than at ingest is deliberate. HIP-150 wants -//! every multiplier in force to be externally auditable, and a ticket rejected -//! at the gRPC boundary leaves no record — rejected here, it is written to a -//! verified report alongside the accepted ones. +//! Validity is decided here rather than at ingest so that refusals leave a +//! record. HIP-150 wants every multiplier in force to be auditable, and a ticket +//! turned away at the gRPC boundary leaves nothing behind. //! -//! Tickets are not stored in Postgres. A verified ticket is written to an s3 -//! report and appended to `data_transfer.multiplier_ticket_history`. +//! A ruled-on ticket goes to two places: //! -//! Current state lives in `data_transfer.multiplier_ticket_inventory`, which -//! [`inventory`] keeps merged out of that history on a schedule. It follows the -//! shape `network-dbt` uses for `enabled_carriers_inventory` over -//! `enabled_carriers_history`, but the SQL is ours and the job runs here. -//! -//! The burn will read the inventory — whatever is current when a session -//! arrives, not what was in force when the data moved; see [`trino`]. Nothing -//! reads either table yet; no multiplier is applied until the burn is wired up. +//! * `data_transfer.multiplier_ticket_history` in Iceberg, plus an s3 report. +//! Every ticket, refusals included. This is the audit record. +//! * `data_transfer_multipliers` in Postgres, grants only. This is what the burn +//! joins against, and it is in Postgres because `data_transfer_sessions` is. +//! See [`db`]. -use std::{collections::HashMap, time::Duration}; +use std::time::Duration; use chrono::{DateTime, Utc}; use file_store::{file_sink::FileSinkClient, file_upload::FileUpload}; use file_store_oracles::{ - mobile::data_transfer_multiplier::{ - proto::VerifiedDataTransferMultiplierTicketReportV1, DataTransferMultiplier, - }, + mobile::data_transfer_multiplier::proto::VerifiedDataTransferMultiplierTicketReportV1, traits::{FileSinkCommitStrategy, FileSinkRollTime, FileSinkWriteExt}, FileType, }; @@ -41,11 +33,8 @@ use task_manager::{ManagedTask, TaskManager}; use crate::gateway::GatewayResolver; +pub mod db; pub mod ingestor; -pub mod inventory; -pub mod trino; - -pub use trino::get_multipliers; #[derive(Debug, Deserialize, Serialize)] pub struct MultiplierSettings { @@ -56,33 +45,21 @@ pub struct MultiplierSettings { pub start_after: DateTime, /// Public keys authorized to issue tickets, comma-separated b58. /// - /// Checked again here even though ingest already checked it: ingest and this - /// verifier are separately deployed and separately configured, and it is - /// this verdict that lands on the record. + /// Checked again here even though ingest already checked it. The two are + /// deployed and configured separately, and it is this verdict that lands on + /// the record. /// - /// May be empty, and is by default — no ticket can be issued until a key is - /// provisioned. Empty rejects every ticket. + /// Empty by default, which rejects every ticket. Nothing can be granted + /// until a signer is provisioned. #[serde(default)] pub authorized_keys: String, - /// How often the inventory table is merged out of the history. - /// - /// This is burn-visible: a ticket takes effect once the next merge has run, - /// so this interval is the lag between verifying a grant and it being worth - /// anything. It also bounds how stale a burn's multipliers can be if the - /// merge starts failing. - #[serde( - with = "humantime_serde", - default = "default_inventory_refresh_interval" - )] - pub inventory_refresh_interval: Duration, /// How old a ticket's signed timestamp may be, measured against the time /// ingest stamped on it, before it is refused. /// - /// A second check of what ingest already checked, configured separately, so - /// a mistake in ingest's window does not silently widen the replay window - /// everywhere. It is *not* an independent defence: both timestamps in the - /// comparison come from the same file, so it cannot help against anyone able - /// to write that file. + /// Configured separately from ingest's own window so that a mistake there + /// does not widen the replay window everywhere. It is not an independent + /// defence: both timestamps come from the same file, so it does not help + /// against anyone who can write that file. #[serde(with = "humantime_serde", default = "default_ticket_max_age")] pub ticket_max_age: Duration, } @@ -91,10 +68,6 @@ fn default_ingest_start_after() -> DateTime { DateTime::UNIX_EPOCH } -fn default_inventory_refresh_interval() -> Duration { - humantime::parse_duration("15 minutes").unwrap() -} - fn default_ticket_max_age() -> Duration { humantime::parse_duration("10 minutes").unwrap() } @@ -119,43 +92,6 @@ impl FromIterator for TicketSigners { } } -/// The multiplier in force per hotspot at a point in time. -/// -/// Only ticketed hotspots appear. [`Multipliers::get`] is the single place the -/// "no ticket means 1" rule is written down — every other caller just asks. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Multipliers(HashMap); - -impl Multipliers { - pub fn get(&self, hotspot_pubkey: &PublicKeyBinary) -> DataTransferMultiplier { - self.0 - .get(hotspot_pubkey) - .copied() - .unwrap_or(DataTransferMultiplier::DEFAULT) - } - - pub fn insert(&mut self, hotspot_pubkey: PublicKeyBinary, multiplier: DataTransferMultiplier) { - self.0.insert(hotspot_pubkey, multiplier); - } - - pub fn len(&self) -> usize { - self.0.len() - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl FromIterator<(PublicKeyBinary, DataTransferMultiplier)> for Multipliers { - fn from_iter>( - iter: I, - ) -> Self { - Self(iter.into_iter().collect()) - } -} - -#[allow(clippy::too_many_arguments)] pub async fn create_managed_task( pool: PgPool, file_upload: FileUpload, @@ -164,7 +100,6 @@ pub async fn create_managed_task( resolver: GatewayResolver, store_base_path: &std::path::Path, history_writer: Option, - trino: trino_client::Client, ) -> anyhow::Result { if signers.is_empty() { tracing::warn!( @@ -200,44 +135,12 @@ pub async fn create_managed_task( history_writer, ); - let inventory = inventory::InventoryRefresher::new(trino, settings.inventory_refresh_interval); - Ok(TaskManager::builder() .add_task(report_server) .add_task(verified_sink_server) .add_task(task_manager::channel_consumer(ingestor)) - .add_task(task_manager::periodic(inventory)) .build()) } /// Type alias so the sink type is spelled once. pub type VerifiedTicketSink = FileSinkClient; - -#[cfg(test)] -mod tests { - use super::*; - - fn key(byte: u8) -> PublicKeyBinary { - PublicKeyBinary::from(vec![byte]) - } - - #[test] - fn unknown_hotspot_gets_the_default_multiplier() { - let multipliers = Multipliers::default(); - assert_eq!( - multipliers.get(&key(1)), - DataTransferMultiplier::DEFAULT, - "a hotspot with no ticket must be unmultiplied" - ); - } - - #[test] - fn ticketed_hotspot_gets_its_multiplier() { - let one_and_a_half = DataTransferMultiplier::new(rust_decimal::dec!(1.5)).expect("valid"); - let multipliers = Multipliers::from_iter([(key(1), one_and_a_half)]); - - assert_eq!(multipliers.get(&key(1)), one_and_a_half); - // ...and its neighbour is unaffected. - assert_eq!(multipliers.get(&key(2)), DataTransferMultiplier::DEFAULT); - } -} diff --git a/mobile_packet_verifier/src/multiplier/trino.rs b/mobile_packet_verifier/src/multiplier/trino.rs deleted file mode 100644 index 95f92d399..000000000 --- a/mobile_packet_verifier/src/multiplier/trino.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Reading the multiplier currently in force per hotspot. -//! -//! **Nothing calls this yet.** It is the read side the burn path will use once -//! multipliers are applied to data credits; until then a ticket is recorded and -//! affects nothing. -//! -//! # Whatever is current when the session arrives -//! -//! A session is multiplied by whatever is in force when it is accumulated — not -//! by what was in force at the instant the data moved. Reconstructing the -//! latter would mean a point-in-time query against the ticket history, bounded -//! by the file's timestamp. -//! -//! That precision would be false. Sessions reach us batched behind an ingest -//! roll, tickets arrive on their own schedule, and the burn only runs hourly, so -//! the boundary between "before the ticket" and "after" is already fuzzy by -//! minutes. Paying for an exact answer to a question whose inputs are -//! approximate buys nothing, and it costs a scan of the whole history per file. -//! -//! So this reads the inventory table [`super::inventory`] keeps merged from the -//! history — one row per hotspot, already latest-per-key. No window function, -//! no timestamp bound. -//! -//! # What that trades away -//! -//! * **The inventory's refresh interval becomes visible in burns.** A ticket -//! takes effect once the next merge has run, not the moment it is verified. -//! Tune `inventory_refresh_interval` against how promptly a grant should land. -//! * **Replaying a backlog applies today's multipliers to old data.** Files -//! reprocessed long after the fact are multiplied by what is in force now, not -//! by what was in force then. Normal operation processes files promptly, so -//! this shows up only after an outage or a deliberate replay. -//! -//! Both follow from the same decision, taken knowingly: the timing was never -//! exact, and pretending otherwise would cost more than it is worth. - -use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; -use helium_crypto::PublicKeyBinary; -use helium_iceberg_oracles::data_transfer::multiplier_ticket_inventory::{NAMESPACE, TABLE_NAME}; -use serde::{Deserialize, Serialize}; -use trino_rust_client::Trino; - -use super::Multipliers; - -/// The multiplier in force per hotspot, as of the last inventory refresh. -/// -/// Hotspots with no ticket are absent from the result; [`Multipliers::get`] -/// resolves those to [`DataTransferMultiplier::DEFAULT`]. -pub async fn get_multipliers(trino: &trino_client::Client) -> anyhow::Result { - get_multipliers_from(trino, &format!("{NAMESPACE}.{TABLE_NAME}")).await -} - -/// Like [`get_multipliers`], with an explicit table name. -/// -/// Tests point this at a per-test catalog, the same way -/// [`crate::gateway::GatewayResolver::new_with_inventory_table`] does. -pub async fn get_multipliers_from( - trino: &trino_client::Client, - table: &str, -) -> anyhow::Result { - #[derive(Trino, Serialize, Deserialize)] - struct Row { - hotspot_pubkey: String, - multiplier: String, - } - - // The inventory is already one row per hotspot, so this is a plain read. - // - // `cast(... as varchar)` rather than reading a decimal: the value is - // re-parsed into a validated `DataTransferMultiplier` below, and a decimal - // string is the exact representation both sides already agree on. - let stmt = trino_client::Statement::new(format!( - "SELECT hotspot_pubkey, cast(multiplier AS varchar) AS multiplier FROM {table}" - )) - .typed::(); - - let rows = trino.get_all(stmt).await?; - - let mut multipliers = Multipliers::default(); - for row in rows { - let hotspot_pubkey: PublicKeyBinary = match row.hotspot_pubkey.parse() { - Ok(pubkey) => pubkey, - Err(err) => { - tracing::warn!(pub_key = %row.hotspot_pubkey, ?err, "skipping unparseable hotspot"); - continue; - } - }; - - // Values were validated before being written, so a value that no longer - // parses means the accepted range was narrowed since. Skip it rather - // than fail the file: the hotspot falls back to the default, which is - // the conservative direction. - match row - .multiplier - .parse() - .map_err(anyhow::Error::from) - .and_then(|value| -> anyhow::Result<_> { Ok(DataTransferMultiplier::new(value)?) }) - { - Ok(multiplier) => multipliers.insert(hotspot_pubkey, multiplier), - Err(err) => tracing::warn!( - %hotspot_pubkey, multiplier = %row.multiplier, ?err, - "stored multiplier is no longer valid, treating hotspot as unmultiplied" - ), - } - } - - Ok(multipliers) -} diff --git a/mobile_packet_verifier/src/pending_burns.rs b/mobile_packet_verifier/src/pending_burns.rs index 6ef15c1be..f1510d0cb 100644 --- a/mobile_packet_verifier/src/pending_burns.rs +++ b/mobile_packet_verifier/src/pending_burns.rs @@ -7,6 +7,10 @@ use file_store_oracles::{ use helium_crypto::PublicKeyBinary; use sqlx::{prelude::FromRow, Pool, Postgres, Row, Transaction}; +use anyhow::Context; +use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; +use rust_decimal::Decimal; + use crate::bytes_to_dc; const METRIC_NAME: &str = "pending_dc_burn"; @@ -18,13 +22,43 @@ pub struct DataTransferSession { uploaded_bytes: i64, downloaded_bytes: i64, rewardable_bytes: i64, + /// HIP-150: the multiplier this row's bytes are priced at. + /// + /// There is no such column on `data_transfer_sessions`. [`get_all`] works it + /// out by joining the ticket history at the row's own timestamp. + /// + /// There is one on `pending_data_transfer_sessions`, because a row that has + /// reached that table has a price already fixed on chain. + /// + /// A plain `Decimal` because that is what Postgres returns. + /// [`DataTransferSession::dc_to_burn`] validates it before use. + multiplier: Decimal, first_timestamp: DateTime, last_timestamp: DateTime, } impl DataTransferSession { - pub fn dc_to_burn(&self) -> u64 { - bytes_to_dc(self.rewardable_bytes as u64) + /// Data credits to burn: bytes converted once, then multiplied. + /// + /// Call this on a group, not a raw row. `bytes_to_dc` rounds up off a + /// one-DC floor, so the number of times it runs affects what a payer pays. + /// [`group_by_multiplier`] decides that; this is just the arithmetic. + /// + /// It fails if the multiplier is outside the accepted range, or if the + /// multiplication overflows. Neither should happen: the first needs the + /// range to have been narrowed since the ticket was recorded, the second a + /// DC count near `u64::MAX`. Both abort the burn from + /// [`get_all_payer_burns`], before any transaction exists, so nothing is + /// spent and the rows wait for the next attempt. + pub fn dc_to_burn(&self) -> anyhow::Result { + let multiplier = DataTransferMultiplier::new(self.multiplier).with_context(|| { + format!( + "multiplier {} for {} is not acceptable", + self.multiplier, self.pub_key + ) + })?; + + Ok(multiplier.apply(bytes_to_dc(self.rewardable_bytes as u64))?) } pub fn from_req(req: &DataTransferSessionReq, last_timestamp: DateTime) -> Self { @@ -34,35 +68,74 @@ impl DataTransferSession { uploaded_bytes: req.data_transfer_usage.upload_bytes as i64, downloaded_bytes: req.data_transfer_usage.download_bytes as i64, rewardable_bytes: req.rewardable_bytes as i64, + // Unpriced. Nothing writes this to `data_transfer_sessions` -- + // there is no column for it -- and the burn resolves it on read. + multiplier: DataTransferMultiplier::DEFAULT.as_decimal(), // timestamps are the same upon ingest first_timestamp: last_timestamp, last_timestamp, } } + + pub fn pub_key(&self) -> &PublicKeyBinary { + &self.pub_key + } + + pub fn multiplier(&self) -> Decimal { + self.multiplier + } + + pub fn last_timestamp(&self) -> DateTime { + self.last_timestamp + } } -impl From for ValidDataTransferSession { - fn from(session: DataTransferSession) -> Self { - let num_dcs = session.dc_to_burn(); +/// Fallible because [`DataTransferSession::dc_to_burn`] is. The burned record +/// states what was charged, so it cannot be written without a price. +impl TryFrom for ValidDataTransferSession { + type Error = anyhow::Error; - ValidDataTransferSession { + fn try_from(session: DataTransferSession) -> anyhow::Result { + let num_dcs = session.dc_to_burn()?; + let multiplier = DataTransferMultiplier::new(session.multiplier)?; + + Ok(ValidDataTransferSession { pub_key: session.pub_key, payer: session.payer, upload_bytes: session.uploaded_bytes as u64, download_bytes: session.downloaded_bytes as u64, rewardable_bytes: session.rewardable_bytes as u64, num_dcs, + multiplier, first_timestamp: session.first_timestamp, last_timestamp: session.last_timestamp, burn_timestamp: Utc::now(), - } + }) } } pub struct PendingPayerBurn { pub payer: PublicKeyBinary, + /// What the payer is charged: the sum over [`Self::sessions`]. pub total_dcs: u64, - pub sessions: Vec, + /// The per-file rows, priced. + /// + /// The burn moves these into `pending_data_transfer_sessions` rather than + /// the groups. A group spans several files, so putting one back as a single + /// row would lose those boundaries, and a retry would then price all of it + /// at one rate. + pub rows: Vec, +} + +impl PendingPayerBurn { + /// One session per `(hotspot, multiplier)`. Each becomes one burned record, + /// so a hotspot billed at two rates produces two records. + /// + /// Derived rather than stored, so it cannot drift from [`Self::rows`] or + /// from the `total_dcs` computed off it. + pub fn sessions(&self) -> Vec { + group_by_multiplier(&self.rows) + } } pub async fn initialize(conn: &Pool) -> anyhow::Result<()> { @@ -86,56 +159,132 @@ pub async fn initialize(conn: &Pool) -> anyhow::Result<()> { Ok(()) } +/// Every accumulated row, with the multiplier that applies to it. +/// +/// The multiplier is looked up here rather than stored on the row. A row is +/// written when its file is processed; the ticket that should apply to it may +/// not have been processed yet. By burn time we know more. +/// +/// Each row covers one hotspot in one file, and all reports in a file share the +/// file's timestamp. So `last_timestamp` is a single point in time, not a range. +/// +/// The join takes the newest grant effective at or before that point. Later +/// grants don't apply. A hotspot with no grant matches nothing and gets 1. +/// +/// These are the raw rows. [`get_all_payer_burns`] groups them before converting +/// anything to data credits. pub async fn get_all(conn: &Pool) -> anyhow::Result> { - sqlx::query_as("SELECT * FROM data_transfer_sessions") - .fetch_all(conn) - .await - .map_err(anyhow::Error::from) + sqlx::query_as( + r#" + SELECT + dts.pub_key, + dts.payer, + dts.uploaded_bytes, + dts.downloaded_bytes, + dts.rewardable_bytes, + COALESCE(m.multiplier, 1) AS multiplier, + dts.first_timestamp, + dts.last_timestamp + FROM data_transfer_sessions dts + LEFT JOIN LATERAL ( + SELECT dtm.multiplier + FROM data_transfer_multipliers dtm + WHERE dtm.hotspot_pubkey = dts.pub_key + AND dtm.effective_timestamp <= dts.last_timestamp + ORDER BY dtm.effective_timestamp DESC + LIMIT 1 + ) m ON TRUE + "#, + ) + .fetch_all(conn) + .await + .map_err(anyhow::Error::from) +} + +/// Collapse priced rows into one session per `(hotspot, multiplier)`. +/// +/// This has to happen before the data credit conversion. `bytes_to_dc` carries +/// two penalties -- a full DC for anything under 100KB, and a round up to the +/// next whole DC above that -- and you pay them once per conversion. Ten +/// 10,000-byte sessions summed are 100,000 bytes, so 1 DC. Converted one at a +/// time they are 10, because each one hits the floor by itself. +/// +/// It matters to the deployer too. `floor(1 DC x 1.5)` is 1, so multiplying +/// per session would erase any multiplier below 2x on a single-DC session. +/// Grouping lets the fractions add up to whole data credits before the floor +/// lands on them. +/// +/// Grouping first means bytes billed at the same rate are summed and converted +/// once, the way they were before HIP-150. Bytes billed at different rates end +/// up in different groups and convert separately, which is unavoidable: one +/// conversion cannot produce two rates. +/// +/// So an unticketed hotspot rounds exactly as it always did, and a hotspot whose +/// multiplier changed mid-window pays one extra rounding. +pub(crate) fn group_by_multiplier(rows: &[DataTransferSession]) -> Vec { + let mut grouped: HashMap<_, DataTransferSession> = HashMap::new(); + + for row in rows { + grouped + .entry((row.pub_key.clone(), row.payer.clone(), row.multiplier)) + .and_modify(|existing| merge_session(existing, row)) + .or_insert_with(|| row.clone()); + } + + grouped.into_values().collect() } +/// What each payer owes, and the rows behind it. +/// +/// Rows are priced one at a time, each at its own instant, then grouped by the +/// multiplier that came back, then converted to data credits once per group. pub async fn get_all_payer_burns(conn: &Pool) -> anyhow::Result> { - let pending_payer_burns = get_all(conn) - .await? - .into_iter() - .fold( - HashMap::::new(), - |mut map, session| { - let dc_to_burn = session.dc_to_burn(); - - match map.get_mut(&session.payer) { - Some(pending_payer_burn) => { - pending_payer_burn.total_dcs += dc_to_burn; - pending_payer_burn.sessions.push(session); - } - None => { - map.insert( - session.payer.clone(), - PendingPayerBurn { - payer: session.payer.clone(), - total_dcs: dc_to_burn, - sessions: vec![session], - }, - ); - } - } - - map - }, - ) - .into_values() - .collect(); + let mut by_payer = HashMap::>::new(); + for row in get_all(conn).await? { + by_payer.entry(row.payer.clone()).or_default().push(row); + } + + let mut burns = Vec::with_capacity(by_payer.len()); + for (payer, rows) in by_payer { + // Fail the whole burn rather than skip a group. A session we cannot + // price is not one to leave quietly out of a payer's bill. Nothing has + // been spent at this point. + let mut total_dcs = 0u64; + for session in group_by_multiplier(&rows) { + total_dcs += session.dc_to_burn()?; + } + + // Set the gauge to what this payer actually owes. This is the first + // point where that is known: `accumulate` increments as bytes arrive, + // but it has no multiplier to apply and it groups by payer rather than + // by hotspot, so its running total is only an estimate. + // + // This runs before the balance check in `Burner::burn`, so a payer that + // cannot pay keeps a gauge showing its real debt, growing every cycle it + // fails to burn. That is the point of the metric. + set_metric(&payer, total_dcs); - Ok(pending_payer_burns) + burns.push(PendingPayerBurn { + payer, + total_dcs, + rows, + }); + } + + Ok(burns) } pub async fn save_data_transfer_sessions( txn: &mut Transaction<'_, Postgres>, data_transfer_session: &[DataTransferSession], ) -> anyhow::Result<()> { + // Keyed on the timestamp as well as the pair, matching the table. Reports + // from one file share a timestamp and merge; reports from different files + // stay in separate rows, so each row belongs to one instant. let mut merged = HashMap::new(); for session in data_transfer_session { merged - .entry((&session.pub_key, &session.payer)) + .entry((&session.pub_key, &session.payer, session.last_timestamp)) .and_modify(|existing| merge_session(existing, session)) .or_insert_with(|| session.clone()); } @@ -149,6 +298,9 @@ pub async fn save_data_transfer_sessions( let first_ts = collect_field(&sessions, |s| s.first_timestamp); let last_ts = collect_field(&sessions, |s| s.last_timestamp); + // Any multiplier on these rows is dropped. They have either never been + // priced, or are coming back from a failed burn. Either way the next burn + // prices them again, against a ticket history that may have grown since. sqlx::query( r#" INSERT INTO data_transfer_sessions @@ -166,12 +318,11 @@ pub async fn save_data_transfer_sessions( ) AS t( pub_key, payer, uploaded_bytes, downloaded_bytes, rewardable_bytes, first_timestamp, last_timestamp ) - ON CONFLICT (pub_key, payer) DO UPDATE SET + ON CONFLICT (pub_key, payer, last_timestamp) DO UPDATE SET uploaded_bytes = data_transfer_sessions.uploaded_bytes + EXCLUDED.uploaded_bytes, downloaded_bytes = data_transfer_sessions.downloaded_bytes + EXCLUDED.downloaded_bytes, rewardable_bytes = data_transfer_sessions.rewardable_bytes + EXCLUDED.rewardable_bytes, - first_timestamp = LEAST(data_transfer_sessions.first_timestamp, EXCLUDED.first_timestamp), - last_timestamp = GREATEST(data_transfer_sessions.last_timestamp, EXCLUDED.last_timestamp) + first_timestamp = LEAST(data_transfer_sessions.first_timestamp, EXCLUDED.first_timestamp) "# ) .bind(pub_keys) @@ -211,14 +362,10 @@ pub async fn delete_for_payer( Ok(()) } -fn set_metric(payer: &PublicKeyBinary, value: u64) { +pub fn set_metric(payer: &PublicKeyBinary, value: u64) { metrics::gauge!(METRIC_NAME, "payer" => payer.to_string()).set(value as f64); } pub fn increment_metric(payer: &PublicKeyBinary, value: u64) { metrics::gauge!(METRIC_NAME, "payer" => payer.to_string()).increment(value as f64); } - -pub fn decrement_metric(payer: &PublicKeyBinary, value: u64) { - metrics::gauge!(METRIC_NAME, "payer" => payer.to_string()).decrement(value as f64); -} diff --git a/mobile_packet_verifier/src/pending_txns.rs b/mobile_packet_verifier/src/pending_txns.rs index 72a520555..413c12823 100644 --- a/mobile_packet_verifier/src/pending_txns.rs +++ b/mobile_packet_verifier/src/pending_txns.rs @@ -30,11 +30,18 @@ impl FromRow<'_, PgRow> for PendingTxn { } } +/// The sessions behind an in-flight burn, grouped for the burned record. +/// +/// Grouped on the multiplier stored on each row when the burn was submitted, +/// not on a fresh read of the ticket history. The amount is already fixed on +/// chain, so the records have to reproduce the groups it came from. Asking the +/// history again could give a different answer if a backdated ticket arrived +/// since, and the record would then disagree with the charge. pub async fn get_pending_data_sessions_for_signature( conn: &PgPool, signature: &Signature, ) -> anyhow::Result> { - let pending = sqlx::query_as( + let pending: Vec = sqlx::query_as( r#" SELECT * FROM pending_data_transfer_sessions WHERE signature = $1 @@ -43,7 +50,8 @@ pub async fn get_pending_data_sessions_for_signature( .bind(signature.to_string()) .fetch_all(conn) .await?; - Ok(pending) + + Ok(pending_burns::group_by_multiplier(&pending)) } pub async fn pending_txn_count(conn: &PgPool) -> anyhow::Result { @@ -53,13 +61,20 @@ pub async fn pending_txn_count(conn: &PgPool) -> anyhow::Result { Ok(count as usize) } +/// `priced` is the per-file rows as [`pending_burns::get_all_payer_burns`] +/// priced them, not the groups it billed, and `amount` is their total. +/// +/// They are passed in rather than looked up again so that the multiplier a row +/// moves with is the one its share of `amount` came from, even if a ticket +/// arrives between the pricing and this call. pub async fn add_pending_txn( conn: &PgPool, payer: &PublicKeyBinary, amount: u64, signature: &Signature, + priced: &[DataTransferSession], ) -> Result<(), sqlx::Error> { - do_add_pending_txn(conn, payer, amount, signature, Utc::now()).await + do_add_pending_txn(conn, payer, amount, signature, priced, Utc::now()).await } pub async fn do_add_pending_txn( @@ -67,6 +82,7 @@ pub async fn do_add_pending_txn( payer: &PublicKeyBinary, amount: u64, signature: &Signature, + priced: &[DataTransferSession], time_of_submission: DateTime, ) -> Result<(), sqlx::Error> { let mut txn = conn.begin().await?; @@ -83,9 +99,21 @@ pub async fn do_add_pending_txn( .execute(&mut *txn) .await?; + // The multiplier comes from `priced`, not from the row, because + // `data_transfer_sessions` has no such column. + // + // Matched on (pub_key, last_timestamp), which is what identifies a row: one + // hotspot in one file. + // + // The fallback to 1 covers a row written between the pricing read and this + // call. The daemon does not ingest while a burn is running, so that should + // not happen; see the note in PLAN.md for what would go wrong if it did. sqlx::query( r#" - WITH moved_rows AS ( + WITH priced AS ( + SELECT * FROM UNNEST($3::text[], $4::timestamptz[], $5::numeric[]) + AS t(pub_key, last_timestamp, multiplier) + ), moved_rows AS ( DELETE FROM data_transfer_sessions WHERE payer = $1 RETURNING * @@ -96,24 +124,42 @@ pub async fn do_add_pending_txn( uploaded_bytes, downloaded_bytes, rewardable_bytes, + multiplier, first_timestamp, last_timestamp, signature ) SELECT - pub_key, - payer, - uploaded_bytes, - downloaded_bytes, - rewardable_bytes, - first_timestamp, - last_timestamp, + moved_rows.pub_key, + moved_rows.payer, + moved_rows.uploaded_bytes, + moved_rows.downloaded_bytes, + moved_rows.rewardable_bytes, + COALESCE(priced.multiplier, 1), + moved_rows.first_timestamp, + moved_rows.last_timestamp, $2 - FROM moved_rows; + FROM moved_rows + LEFT JOIN priced + ON priced.pub_key = moved_rows.pub_key + AND priced.last_timestamp = moved_rows.last_timestamp; "#, ) .bind(payer) .bind(signature.to_string()) + .bind( + priced + .iter() + .map(|s| s.pub_key().to_string()) + .collect::>(), + ) + .bind( + priced + .iter() + .map(|s| s.last_timestamp()) + .collect::>(), + ) + .bind(priced.iter().map(|s| s.multiplier()).collect::>()) .execute(&mut *txn) .await?; diff --git a/mobile_packet_verifier/tests/integrations/accumulate_sessions.rs b/mobile_packet_verifier/tests/integrations/accumulate_sessions.rs index facf64ec7..8d95425c6 100644 --- a/mobile_packet_verifier/tests/integrations/accumulate_sessions.rs +++ b/mobile_packet_verifier/tests/integrations/accumulate_sessions.rs @@ -104,7 +104,7 @@ async fn accumlate_reports_for_same_key(pool: PgPool) -> anyhow::Result<()> { let pending = pending_burns::get_all(&pool).await?; assert_eq!(pending.len(), 1); - assert_eq!(pending[0].dc_to_burn(), bytes_to_dc(2_000)); + assert_eq!(pending[0].dc_to_burn()?, bytes_to_dc(2_000)); Ok(()) } diff --git a/mobile_packet_verifier/tests/integrations/apply_multiplier.rs b/mobile_packet_verifier/tests/integrations/apply_multiplier.rs new file mode 100644 index 000000000..bf111b4e5 --- /dev/null +++ b/mobile_packet_verifier/tests/integrations/apply_multiplier.rs @@ -0,0 +1,851 @@ +//! HIP-150: applying the multiplier to what a payer burns. +//! +//! Sessions accumulate without a multiplier, one row per hotspot per file. The +//! burn joins each row against the ticket history at that row's timestamp. +//! +//! Two things have to hold, and most of these tests are about one or the other: +//! +//! * Bytes that moved before a grant took effect bill at the old rate, and bytes +//! after it at the new one. +//! * It doesn't matter when we processed the ticket, only when it took effect. +//! A ticket effective at 1:30 applies to a 1:40 row even if we didn't see the +//! ticket until 1:55. +//! +//! The arithmetic is not per row. `bytes_to_dc` rounds up off a one-DC floor, so +//! converting every file separately would charge a full DC for each. Rows are +//! grouped by the multiplier the burn resolved, summed within a group, and +//! converted once. + +use chrono::{DateTime, Duration, Utc}; +use file_store::file_sink::FileSinkClient; +use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; +use file_store_oracles::mobile_session::{ + DataTransferEvent, DataTransferSessionIngestReport, DataTransferSessionReq, +}; +use helium_crypto::PublicKeyBinary; +use helium_proto::services::poc_mobile::{CarrierIdV2, DataTransferRadioAccessTechnology}; +use mobile_packet_verifier::{ + banning, bytes_to_dc, + daemon::handle_data_transfer_session_file, + multiplier::db::{self, GrantedMultiplier}, + pending_burns, + routing::RoutingKeys, +}; +use rust_decimal::dec; +use sqlx::PgPool; + +use crate::common::{self, hotspot_inventory::MobileHotspotInventory}; + +fn gateway(byte: u8) -> PublicKeyBinary { + PublicKeyBinary::from(vec![byte]) +} + +fn payer() -> PublicKeyBinary { + PublicKeyBinary::from(vec![0]) +} + +/// A report of `rewardable_bytes` from `gateway`. `event_id` is unique per call +/// so repeated reports are not rejected as duplicates. +fn report( + gateway: &PublicKeyBinary, + rewardable_bytes: u64, + event: &str, + received_timestamp: DateTime, +) -> DataTransferSessionIngestReport { + DataTransferSessionIngestReport { + received_timestamp, + report: DataTransferSessionReq { + rewardable_bytes, + pub_key: gateway.clone(), + signature: vec![], + carrier_id: CarrierIdV2::Carrier9, + sampling: false, + data_transfer_usage: DataTransferEvent { + pub_key: gateway.clone(), + upload_bytes: rewardable_bytes, + download_bytes: 0, + radio_access_technology: DataTransferRadioAccessTechnology::Wlan, + event_id: event.to_string(), + payer: payer(), + timestamp: received_timestamp, + signature: vec![], + }, + }, + } +} + +/// Record a granted multiplier as taking effect at `effective`, the way the +/// ticket ingestor does. +async fn grant( + pool: &PgPool, + gateway: &PublicKeyBinary, + value: rust_decimal::Decimal, + effective: DateTime, +) -> anyhow::Result<()> { + let mut txn = pool.begin().await?; + db::save( + &mut txn, + &[GrantedMultiplier { + hotspot_pubkey: gateway.clone(), + multiplier: DataTransferMultiplier::new(value).expect("valid multiplier"), + effective_timestamp: effective, + }], + ) + .await?; + txn.commit().await?; + Ok(()) +} + +/// Accumulate a batch of reports as one file landing at `file_ts`. +/// +/// `file_ts` becomes the sessions' `last_timestamp`, which is the instant the +/// burn prices them at. +async fn accumulate( + pool: &PgPool, + gateways: &[PublicKeyBinary], + reports: Vec, + file_ts: DateTime, +) -> anyhow::Result<()> { + let harness = common::setup_iceberg().await?; + common::hotspot_inventory::seed( + &harness, + gateways + .iter() + .map(|g| MobileHotspotInventory::known(g, Utc::now() - Duration::hours(6))) + .collect(), + ) + .await?; + let resolver = common::gateway_resolver(&harness).await?; + let routing_keys: RoutingKeys = gateways.iter().cloned().collect(); + + let mut txn = pool.begin().await?; + let (verified_tx, _verified_rx) = tokio::sync::mpsc::channel(10); + let verified_sink = FileSinkClient::new(verified_tx, "test"); + let banned_radios = banning::get_banned_radios(&mut txn, Utc::now()).await?; + + handle_data_transfer_session_file( + &mut txn, + None, + None, + "test_write_id", + banned_radios, + &resolver, + &routing_keys, + &verified_sink, + file_ts, + futures::stream::iter(reports), + ) + .await?; + txn.commit().await?; + + Ok(()) +} + +/// Total DC the burn would charge `payer`. +async fn pending_dc(pool: &PgPool) -> anyhow::Result { + let burns = pending_burns::get_all_payer_burns(pool).await?; + Ok(burns.iter().map(|b| b.total_dcs).sum()) +} + +/// The baseline that must not move: with no tickets, every hotspot is at 1 and +/// the burn is exactly what it was before HIP-150. +#[sqlx::test] +async fn no_tickets_burns_exactly_what_it_did_before(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![ + report(&gw, 150_000, "a", Utc::now()), + report(&gw, 150_000, "b", Utc::now()), + ], + Utc::now(), + ) + .await?; + + // 300,000 bytes summed, converted once: 3 DC. Unchanged by HIP-150. + assert_eq!(pending_dc(&pool).await?, bytes_to_dc(300_000)); + assert_eq!(pending_dc(&pool).await?, 3); + + Ok(()) +} + +#[sqlx::test] +async fn a_multiplier_scales_what_is_burned(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + + grant(&pool, &gw, dec!(1.5), now - Duration::hours(1)).await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", now)], + now, + ) + .await?; + + // 200,000 bytes -> 2 DC -> x1.5 -> 3. + assert_eq!(pending_dc(&pool).await?, 3); + + Ok(()) +} + +/// HIP-150: "rounded down, so a payer never burns more than the multiplier +/// earns". 3 DC at 1.5 is 4.5, which must charge 4 rather than 5. +#[sqlx::test] +async fn the_multiplier_rounds_down(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + + grant(&pool, &gw, dec!(1.5), now - Duration::hours(1)).await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 250_000, "a", now)], + now, + ) + .await?; + + // 250,000 bytes -> ceil = 3 DC -> x1.5 = 4.5 -> floor = 4. + assert_eq!(pending_dc(&pool).await?, 4); + + Ok(()) +} + +/// Data credits are derived first, then multiplied -- not the other way round. +/// +/// HIP-150: "It applies to the data credits derived from that Hotspot's +/// rewardable bytes, not to the bytes themselves: a rewardable-byte count is a +/// measurement and does not change." +/// +/// The two orderings give different answers, so this pins ours. 100,000 bytes is +/// 1 DC, and 1 x 1.5 floored is 1. Multiplying the bytes first would give +/// 150,000 bytes, which rounds up to 2. +/// +/// 2 would also break the rule the HIP states alongside it -- "a payer never +/// burns more than the multiplier earns" -- because the multiplier earns 1.5 +/// here. `floor(m x dc)` can never exceed `m x dc`; `ceil(m x bytes / 100k)` +/// can. +#[sqlx::test] +async fn bytes_convert_to_dc_before_the_multiplier_is_applied(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + + grant(&pool, &gw, dec!(1.5), now - Duration::hours(1)).await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 100_000, "a", now)], + now, + ) + .await?; + + assert_eq!( + pending_dc(&pool).await?, + 1, + "1 DC at 1.5x floors to 1; multiplying the bytes first would charge 2" + ); + + Ok(()) +} + +/// The session arrives and accumulates, and only then is the ticket processed -- +/// a ticket that had already taken effect before the data moved. +/// +/// Looking the multiplier up at burn time means it still applies. Stamping it on +/// arrival would have left this hotspot at 1x forever, because nothing knew +/// about the grant when its bytes landed. +#[sqlx::test] +async fn a_ticket_processed_late_still_prices_the_session_it_predates( + pool: PgPool, +) -> anyhow::Result<()> { + let gw = gateway(1); + let session_ts = Utc::now() - Duration::minutes(10); + + // The session lands first, with no ticket on record for this hotspot. + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", session_ts)], + session_ts, + ) + .await?; + assert_eq!( + pending_dc(&pool).await?, + 2, + "nothing known yet, so the session prices at 1" + ); + + // The ticket is processed afterwards, but took effect five minutes before + // the data moved. + grant(&pool, &gw, dec!(1.5), session_ts - Duration::minutes(5)).await?; + + // 200,000 bytes -> 2 DC -> x1.5 -> 3. + assert_eq!( + pending_dc(&pool).await?, + 3, + "the burn must price at what was in force when the data moved" + ); + + Ok(()) +} + +/// The other half of that rule. A grant that took effect after the data moved +/// does not apply to it. +#[sqlx::test] +async fn a_ticket_does_not_reach_back_over_older_sessions(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let session_ts = Utc::now() - Duration::hours(2); + + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", session_ts)], + session_ts, + ) + .await?; + + // Effective an hour *after* the data moved. + grant(&pool, &gw, dec!(5), session_ts + Duration::hours(1)).await?; + + assert_eq!( + pending_dc(&pool).await?, + 2, + "a later grant must not be applied to data that predates it" + ); + + Ok(()) +} + +/// With several grants on record, a session is priced by the newest one that had +/// taken effect by the time the data moved — not the newest overall. +#[sqlx::test] +async fn the_newest_grant_in_force_wins(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let session_ts = Utc::now() - Duration::hours(1); + + grant(&pool, &gw, dec!(5), session_ts - Duration::hours(2)).await?; + grant(&pool, &gw, dec!(2), session_ts - Duration::minutes(30)).await?; + // Superseded by nothing yet: this one is in the future relative to the data. + grant(&pool, &gw, dec!(3), session_ts + Duration::hours(1)).await?; + + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", session_ts)], + session_ts, + ) + .await?; + + // 2 DC at x2 = 4. Not x5 (superseded) and not x3 (not yet in force). + assert_eq!(pending_dc(&pool).await?, 4); + + Ok(()) +} + +/// A reset to 1 is an ordinary grant and takes effect the same way. This is the +/// path the re-assertion cron drives. +#[sqlx::test] +async fn a_reset_to_one_takes_effect_like_any_other_grant(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let session_ts = Utc::now() - Duration::hours(1); + + grant(&pool, &gw, dec!(5), session_ts - Duration::hours(2)).await?; + grant(&pool, &gw, dec!(1), session_ts - Duration::minutes(10)).await?; + + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", session_ts)], + session_ts, + ) + .await?; + + assert_eq!(pending_dc(&pool).await?, 2, "back to unmultiplied"); + + Ok(()) +} + +/// A multiplier attaches to one hotspot. Its neighbour on the same payer is +/// unaffected, and both are billed to the same payer. +#[sqlx::test] +async fn a_multiplier_does_not_leak_between_hotspots(pool: PgPool) -> anyhow::Result<()> { + let multiplied = gateway(1); + let plain = gateway(2); + let now = Utc::now(); + + grant(&pool, &multiplied, dec!(5), now - Duration::hours(1)).await?; + accumulate( + &pool, + &[multiplied.clone(), plain.clone()], + vec![ + report(&multiplied, 200_000, "a", now), + report(&plain, 200_000, "b", now), + ], + now, + ) + .await?; + + // 2 DC x5 = 10, plus 2 DC x1 = 2. + assert_eq!(pending_dc(&pool).await?, 12); + + Ok(()) +} + +/// Bytes billed at the same rate convert **once**, however many files they +/// arrived in. +/// +/// `bytes_to_dc` charges a full DC for anything under 100KB and rounds up above +/// it, and you pay that once per conversion. Three files of 100,000 bytes summed +/// are 300,000, so 3 DC, and 3 x 1.5 floors to 4. Converted a row at a time they +/// would be 1 DC each, and `floor(1 x 1.5)` is 1, so three rows would charge 3. +/// +/// Pick numbers that tell those apart. An earlier version of this test used +/// 40,000-byte files, which give 3 either way and so proved nothing. +#[sqlx::test] +async fn bytes_from_several_files_convert_together(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let start = Utc::now() - Duration::hours(1); + + grant(&pool, &gw, dec!(1.5), start - Duration::hours(1)).await?; + for (i, event) in ["a", "b", "c"].iter().enumerate() { + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 100_000, event, start)], + start + Duration::minutes(i as i64 * 10), + ) + .await?; + } + + let rows = pending_burns::get_all(&pool).await?; + assert_eq!(rows.len(), 3, "one row per file"); + + assert_eq!( + pending_dc(&pool).await?, + 4, + "300,000 bytes -> 3 DC -> x1.5 -> 4; converting per row would charge 3" + ); + + Ok(()) +} + +/// The same grouping rule with no multiplier in play, where it costs the payer +/// rather than the deployer. +/// +/// Three files of 40,000 bytes are 120,000 summed, which is 2 DC. Each one alone +/// is under the 100KB floor and so costs a full DC, making 3. This is not a +/// HIP-150 behaviour -- it is why sessions were accumulated before any of this +/// existed -- but per-file rows are new, so it needs holding down. +#[sqlx::test] +async fn small_files_do_not_each_pay_the_one_dc_floor(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let start = Utc::now() - Duration::hours(1); + + for (i, event) in ["a", "b", "c"].iter().enumerate() { + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 40_000, event, start)], + start + Duration::minutes(i as i64 * 10), + ) + .await?; + } + + assert_eq!(pending_burns::get_all(&pool).await?.len(), 3, "three files"); + assert_eq!( + pending_dc(&pool).await?, + bytes_to_dc(120_000), + "summed and converted once" + ); + assert_eq!( + pending_dc(&pool).await?, + 2, + "3 DC would mean each file paid the floor on its own" + ); + + Ok(()) +} + +/// A ticket takes effect at 1:30. The file before it bills at the old rate, the +/// file after it at the new one. +/// +/// The ticket is not processed until after both files have landed, so the split +/// has to be worked out at burn time from the history. +#[sqlx::test] +async fn a_ticket_mid_window_splits_the_burn_at_the_boundary(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let one_thirty = Utc::now() - Duration::minutes(30); + + // 1:15 — before the ticket. + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report( + &gw, + 600_000, + "before", + one_thirty - Duration::minutes(15), + )], + one_thirty - Duration::minutes(15), + ) + .await?; + + // 1:45 — after it. + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report( + &gw, + 500_000, + "after", + one_thirty + Duration::minutes(15), + )], + one_thirty + Duration::minutes(15), + ) + .await?; + + // Only now is the ticket processed, taking effect at 1:30. + grant(&pool, &gw, dec!(1.5), one_thirty).await?; + + // 600,000 -> 6 DC at 1x -> 6 + // 500,000 -> 5 DC at 1.5x -> 7 (7.5 floored) + assert_eq!(pending_dc(&pool).await?, 13); + + // Two groups, billed at two rates, from one hotspot in one burn window. + let burns = pending_burns::get_all_payer_burns(&pool).await?; + assert_eq!(burns.len(), 1, "one payer"); + let mut rates: Vec<_> = burns[0].sessions().iter().map(|s| s.multiplier()).collect(); + rates.sort(); + assert_eq!(rates, vec![dec!(1), dec!(1.5)]); + + Ok(()) +} + +/// The same boundary, checked through the burned records rather than the total. +/// +/// A total can come out right while the split is wrong, so this asserts the +/// bytes on each side separately. +#[sqlx::test] +async fn each_side_of_the_boundary_carries_its_own_bytes(pool: PgPool) -> anyhow::Result<()> { + use mobile_packet_verifier::{burner::Burner, iceberg::burned_session}; + + let harness = common::setup_iceberg().await?; + let burn_writer = harness.get_table_writer(burned_session::TABLE_NAME).await?; + + let gw = gateway(1); + let boundary = Utc::now() - Duration::minutes(30); + + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report( + &gw, + 600_000, + "before", + boundary - Duration::minutes(15), + )], + boundary - Duration::minutes(15), + ) + .await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report( + &gw, + 500_000, + "after", + boundary + Duration::minutes(15), + )], + boundary + Duration::minutes(15), + ) + .await?; + grant(&pool, &gw, dec!(1.5), boundary).await?; + + let solana = solana::burn::TestSolanaClientMap::default(); + solana.insert(&payer(), 1_000_000).await; + let (tx, _rx) = tokio::sync::mpsc::channel(10); + Burner::new( + FileSinkClient::new(tx, "test"), + solana, + 0, + std::time::Duration::default(), + Some(burn_writer), + ) + .burn(&pool) + .await?; + + let mut burned = burned_session::get_all(harness.trino()).await?; + burned.sort_by_key(|b| b.rewardable_bytes); + assert_eq!(burned.len(), 2, "one record per rate billed"); + + // 500,000 bytes at 1.5x -> 5 DC -> 7. + assert_eq!(burned[0].rewardable_bytes, 500_000); + assert_eq!(burned[0].num_dcs, 7); + // 600,000 bytes at 1x -> 6 DC -> 6. + assert_eq!(burned[1].rewardable_bytes, 600_000); + assert_eq!(burned[1].num_dcs, 6); + + Ok(()) +} + +/// The burned record has to say which multiplier produced its `num_dcs`. +/// +/// `num_dcs` is the figure after the multiplier: what the payer was charged, and +/// what the reward path divides up. Without this column there is no way back to +/// the pre-multiplier count, and the burn cannot be audited. +/// +/// This covers the synchronous path, where the record is written from the groups +/// still in memory. See `a_frozen_multiplier_survives_the_pending_round_trip` +/// for the other one. +#[sqlx::test] +async fn the_burned_record_carries_the_multiplier(pool: PgPool) -> anyhow::Result<()> { + use mobile_packet_verifier::{burner::Burner, iceberg::burned_session}; + + let harness = common::setup_iceberg().await?; + let burn_writer = harness.get_table_writer(burned_session::TABLE_NAME).await?; + + let gw = gateway(1); + let now = Utc::now(); + + grant(&pool, &gw, dec!(1.5), now - Duration::hours(1)).await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", now)], + now, + ) + .await?; + + let solana = solana::burn::TestSolanaClientMap::default(); + solana.insert(&payer(), 1_000_000).await; + + let (tx, _rx) = tokio::sync::mpsc::channel(10); + let burner = Burner::new( + FileSinkClient::new(tx, "test"), + solana, + 0, + std::time::Duration::default(), + Some(burn_writer), + ); + burner.burn(&pool).await?; + + let burned = burned_session::get_all(harness.trino()).await?; + assert_eq!(burned.len(), 1); + + // 200,000 bytes -> 2 DC -> x1.5 -> 3, and the record says how. + assert_eq!(burned[0].num_dcs, 3); + // Compared numerically, not as a string: a decimal(9,6) column returns + // everything scale-padded, so 1.5 comes back as "1.500000". + let stored: rust_decimal::Decimal = burned[0] + .multiplier + .as_ref() + .expect("the burned record must state the multiplier it was priced at") + .as_string() + .parse()?; + assert_eq!(stored, dec!(1.5)); + + Ok(()) +} + +/// A burn that is not confirmed straight away leaves its rows in +/// `pending_data_transfer_sessions`, and the records are written later from +/// those rows, grouped on the multiplier stored with them. +/// +/// Nothing else covers that path with a multiplier other than 1. The synchronous +/// burn writes from groups still in memory, so it would pass even if the stored +/// multiplier were dropped on the way in. +#[sqlx::test] +async fn a_frozen_multiplier_survives_the_pending_round_trip(pool: PgPool) -> anyhow::Result<()> { + use mobile_packet_verifier::{burner::Burner, iceberg::burned_session, pending_txns}; + use solana::Signature; + + let harness = common::setup_iceberg().await?; + let burn_writer = harness.get_table_writer(burned_session::TABLE_NAME).await?; + + let gw = gateway(1); + let now = Utc::now(); + + grant(&pool, &gw, dec!(1.5), now - Duration::hours(1)).await?; + accumulate( + &pool, + std::slice::from_ref(&gw), + vec![report(&gw, 200_000, "a", now)], + now, + ) + .await?; + + // Price it, then park it as an in-flight burn — the rows carry their + // multiplier in, the way the burner does it. + let burns = pending_burns::get_all_payer_burns(&pool).await?; + assert_eq!(burns[0].total_dcs, 3, "2 DC at 1.5x"); + + let signature = Signature::new_unique(); + pending_txns::do_add_pending_txn( + &pool, + &payer(), + burns[0].total_dcs, + &signature, + &burns[0].rows, + // Backdated so the confirm path does not sleep waiting for finality. + Utc::now() - Duration::minutes(2), + ) + .await?; + + let solana = solana::burn::TestSolanaClientMap::default(); + solana.insert(&payer(), 1_000_000).await; + solana.add_confirmed(signature).await; + + let (tx, _rx) = tokio::sync::mpsc::channel(10); + Burner::new( + FileSinkClient::new(tx, "test"), + solana, + 0, + std::time::Duration::default(), + Some(burn_writer), + ) + .confirm_pending_txns(&pool) + .await?; + + let burned = burned_session::get_all(harness.trino()).await?; + assert_eq!(burned.len(), 1); + // 3, not 2: the frozen 1.5 was used. Dropping it on the way into the + // pending table would leave this at 2. + assert_eq!(burned[0].num_dcs, 3); + + let stored: rust_decimal::Decimal = burned[0] + .multiplier + .as_ref() + .expect("the record must state the multiplier it was priced at") + .as_string() + .parse()?; + assert_eq!(stored, dec!(1.5)); + + Ok(()) +} + +/// Which grant is in force, tested through the burn's own read path. +/// +/// These were inventory tests before the inventory was dropped. The guarantees +/// are the same; the table backing them is Postgres now. +/// +/// A ticket is a signed message that never expires, so whoever captures one can +/// send it again later. Getting the ordering wrong is how a replayed ticket +/// restores a multiplier that was taken away. +mod which_grant_wins { + use super::*; + + /// Price 200,000 bytes for `gw` right now, which is 2 DC before any + /// multiplier, so the answer is 2 x whatever grant applies. + /// + /// `probe` has to differ between calls: repeated event ids are rejected as + /// duplicates. The sessions are cleared afterwards so each probe stands + /// alone rather than adding to the last one. + async fn dc_for(pool: &PgPool, gw: &PublicKeyBinary, probe: &str) -> anyhow::Result { + accumulate( + pool, + std::slice::from_ref(gw), + vec![report(gw, 200_000, probe, Utc::now())], + Utc::now(), + ) + .await?; + let dc = pending_dc(pool).await?; + pending_burns::delete_for_payer(pool, &payer()).await?; + Ok(dc) + } + + /// Grants are ordered by when the issuer signed them, so a ticket that took + /// a long time to arrive cannot override one issued after it. + #[sqlx::test] + async fn a_delayed_grant_does_not_supersede_a_newer_one(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + + // Issued second, recorded first. + grant(&pool, &gw, dec!(1), now - Duration::hours(1)).await?; + // Issued first, recorded second, and must lose. + grant(&pool, &gw, dec!(5), now - Duration::hours(3)).await?; + + assert_eq!( + dc_for(&pool, &gw, "a").await?, + 2, + "the newer grant still wins" + ); + + Ok(()) + } + + /// Replaying a captured ticket writes the same row again rather than a newer + /// one, so it cannot bring back a multiplier that was superseded. + #[sqlx::test] + async fn replaying_a_superseded_grant_does_not_restore_it(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + let issued = now - Duration::hours(3); + + grant(&pool, &gw, dec!(5), issued).await?; + grant(&pool, &gw, dec!(1), now - Duration::hours(1)).await?; + assert_eq!(dc_for(&pool, &gw, "before").await?, 2, "revoked"); + + // The attacker resends the original, byte for byte. + grant(&pool, &gw, dec!(5), issued).await?; + + assert_eq!( + dc_for(&pool, &gw, "after").await?, + 2, + "a replay must not restore the old multiplier" + ); + + Ok(()) + } + + /// `1.50` and `1.5` are the same number, so they are the same grant rather + /// than two rows competing. + #[sqlx::test] + async fn equivalent_spellings_are_one_grant(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let issued = Utc::now() - Duration::hours(1); + + grant(&pool, &gw, dec!(1.5), issued).await?; + grant(&pool, &gw, dec!(1.50), issued).await?; + + // 2 DC x1.5 = 3. Two rows racing would still give 3, so also check + // there is only one. + assert_eq!(dc_for(&pool, &gw, "a").await?, 3); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM data_transfer_multipliers WHERE hotspot_pubkey = $1", + ) + .bind(gw.to_string()) + .fetch_one(&pool) + .await?; + assert_eq!(rows, 1, "same value, same instant, one row"); + + Ok(()) + } + + /// Values survive Postgres `NUMERIC` unchanged, including one that a float + /// would mangle. The multiplier decides what a payer is charged, so an + /// approximation here is a wrong bill. + #[sqlx::test] + async fn grants_round_trip_exactly(pool: PgPool) -> anyhow::Result<()> { + let issued = Utc::now() - Duration::hours(1); + let values = [dec!(1), dec!(1.3), dec!(1.5), dec!(2.25), dec!(5)]; + + for (i, value) in values.iter().enumerate() { + grant(&pool, &gateway(i as u8 + 1), *value, issued).await?; + } + + let mut stored: Vec = sqlx::query_scalar( + "SELECT multiplier FROM data_transfer_multipliers ORDER BY multiplier", + ) + .fetch_all(&pool) + .await?; + stored.sort(); + + assert_eq!(stored, values.to_vec()); + + Ok(()) + } +} diff --git a/mobile_packet_verifier/tests/integrations/burn_metric.rs b/mobile_packet_verifier/tests/integrations/burn_metric.rs index 3b6a638e0..0199637bf 100644 --- a/mobile_packet_verifier/tests/integrations/burn_metric.rs +++ b/mobile_packet_verifier/tests/integrations/burn_metric.rs @@ -17,8 +17,24 @@ use sqlx::{types::Uuid, PgPool}; use crate::common::hotspot_inventory::MobileHotspotInventory; +/// `pending_dc_burn` reads 0 for payers that burned, and their real debt for a +/// payer that did not. +/// +/// That second half is the point of the gauge: leftover DC should be visible. +/// The burn sets each payer's gauge from the priced total before it checks +/// balances, so a payer that cannot pay keeps a gauge showing exactly what it +/// owes, and that figure grows every cycle it fails to burn. +/// +/// The gauge is set rather than decremented because the two sides do not count +/// the same way. `accumulate` sums bytes per payer and converts once; the burn +/// converts per hotspot group, and multiplies. Subtracting one from the other +/// walks the gauge negative, which is what it did before this was measured. +/// +/// All three payers share one test because `TestMetrics` installs a global +/// recorder: a second instance in the same process leaves the first serving the +/// data and the second serving nothing. #[sqlx::test] -async fn burn_metric_reports_0_after_successful_accumulate_and_burn( +async fn burn_metric_reports_0_for_burned_payers_and_the_debt_for_stuck_ones( pool: PgPool, ) -> anyhow::Result<()> { let harness = crate::common::setup_iceberg().await?; @@ -60,7 +76,7 @@ async fn burn_metric_reports_0_after_successful_accumulate_and_burn( reports.push(mk_dt(dc_to_bytes(150) - 2_000)); } - let metrics = TestMetrics::new(); + let metrics = TestMetrics::shared(); // accumulate and burn run_accumulate_sessions( @@ -72,17 +88,179 @@ async fn burn_metric_reports_0_after_successful_accumulate_and_burn( Some(session_writer), ) .await?; - run_burner(&pool, &payer_key, Some(burn_writer)).await?; + // ...and a second payer whose hotspot carries a 1.5 multiplier. + let multiplied_gateway = PublicKeyBinary::from(vec![2]); + let multiplied_payer = PublicKeyBinary::from(vec![9]); + grant_multiplier(&pool, &multiplied_gateway).await?; + run_accumulate_sessions( + &pool, + &harness, + vec![mk_dt_for( + &multiplied_gateway, + &multiplied_payer, + dc_to_bytes(200), + )], + vec![multiplied_gateway.clone()], + vec![multiplied_gateway.clone()], + None, + ) + .await?; + + // ...and a third who cannot afford what it owes. Its hotspot carries a + // multiplier, so the debt on the gauge can only have come from the priced + // total -- `accumulate` would have incremented the smaller, unmultiplied + // figure. + let broke_gateway = PublicKeyBinary::from(vec![3]); + let broke_payer = PublicKeyBinary::from(vec![8]); + grant_multiplier(&pool, &broke_gateway).await?; + run_accumulate_sessions( + &pool, + &harness, + vec![mk_dt_for(&broke_gateway, &broke_payer, dc_to_bytes(500))], + vec![broke_gateway.clone()], + vec![broke_gateway.clone()], + None, + ) + .await?; + + run_burner( + &pool, + &[&payer_key, &multiplied_payer], + &[(&broke_payer, 10)], + Some(burn_writer), + ) + .await?; metrics.assert_pending_dc_burn(&payer_key, 0).await?; + // 200 DC accumulated, 300 burned at 1.5x. Set rather than subtracted, so + // this is 0 and not -100. + metrics.assert_pending_dc_burn(&multiplied_payer, 0).await?; + // 500 DC of bytes at 1.5x is 750 owed, against a balance of 10. Nothing + // burns, and the gauge shows the real debt -- not the 500 `accumulate` put + // there. + metrics.assert_pending_dc_burn(&broke_payer, 750).await?; + + // A second cycle with more traffic: the debt grows rather than resetting, + // which is what makes a stuck payer visible over time. + run_accumulate_sessions( + &pool, + &harness, + vec![mk_dt_for(&broke_gateway, &broke_payer, dc_to_bytes(300))], + vec![broke_gateway.clone()], + vec![broke_gateway.clone()], + None, + ) + .await?; + run_burner(&pool, &[], &[(&broke_payer, 10)], None).await?; + // 800 DC of bytes now, still at 1.5x. + metrics.assert_pending_dc_burn(&broke_payer, 1200).await?; let trino = harness.trino(); let all_sessions = iceberg::session::get_all(trino).await?; let all_burns = iceberg::burned_session::get_all(trino).await?; assert_eq!(all_sessions.len(), 2000, "individual sessions"); - assert_eq!(all_burns.len(), 1, "combined sessions"); + assert_eq!(all_burns.len(), 2, "one combined burn per payer"); + assert!( + all_burns.iter().any(|b| b.num_dcs == 300), + "the multiplied payer burned 200 DC at 1.5x" + ); + + Ok(()) +} + +/// `burned_dc_by_multiplier` splits a payer's burn by the multiplier behind it. +/// +/// `pending_dc_burn` tells you sessions are piling up and burns are happening, +/// but it is fed by `accumulate`, which has no multiplier to apply. This is the +/// metric that shows the real rate, and where it comes from. +#[sqlx::test] +async fn burned_dc_is_split_by_multiplier(pool: PgPool) -> anyhow::Result<()> { + let harness = crate::common::setup_iceberg().await?; + let burn_writer = harness + .get_table_writer(iceberg::burned_session::TABLE_NAME) + .await?; + + // Its own payer, because the exporter is shared with the other tests here. + let payer_key = PublicKeyBinary::from(vec![7]); + let plain = PublicKeyBinary::from(vec![20]); + let boosted = PublicKeyBinary::from(vec![21]); + + grant_multiplier(&pool, &boosted).await?; + + let metrics = TestMetrics::shared(); + + run_accumulate_sessions( + &pool, + &harness, + vec![ + mk_dt_for(&plain, &payer_key, dc_to_bytes(100)), + mk_dt_for(&boosted, &payer_key, dc_to_bytes(200)), + ], + vec![plain.clone(), boosted.clone()], + vec![plain.clone(), boosted.clone()], + None, + ) + .await?; + run_burner(&pool, &[&payer_key], &[], Some(burn_writer)).await?; + + // 100 DC at 1x, and 200 DC at 1.5x charged as 300. + metrics.assert_burned_at(&payer_key, "1", 100).await?; + metrics.assert_burned_at(&payer_key, "1.5", 300).await?; + + // The split adds back up to what the transaction charged. + metrics + .assert_line(&format!( + r#"burned{{payer="{payer_key}",success="true"}} 400"# + )) + .await?; + + Ok(()) +} +/// A report of `rewardable_bytes` from `gateway`, billed to `payer`. +fn mk_dt_for( + gateway: &PublicKeyBinary, + payer: &PublicKeyBinary, + rewardable_bytes: u64, +) -> DataTransferSessionIngestReport { + DataTransferSessionIngestReport { + received_timestamp: Utc::now(), + report: DataTransferSessionReq { + rewardable_bytes, + pub_key: gateway.clone(), + signature: vec![], + carrier_id: CarrierIdV2::Carrier9, + sampling: false, + data_transfer_usage: DataTransferEvent { + pub_key: gateway.clone(), + upload_bytes: 0, + download_bytes: 0, + radio_access_technology: DataTransferRadioAccessTechnology::Wlan, + event_id: Uuid::new_v4().to_string(), + payer: payer.clone(), + timestamp: Utc::now(), + signature: vec![], + }, + }, + } +} + +async fn grant_multiplier(pool: &PgPool, gateway: &PublicKeyBinary) -> anyhow::Result<()> { + use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; + use mobile_packet_verifier::multiplier::db::{self, GrantedMultiplier}; + + let mut txn = pool.begin().await?; + db::save( + &mut txn, + &[GrantedMultiplier { + hotspot_pubkey: gateway.clone(), + multiplier: DataTransferMultiplier::new(rust_decimal::dec!(1.5))?, + effective_timestamp: Utc::now() - Duration::hours(1), + }], + ) + .await?; + txn.commit().await?; Ok(()) } @@ -130,15 +308,24 @@ async fn run_accumulate_sessions( Ok(verified_sessions_rx) } +/// `funded` gets enough to burn anything; `underfunded` gets the balance given, +/// which is how a payer is made to fail the balance check. async fn run_burner( pool: &PgPool, - payer_key: &PublicKeyBinary, + funded: &[&PublicKeyBinary], + underfunded: &[(&PublicKeyBinary, u64)], iceberg_writer: Option, ) -> anyhow::Result<()> { let (valid_sessions_tx, _valid_sessions_rx) = tokio::sync::mpsc::channel(999_999); let valid_sessions = FileSinkClient::new(valid_sessions_tx, "test"); let solana_network = solana::burn::TestSolanaClientMap::default(); - solana_network.insert(payer_key, 900_000_000).await; + // `burn` visits every payer with pending rows, and asks each for a balance. + for payer_key in funded { + solana_network.insert(payer_key, 900_000_000).await; + } + for (payer_key, balance) in underfunded { + solana_network.insert(payer_key, *balance).await; + } mobile_packet_verifier::burner::Burner::new( valid_sessions, solana_network.clone(), @@ -153,40 +340,88 @@ async fn run_burner( } #[derive(Clone)] +/// The Prometheus exporter, shared by every test in this binary. +/// +/// `start_metrics` installs a *global* recorder, so a second one would leave the +/// first serving the data and the second serving nothing. Tests take this one +/// and keep to their own payer keys so they cannot read each other's writes. struct TestMetrics { addr: String, } +static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + impl TestMetrics { - fn new() -> Self { - let addr = { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("tcp listener"); - listener.local_addr().expect("local address") - }; - - poc_metrics::start_metrics(&poc_metrics::Settings { endpoint: addr }) - .expect("install prometheus"); - TestMetrics { - addr: format!("http://{addr}"), - } + fn shared() -> &'static TestMetrics { + METRICS.get_or_init(|| { + let addr = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("tcp listener"); + listener.local_addr().expect("local address") + }; + + // On its own runtime, on its own thread. The exporter's HTTP server + // is spawned onto whatever runtime installs it, and each + // `#[sqlx::test]` brings up and tears down its own -- so installing + // from inside a test would take the endpoint down with it and leave + // every later test unable to scrape. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("metrics runtime"); + rt.block_on(async move { + poc_metrics::start_metrics(&poc_metrics::Settings { endpoint: addr }) + .expect("install prometheus"); + ready_tx.send(()).expect("signal ready"); + std::future::pending::<()>().await; + }); + }); + ready_rx.recv().expect("metrics endpoint started"); + + TestMetrics { + addr: format!("http://{addr}"), + } + }) } - async fn assert_pending_dc_burn( - &self, - payer: &PublicKeyBinary, - amount: u64, - ) -> anyhow::Result<()> { - let res = reqwest::get(self.addr.clone()).await?; - let body = res.text().await?; + async fn scrape(&self) -> anyhow::Result { + let body = reqwest::get(self.addr.clone()).await?.text().await?; if body.is_empty() { anyhow::bail!("metrics body is empty") } + Ok(body) + } - let expected = format!(r#"pending_dc_burn{{payer="{payer}"}} {amount}"#); - if !body.contains(&expected) { + /// Assert an exact line is present, e.g. + /// `pending_dc_burn{payer="..."} 0`. + async fn assert_line(&self, expected: &str) -> anyhow::Result<()> { + let body = self.scrape().await?; + if !body.contains(expected) { anyhow::bail!("expected: {expected} in:\n{body}"); } - Ok(()) } + + async fn assert_pending_dc_burn( + &self, + payer: &PublicKeyBinary, + amount: u64, + ) -> anyhow::Result<()> { + self.assert_line(&format!(r#"pending_dc_burn{{payer="{payer}"}} {amount}"#)) + .await + } + + /// Labels come out in the order they were given, not alphabetically. + async fn assert_burned_at( + &self, + payer: &PublicKeyBinary, + multiplier: &str, + dc: u64, + ) -> anyhow::Result<()> { + self.assert_line(&format!( + r#"burned_dc_by_multiplier{{payer="{payer}",multiplier="{multiplier}"}} {dc}"# + )) + .await + } } diff --git a/mobile_packet_verifier/tests/integrations/burner.rs b/mobile_packet_verifier/tests/integrations/burner.rs index 3bb21be3c..733f9b608 100644 --- a/mobile_packet_verifier/tests/integrations/burner.rs +++ b/mobile_packet_verifier/tests/integrations/burner.rs @@ -126,6 +126,7 @@ async fn test_confirm_pending_txns(pool: PgPool) -> anyhow::Result<()> { &payer_one, 1_000, &confirmed_signature, + &[], Utc::now() - chrono::Duration::minutes(2), ) .await?; @@ -138,6 +139,7 @@ async fn test_confirm_pending_txns(pool: PgPool) -> anyhow::Result<()> { &payer_two, 500, &unconfirmed_signature, + &[], Utc::now() - chrono::Duration::minutes(2), ) .await?; @@ -168,7 +170,7 @@ async fn test_confirm_pending_txns(pool: PgPool) -> anyhow::Result<()> { let payer_burn = &burns[0]; assert_eq!(payer_burn.payer, payer_two); assert_eq!(payer_burn.total_dcs, bytes_to_dc(2_000)); - assert_eq!(payer_burn.sessions.len(), 1); + assert_eq!(payer_burn.sessions().len(), 1); let iceberg_burns = burned_session::get_all(harness.trino()).await?; assert_eq!(iceberg_burns.len(), 1, "1 of 2 burns made it"); @@ -204,6 +206,7 @@ fn confirmed_pending_txns_writes_out_sessions(pool: PgPool) -> anyhow::Result<() &payer, 1_000, &signature, + &[], Utc::now() - chrono::Duration::minutes(2), ) .await?; @@ -249,7 +252,7 @@ fn confirmed_pending_txns_writes_out_sessions(pool: PgPool) -> anyhow::Result<() payer_burn.total_dcs, bytes_to_dc(5_000) + bytes_to_dc(5_000) ); - assert_eq!(payer_burn.sessions.len(), 2); + assert_eq!(payer_burn.sessions().len(), 2); Ok(()) } @@ -282,6 +285,7 @@ fn unconfirmed_pending_txn_moves_data_session_back_to_primary_table( &payer, 1_000, &signature, + &[], Utc::now() - chrono::Duration::minutes(2), ) .await?; @@ -371,6 +375,7 @@ fn will_not_burn_when_pending_txns(pool: PgPool) -> anyhow::Result<()> { &payer, 1_000, &signature, + &[], Utc::now() - chrono::Duration::minutes(2), ) .await?; diff --git a/mobile_packet_verifier/tests/integrations/common/mod.rs b/mobile_packet_verifier/tests/integrations/common/mod.rs index 258e4a2fe..8f2f240c0 100644 --- a/mobile_packet_verifier/tests/integrations/common/mod.rs +++ b/mobile_packet_verifier/tests/integrations/common/mod.rs @@ -11,7 +11,6 @@ pub async fn setup_iceberg() -> anyhow::Result { iceberg::invalid_session::table_definition()?, iceberg::burned_session::table_definition()?, iceberg::multiplier_ticket_history::table_definition()?, - iceberg::multiplier_ticket_inventory::table_definition()?, hotspot_inventory::table_definition()?, ]) .await?; diff --git a/mobile_packet_verifier/tests/integrations/main.rs b/mobile_packet_verifier/tests/integrations/main.rs index 717ff68d7..44db5f7db 100644 --- a/mobile_packet_verifier/tests/integrations/main.rs +++ b/mobile_packet_verifier/tests/integrations/main.rs @@ -1,6 +1,7 @@ pub mod common; pub mod accumulate_sessions; +pub mod apply_multiplier; pub mod banning; pub mod burn_metric; pub mod burner; diff --git a/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs index 110d283b1..0bda1a9e5 100644 --- a/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs +++ b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs @@ -1,20 +1,13 @@ -//! HIP-150 data transfer multiplier tickets. +//! HIP-150 data transfer multiplier tickets: whether a ticket counts, and what +//! it grants. //! -//! Two things are tested here, and the first is the one that matters. +//! `ticket_status` decides whether a ticket is valid. `granted_multiplier` turns +//! a verified ticket into a grant, or into nothing. Both are pure functions, so +//! they are tested directly rather than through a file poller. //! -//! **Which ticket is in force.** The history table is append-only and holds -//! every ticket a hotspot has ever been issued, so "the multiplier" is whichever -//! row wins an ordering — decided by the merge that builds the inventory. A -//! ticket is a correctly signed message that never expires, so getting that -//! ordering wrong is not cosmetic: it is a way to restore a revoked multiplier -//! by replaying a captured message. -//! -//! These go through the whole path — seed the history, merge, read the -//! inventory — because that is what the burn will do. There is deliberately no -//! test that a ticket applies only to data that post-dates it: multipliers take -//! effect from the next refresh, not from the instant the data moved. -//! -//! **Whether a ticket counts at all** — the `verdict` module at the bottom. +//! Which grant is in force for a given hotspot at a given time is a property of +//! the Postgres table and the burn's join, so those tests live in +//! `apply_multiplier.rs` where the real read path is. use chrono::{DateTime, Duration, Utc}; use file_store_oracles::mobile::data_transfer_multiplier::{ @@ -23,31 +16,15 @@ use file_store_oracles::mobile::data_transfer_multiplier::{ VerifiedDataTransferMultiplierTicketStatus as Status, }; use helium_crypto::PublicKeyBinary; -use helium_iceberg::IcebergTestHarness; -use helium_iceberg_oracles::data_transfer::multiplier_ticket_history::{ - IcebergMultiplierTicket, NAMESPACE, TABLE_NAME, -}; -use mobile_packet_verifier::multiplier::{ - inventory::InventoryRefresher, trino::get_multipliers_from, Multipliers, -}; use rust_decimal::dec; use crate::common; -/// Two-part `schema.table` name, resolved against the per-test catalog the -/// harness registers. -const HISTORY_TABLE: &str = "data_transfer.multiplier_ticket_history"; -const INVENTORY_TABLE: &str = "data_transfer.multiplier_ticket_inventory"; - fn hotspot(byte: u8) -> PublicKeyBinary { PublicKeyBinary::from(vec![byte]) } -fn multiplier(value: rust_decimal::Decimal) -> DataTransferMultiplier { - DataTransferMultiplier::new(value).expect("valid multiplier") -} - -/// A ticket carrying whatever multiplier is given, valid or not — the range is +/// A ticket carrying whatever multiplier is given, valid or not. The range is /// judged by `ticket_status`, not by construction. fn ticket_with( hotspot_pubkey: &PublicKeyBinary, @@ -80,464 +57,81 @@ fn ticket( } } -/// A history row as the ingestor would have written it. -fn history_row( +/// What the ingestor would hold after ruling on a ticket. +fn verified( report: DataTransferMultiplierTicketReport, status: Status, -) -> IcebergMultiplierTicket { - let verified = VerifiedDataTransferMultiplierTicketReport { +) -> VerifiedDataTransferMultiplierTicketReport { + VerifiedDataTransferMultiplierTicketReport { verified_timestamp: report.received_timestamp, report, status, - }; - IcebergMultiplierTicket::from(&verified) -} - -fn valid_row(report: DataTransferMultiplierTicketReport) -> IcebergMultiplierTicket { - history_row(report, Status::Valid) -} - -async fn seed( - harness: &IcebergTestHarness, - rows: Vec, -) -> anyhow::Result<()> { - if rows.is_empty() { - return Ok(()); } - harness - .get_table_writer_in::(NAMESPACE, TABLE_NAME) - .await? - .write(rows) - .await?; - Ok(()) } -/// Seed the history, run the merge, and read what the burn would see. +/// What a verified ticket grants, and to whom. /// -/// The whole path: a ticket lands in the history, the inventory is merged out of -/// it, and the burn reads the inventory. Ordering and refusal rules live in the -/// merge, so exercising them through this helper tests them where they run. -async fn multipliers_after_refresh( - rows: Vec, -) -> anyhow::Result { - let harness = common::setup_iceberg().await?; - seed(&harness, rows).await?; - let trino = trino_client::Client::from_client(harness.owned_trino().await?); - - InventoryRefresher::new_with_tables( - trino.clone(), - std::time::Duration::from_secs(900), - HISTORY_TABLE.to_string(), - INVENTORY_TABLE.to_string(), - ) - .refresh() - .await?; - - get_multipliers_from(&trino, INVENTORY_TABLE).await -} - -#[tokio::test] -async fn no_tickets_means_every_hotspot_is_unmultiplied() -> anyhow::Result<()> { - let multipliers = multipliers_after_refresh(vec![]).await?; - - assert!(multipliers.is_empty()); - assert_eq!( - multipliers.get(&hotspot(1)), - DataTransferMultiplier::DEFAULT, - "a hotspot with no ticket must be unmultiplied" - ); - - Ok(()) -} - -#[tokio::test] -async fn latest_ticket_by_issue_time_wins() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let multipliers = multipliers_after_refresh(vec![ - valid_row(ticket( - &hotspot, - dec!(5), - now - Duration::hours(2), - now - Duration::hours(2), - )), - valid_row(ticket( - &hotspot, - dec!(1.5), - now - Duration::hours(1), - now - Duration::hours(1), - )), - ]) - .await?; - - assert_eq!(multipliers.get(&hotspot), multiplier(dec!(1.5))); - - Ok(()) -} - -/// **The replay test.** A ticket granting 5x is superseded by one granting 1x. -/// An attacker resubmits the original 5x ticket — a genuine, correctly signed -/// message — hours after it was revoked. -/// -/// With no database there is no primary key to reject the duplicate, so the -/// replay *does* become a row; the history honestly records that a resubmission -/// happened. It changes nothing because the ordering is on the issuer's signed -/// timestamp, which a replay cannot alter without the signing key. Ordering on -/// arrival would hand the attacker the 5x back. -#[tokio::test] -async fn replaying_a_superseded_ticket_does_not_restore_it() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let granted_at = now - Duration::hours(3); - let revoked_at = now - Duration::hours(2); - - let granted = valid_row(ticket(&hotspot, dec!(5), granted_at, granted_at)); - let revoked = valid_row(ticket(&hotspot, dec!(1), revoked_at, revoked_at)); - // The captured 5x ticket, resubmitted now. Same signed timestamp — that is - // what makes it a replay rather than a new grant — but it arrives after the - // revocation. - let replayed = valid_row(ticket(&hotspot, dec!(5), granted_at, now)); - - // Control: before the revocation the grant really was in force. Without - // this the assertion below would pass just as well if the query returned - // nothing at all, since "no ticket" and "revoked to 1" are both the default. - assert_eq!( - multipliers_after_refresh(vec![granted.clone()]) - .await? - .get(&hotspot), - multiplier(dec!(5)), - "the original grant should have been in force before revocation" - ); - - let multipliers = multipliers_after_refresh(vec![granted, revoked, replayed]).await?; - - assert_eq!( - multipliers.get(&hotspot), - DataTransferMultiplier::DEFAULT, - "a replayed ticket must not restore a revoked multiplier" - ); - - Ok(()) -} - -/// A ticket held up in delivery must not leapfrog a newer one that overtook it. -/// Ordering by arrival gets this wrong with no attacker involved at all. -#[tokio::test] -async fn a_delayed_ticket_does_not_supersede_a_newer_one() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let multipliers = multipliers_after_refresh(vec![ - // Signed second, arrived first. - valid_row(ticket( - &hotspot, - dec!(1.5), - now - Duration::hours(1), - now - Duration::minutes(30), - )), - // Signed first, arrived second. - valid_row(ticket( - &hotspot, - dec!(5), - now - Duration::hours(2), - now - Duration::minutes(10), - )), - ]) - .await?; - - assert_eq!( - multipliers.get(&hotspot), - multiplier(dec!(1.5)), - "the more recently *issued* ticket wins, not the more recently received" - ); - - Ok(()) -} - -/// The history table keeps refused tickets, so the read has to exclude them. -/// Without the status filter a rejected grant would take effect. -#[tokio::test] -async fn rejected_tickets_do_not_take_effect() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let multipliers = multipliers_after_refresh(vec![ - valid_row(ticket( - &hotspot, - dec!(1.5), - now - Duration::hours(2), - now - Duration::hours(2), - )), - // Newer, larger, and refused — it must not win despite sorting first. - history_row( - ticket( - &hotspot, - dec!(5), - now - Duration::hours(1), - now - Duration::hours(1), - ), - Status::InvalidSigner, - ), - ]) - .await?; - - assert_eq!( - multipliers.get(&hotspot), - multiplier(dec!(1.5)), - "a refused ticket must not take effect" - ); - - Ok(()) -} - -#[tokio::test] -async fn hotspots_do_not_affect_each_other() -> anyhow::Result<()> { - let now = Utc::now(); - let (a, b, c) = (hotspot(1), hotspot(2), hotspot(3)); - - let multipliers = multipliers_after_refresh(vec![ - valid_row(ticket(&a, dec!(1.5), now, now)), - valid_row(ticket(&b, dec!(5), now, now)), - ]) - .await?; - - assert_eq!(multipliers.len(), 2, "only ticketed hotspots appear"); - assert_eq!(multipliers.get(&a), multiplier(dec!(1.5))); - assert_eq!(multipliers.get(&b), multiplier(dec!(5))); - assert_eq!(multipliers.get(&c), DataTransferMultiplier::DEFAULT); - - Ok(()) -} - -/// The exact decimal must survive the round trip through `decimal(9,6)`. 1.3 is -/// the value that would come back mangled from a float column. -#[tokio::test] -async fn multipliers_round_trip_exactly() -> anyhow::Result<()> { - let now = Utc::now(); - let values = [dec!(1), dec!(1.5), dec!(1.3), dec!(2.718281), dec!(5)]; - - let rows = values - .iter() - .enumerate() - .map(|(i, value)| valid_row(ticket(&hotspot(i as u8 + 1), *value, now, now))) - .collect(); - - let multipliers = multipliers_after_refresh(rows).await?; - - for (i, value) in values.iter().enumerate() { - assert_eq!( - multipliers.get(&hotspot(i as u8 + 1)), - multiplier(*value), - "{value} did not survive storage" - ); - } - - Ok(()) -} - -/// `1.5` and `1.50` are the same multiplier. Normalizing on parse is what keeps -/// a difference in spelling from becoming a difference in value — including -/// through the `decimal(9,6)` column, which returns everything scale-padded. -#[tokio::test] -async fn equivalent_spellings_are_one_multiplier() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let multipliers = - multipliers_after_refresh(vec![valid_row(ticket(&hotspot, dec!(1.50), now, now))]).await?; - - assert_eq!(multipliers.get(&hotspot), multiplier(dec!(1.5))); - - Ok(()) -} - -// ── The inventory table ───────────────────────────────────────────────────── -// -// The history is the log; the inventory is what is currently in force. A -// periodic MERGE builds the second from the first. - -mod inventory { +/// This is the gate between "we ruled on a ticket" and "a hotspot's data credits +/// are worth more". Only valid tickets pass it. +mod grants { use super::*; - use helium_iceberg_oracles::data_transfer::multiplier_ticket_inventory::IcebergMultiplierInventory; - use mobile_packet_verifier::multiplier::inventory::InventoryRefresher; - use std::time::Duration as StdDuration; - - /// Compare by value, not by formatting. Trino returns a `decimal(9,6)` - /// scale-padded — 1.5 comes back as "1.500000" — which says nothing about - /// whether the stored value is right. - fn stored(row: &IcebergMultiplierInventory) -> rust_decimal::Decimal { - row.multiplier - .as_string() - .parse() - .expect("stored multiplier parses") - } - - /// Seed history, run the merge, and read the inventory back. - async fn refresh_and_read( - rows: Vec, - ) -> anyhow::Result> { - let harness = common::setup_iceberg().await?; - seed(&harness, rows).await?; - let trino = trino_client::Client::from_client(harness.owned_trino().await?); - - InventoryRefresher::new_with_tables( - trino.clone(), - StdDuration::from_secs(900), - HISTORY_TABLE.to_string(), - INVENTORY_TABLE.to_string(), - ) - .refresh() - .await?; + use mobile_packet_verifier::multiplier::ingestor::granted_multiplier; - let mut rows: Vec = trino - .get_all_raw(format!("SELECT * FROM {INVENTORY_TABLE}")) - .await?; - rows.sort_by(|a, b| a.hotspot_pubkey.cmp(&b.hotspot_pubkey)); - Ok(rows) - } - - #[tokio::test] - async fn merges_the_latest_valid_ticket_per_hotspot() -> anyhow::Result<()> { + #[test] + fn a_valid_ticket_grants_its_multiplier() { let now = Utc::now(); - let (a, b) = (hotspot(1), hotspot(2)); - - let rows = refresh_and_read(vec![ - valid_row(ticket( - &a, - dec!(5), - now - Duration::hours(2), - now - Duration::hours(2), - )), - valid_row(ticket( - &a, - dec!(1.5), - now - Duration::hours(1), - now - Duration::hours(1), - )), - valid_row(ticket( - &b, - dec!(2), - now - Duration::hours(1), - now - Duration::hours(1), - )), - ]) - .await?; - - assert_eq!(rows.len(), 2, "one row per hotspot, not one per ticket"); - assert_eq!(stored(&rows[0]), dec!(1.5), "superseded 5 must not win"); - assert_eq!(stored(&rows[1]), dec!(2)); - - Ok(()) - } - - /// The inventory holds what is in force, so refusals are excluded. A refusal - /// also must not revoke an earlier grant. - #[tokio::test] - async fn refused_tickets_are_excluded() -> anyhow::Result<()> { - let hotspot = hotspot(1); - let now = Utc::now(); - - let rows = refresh_and_read(vec![ - valid_row(ticket( - &hotspot, - dec!(1.5), - now - Duration::hours(2), - now - Duration::hours(2), - )), - // Newer, larger, refused. - history_row( - ticket( - &hotspot, - dec!(5), - now - Duration::hours(1), - now - Duration::hours(1), - ), - Status::InvalidSigner, - ), - ]) - .await?; - - assert_eq!(rows.len(), 1); + let signed = now - Duration::minutes(1); + let grant = granted_multiplier(&verified( + ticket(&hotspot(1), dec!(1.5), signed, now), + Status::Valid, + )) + .expect("a valid ticket grants something"); + + assert_eq!(grant.hotspot_pubkey, hotspot(1)); assert_eq!( - stored(&rows[0]), - dec!(1.5), - "a refusal must not take effect, nor revoke the last valid grant" + grant.multiplier, + DataTransferMultiplier::new(dec!(1.5)).unwrap() ); - - Ok(()) + // The signed time, not the received one: the issuer says when the grant + // starts, and the history is ordered on the same value. + assert_eq!(grant.effective_timestamp, signed); } - /// **The merge test.** Running twice must update in place, not append a - /// second row per hotspot — that is the whole difference between a merge and - /// the append-only writer. - #[tokio::test] - async fn refreshing_twice_updates_in_place() -> anyhow::Result<()> { - let hotspot = hotspot(1); + /// A refused ticket grants nothing, whatever it was refused for. It still + /// gets a verified report and a history row; it just never reaches the burn. + #[test] + fn a_refused_ticket_grants_nothing() { let now = Utc::now(); - - let harness = common::setup_iceberg().await?; - seed( - &harness, - vec![valid_row(ticket( - &hotspot, - dec!(1.5), - now - Duration::hours(2), - now - Duration::hours(2), - ))], - ) - .await?; - let trino = trino_client::Client::from_client(harness.owned_trino().await?); - let refresher = InventoryRefresher::new_with_tables( - trino.clone(), - StdDuration::from_secs(900), - HISTORY_TABLE.to_string(), - INVENTORY_TABLE.to_string(), - ); - - refresher.refresh().await?; - - // A newer grant arrives, then we refresh again. - seed( - &harness, - vec![valid_row(ticket( - &hotspot, - dec!(5), - now - Duration::hours(1), - now - Duration::hours(1), - ))], - ) - .await?; - refresher.refresh().await?; - - let rows: Vec = trino - .get_all_raw(format!("SELECT * FROM {INVENTORY_TABLE}")) - .await?; - - assert_eq!( - rows.len(), - 1, - "the hotspot must have one row, not one per refresh" - ); - assert_eq!(stored(&rows[0]), dec!(5), "the row must have been updated"); - - Ok(()) + for status in [ + Status::InvalidSigner, + Status::InvalidMultiplier, + Status::InvalidHotspotKey, + Status::InvalidTimestamp, + ] { + let report = ticket(&hotspot(1), dec!(5), now - Duration::minutes(1), now); + assert!( + granted_multiplier(&verified(report, status)).is_none(), + "{} must not grant anything", + status.as_str_name() + ); + } } - #[tokio::test] - async fn no_tickets_leaves_the_inventory_empty() -> anyhow::Result<()> { - assert!(refresh_and_read(vec![]).await?.is_empty()); - Ok(()) + /// Belt and braces. `ticket_status` refuses an absent multiplier, so a + /// ticket cannot be both valid and empty unless the two checks have drifted + /// apart. If they ever do, grant nothing rather than guess. + #[test] + fn a_valid_ticket_with_no_multiplier_grants_nothing() { + let now = Utc::now(); + let report = ticket_with(&hotspot(1), None, now - Duration::minutes(1), now); + assert!(granted_multiplier(&verified(report, Status::Valid)).is_none()); } } -// ── The verdict rule ──────────────────────────────────────────────────────── -// -// Storage above decides which ticket wins. These decide whether a ticket counts -// at all — the second line of defence behind ingest, exercised here because -// ingest and this verifier are configured separately and either could be wrong. - +/// Whether a ticket counts at all. +/// +/// `ticket_status` is the gate. Everything it refuses still gets a verified +/// report and a history row, so a refusal is as visible as a grant. mod verdict { use super::*; use file_store_oracles::mobile::data_transfer_multiplier::MAX_CLOCK_DRIFT; diff --git a/mobile_verifier/tests/integrations/reward_dc.rs b/mobile_verifier/tests/integrations/reward_dc.rs index beb1af9cf..7fb28e0d3 100644 --- a/mobile_verifier/tests/integrations/reward_dc.rs +++ b/mobile_verifier/tests/integrations/reward_dc.rs @@ -410,6 +410,10 @@ impl DataSession { download_bytes: self.download_bytes, rewardable_bytes: self.rewardable_bytes, num_dcs: self.num_dcs, + // A row as written before HIP-150, so these also cover the + // backward-compatible path: rewards distribute pro-rata of num_dcs + // whether or not a multiplier produced it. + multiplier: None, first_timestamp: self.timestamp.into(), last_timestamp: self.timestamp.into(), burn_timestamp: self.timestamp.into(), diff --git a/mobile_verifier/tests/integrations/rewarder_dc_trino.rs b/mobile_verifier/tests/integrations/rewarder_dc_trino.rs index 36d57d2ec..cd3811d6f 100644 --- a/mobile_verifier/tests/integrations/rewarder_dc_trino.rs +++ b/mobile_verifier/tests/integrations/rewarder_dc_trino.rs @@ -25,6 +25,7 @@ fn make_burned_session( download_bytes: 0, rewardable_bytes, num_dcs, + multiplier: None, first_timestamp: burn.into(), last_timestamp: burn.into(), burn_timestamp: burn.into(), From 1e62e93d6a21f00a8b6c76c12e37f21da024cc5f Mon Sep 17 00:00:00 2001 From: Brian Balser Date: Sun, 30 Aug 2026 19:47:55 -0400 Subject: [PATCH 5/7] update multiplier to be an optional field in the iceberg table --- .../src/data_transfer/multiplier_ticket_history.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs index 1b42f5412..ac49a145f 100644 --- a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs @@ -69,7 +69,12 @@ pub fn table_definition() -> helium_iceberg::Result { FieldDefinition::required_timestamptz("signed_timestamp"), FieldDefinition::required_timestamptz("received_timestamp"), FieldDefinition::required_timestamptz("verified_timestamp"), - FieldDefinition::required_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), + // Optional because a refused ticket may carry no usable multiplier + // at all, and the row still has to record the refusal. A required + // column rejects the write instead — arrow refuses a null in a + // non-nullable field at flush — which fails the file's transaction + // and leaves the poller retrying it forever. + FieldDefinition::optional_decimal("multiplier", MULTIPLIER_PRECISION, MULTIPLIER_SCALE), FieldDefinition::required_string("signer"), FieldDefinition::required_string("message"), FieldDefinition::required_string("status"), From 81fefdfafe89e8aa0cda6b05f8b0d2a6670002d1 Mon Sep 17 00:00:00 2001 From: Brian Balser Date: Mon, 31 Aug 2026 07:36:10 -0400 Subject: [PATCH 6/7] Fixing bugs around multiplier value and idempotency --- Cargo.lock | 1 + Cargo.toml | 6 + helium_iceberg_oracles/Cargo.toml | 1 + .../multiplier_ticket_history.rs | 5 +- helium_iceberg_oracles/src/decimal.rs | 130 ++++++++++++++++-- mobile_packet_verifier/src/multiplier/db.rs | 30 +++- .../tests/integrations/apply_multiplier.rs | 107 ++++++++++++++ .../tests/integrations/multiplier_tickets.rs | 85 ++++++++++++ 8 files changed, 352 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e98bd7b0b..6e85430f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4032,6 +4032,7 @@ name = "helium-iceberg-oracles" version = "0.1.0" dependencies = [ "anyhow", + "bigdecimal", "chrono", "file-store-oracles", "helium-iceberg", diff --git a/Cargo.toml b/Cargo.toml index 5c71a43a2..2736c0fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,12 @@ anyhow = { version = "1", features = ["backtrace"] } async-stream = "0.3" async-trait = "*" base64 = ">=0.21" +# Must stay semver-compatible with the `bigdecimal` that `trino-rust-client` +# depends on: `IcebergDecimal` inspects the `BigDecimal` inside a +# `trino_rust_client::types::Decimal`, so a major-version split would be two +# incompatible types. Cargo unifies them today; a mismatch is a compile error, +# not a silent one. +bigdecimal = "0.4" bincode = "1" blake3 = "*" bs58 = { version = "0.5.1", features = ["check"] } diff --git a/helium_iceberg_oracles/Cargo.toml b/helium_iceberg_oracles/Cargo.toml index 4dc1b1901..a0e616890 100644 --- a/helium_iceberg_oracles/Cargo.toml +++ b/helium_iceberg_oracles/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] anyhow = { workspace = true } +bigdecimal = { workspace = true } chrono = { workspace = true } rust_decimal = { workspace = true } serde = { workspace = true } diff --git a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs index ac49a145f..7dbd4a3dc 100644 --- a/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs @@ -50,8 +50,9 @@ pub struct IcebergMultiplierTicket { /// `None` when the ticket carried no usable multiplier — absent, /// unparseable, or too large for the column. `status` records that it was /// refused, but not which of the three it was; all three are - /// `invalid_multiplier`. A value that is merely out of range *is* stored, - /// so the record shows what was asked for. + /// `invalid_multiplier`. A value that merely falls outside HIP-150's 1-to-5 + /// range but still fits the column *is* stored, so the record shows what + /// was asked for. pub multiplier: Option, pub signer: String, pub message: String, diff --git a/helium_iceberg_oracles/src/decimal.rs b/helium_iceberg_oracles/src/decimal.rs index 73c7d8c2c..34b1b10eb 100644 --- a/helium_iceberg_oracles/src/decimal.rs +++ b/helium_iceberg_oracles/src/decimal.rs @@ -16,6 +16,7 @@ use std::str::FromStr; +use bigdecimal::RoundingMode; use serde::{de::DeserializeSeed, Deserialize, Deserializer, Serialize, Serializer}; use trino_rust_client::{ types::{Context, Decimal as TrinoDecimal}, @@ -26,9 +27,22 @@ use trino_rust_client::{ #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct IcebergDecimal(TrinoDecimal); -#[derive(thiserror::Error, Debug)] -#[error("invalid decimal: {0}")] -pub struct ParseDecimalError(String); +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum ParseDecimalError { + #[error("invalid decimal: {0}")] + Unparseable(String), + /// The value is too large for the column it is destined for. + /// + /// Separate from [`Self::Unparseable`] because the two mean different + /// things to whoever is looking: one is a malformed value, the other a + /// well-formed value that this column cannot hold. + #[error("decimal {value} does not fit decimal({precision}, {scale})")] + OutOfRange { + value: String, + precision: usize, + scale: usize, + }, +} impl IcebergDecimal { pub fn as_string(&self) -> String { @@ -39,10 +53,47 @@ impl IcebergDecimal { impl FromStr for IcebergDecimal { type Err = ParseDecimalError; + /// Parses *and* range-checks against `P` and `S`. + /// + /// The range check is not redundant. `TrinoDecimal`'s own `FromStr` is a + /// `BigDecimal` parse that ignores both const parameters, so a value far + /// too large for the column parses happily here and fails much later — + /// when `arrow-json` decodes the row into a `Decimal128(P, S)` and reports + /// `parse decimal overflow`. That is a bad place to find out: it fails + /// mid-write, taking whatever transaction the write was part of with it. + /// Refusing here gives the caller a value it can act on. + /// + /// Excess *scale* is deliberately not refused. Arrow truncates it, as + /// Trino would, so `1.5000001` into a `decimal(9, 6)` column stores as + /// `1.500000`. Only magnitude is unrecoverable. fn from_str(s: &str) -> Result { - TrinoDecimal::from_str(s) - .map(Self) - .map_err(|_| ParseDecimalError(s.to_string())) + let inner = TrinoDecimal::::from_str(s) + .map_err(|_| ParseDecimalError::Unparseable(s.to_string()))?; + + // Rescaling to the column's scale first is what makes `digits()` the + // precision the column actually needs: it counts the unscaled integer, + // so it only answers the right question once the scale matches. + // + // `Down` (truncate toward zero), not `HalfUp`, because that is what + // arrow does — verified against `arrow-json`, which accepts + // `999.9999996` into a `decimal(9, 6)` by dropping the trailing digit. + // Rounding up here instead would carry it to `1000.000000`, ten digits, + // and refuse a value the writer would have taken. + let digits = inner + .clone() + .into_bigdecimal() + .with_scale_round(S as i64, RoundingMode::Down) + .digits(); + + if digits > P as u64 { + return Err(ParseDecimalError::OutOfRange { + value: s.to_string(), + precision: P, + scale: S, + }); + } + + Ok(Self(inner)) } } @@ -150,7 +201,70 @@ mod tests { #[test] fn rejects_nonsense() { - assert!(Multiplier::from_str("").is_err()); - assert!(Multiplier::from_str("abc").is_err()); + assert!(matches!( + Multiplier::from_str("").unwrap_err(), + ParseDecimalError::Unparseable(_) + )); + assert!(matches!( + Multiplier::from_str("abc").unwrap_err(), + ParseDecimalError::Unparseable(_) + )); + } + + /// The bug this guards: `TrinoDecimal`'s `FromStr` ignores `P` and `S`, so + /// without a check here an unstorable value parses fine and only fails + /// later, inside `arrow-json`, with `parse decimal overflow` — mid-write, + /// where it fails the enclosing transaction rather than the caller. + /// + /// `decimal(9, 6)` leaves three digits ahead of the point, so anything from + /// 1000 up cannot be stored. + #[test] + fn rejects_values_too_large_for_the_column() { + for input in ["1000", "12345678901234", "1E+20", "-1000"] { + assert!( + matches!( + Multiplier::from_str(input).unwrap_err(), + ParseDecimalError::OutOfRange { .. } + ), + "{input} must be refused, not deferred to the writer" + ); + } + } + + /// The boundary, so the check above is a real edge rather than a blanket + /// refusal of anything large. + /// + /// The last case is the one that pins the rounding mode. Arrow truncates + /// the extra digit and stores `999.999999`; rounding it up here instead + /// would make this a refusal and lose a value the writer would have taken. + /// Checked against `arrow-json` directly — every input in this test and in + /// `rejects_values_too_large_for_the_column` was run through the real + /// decoder, and the verdicts match. + #[test] + fn accepts_right_up_to_the_boundary() { + for input in ["999.999999", "-999.999999", "0", "999.9999996"] { + assert!( + Multiplier::from_str(input).is_ok(), + "{input} fits decimal(9, 6) and must not be refused" + ); + } + } + + /// Scale is not magnitude: arrow truncates extra fractional digits rather + /// than failing, so refusing them here would drop values the column can + /// perfectly well hold. + #[test] + fn tolerates_more_scale_than_the_column_keeps() { + let decimal = Multiplier::try_from(dec!(1.5000001)).expect("scale is not fatal"); + assert_eq!(decimal.as_string(), "1.5000001"); + } + + /// The path the ticket history actually uses. `try_from(...).ok()` has to + /// yield `None` for an unstorable multiplier — that is what lets the row be + /// written with a null and the refusal recorded. + #[test] + fn try_from_reports_none_for_an_unstorable_value() { + assert!(Multiplier::try_from(dec!(1.5)).is_ok()); + assert!(Multiplier::try_from(dec!(10000000000)).ok().is_none()); } } diff --git a/mobile_packet_verifier/src/multiplier/db.rs b/mobile_packet_verifier/src/multiplier/db.rs index 7f497a63d..54f5fb1f2 100644 --- a/mobile_packet_verifier/src/multiplier/db.rs +++ b/mobile_packet_verifier/src/multiplier/db.rs @@ -9,6 +9,8 @@ //! Append-only, because the burn asks what was in force when the data moved, not //! what is in force now. See [`crate::pending_burns::get_all`]. +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use file_store_oracles::mobile::data_transfer_multiplier::DataTransferMultiplier; use helium_crypto::PublicKeyBinary; @@ -32,6 +34,19 @@ pub struct GrantedMultiplier { /// /// Keyed on `(hotspot_pubkey, effective_timestamp)`, so reprocessing a file /// rewrites the same rows instead of adding to them. +/// +/// `granted` is deduplicated on that key before the insert. `ON CONFLICT` only +/// resolves a collision with a row already in the table — Postgres refuses a +/// statement that proposes the same key twice in one command, with "ON CONFLICT +/// DO UPDATE command cannot affect row a second time", and that would abort the +/// whole file's transaction. One file can carry the same key twice easily +/// enough: a client that retransmits a signed ticket sends the same +/// `(hotspot, timestamp)` again, and both copies land in the same ingest roll. +/// +/// Later wins, matching what `ON CONFLICT DO UPDATE` does across files, so a +/// duplicate resolves the same way whether or not it shares a file with the +/// grant it supersedes. [`crate::pending_burns::save_data_transfer_sessions`] +/// merges its own batch ahead of an upsert for the same reason. pub async fn save( txn: &mut Transaction<'_, Postgres>, granted: &[GrantedMultiplier], @@ -40,12 +55,21 @@ pub async fn save( return Ok(()); } - let hotspot_pubkeys: Vec = granted + // `HashMap` from an iterator of pairs keeps the last value for a repeated + // key, which is the "later wins" above. + let deduped: Vec<&GrantedMultiplier> = granted + .iter() + .map(|g| ((&g.hotspot_pubkey, g.effective_timestamp), g)) + .collect::>() + .into_values() + .collect(); + + let hotspot_pubkeys: Vec = deduped .iter() .map(|g| g.hotspot_pubkey.to_string()) .collect(); - let multipliers: Vec = granted.iter().map(|g| g.multiplier.as_decimal()).collect(); - let effective: Vec> = granted.iter().map(|g| g.effective_timestamp).collect(); + let multipliers: Vec = deduped.iter().map(|g| g.multiplier.as_decimal()).collect(); + let effective: Vec> = deduped.iter().map(|g| g.effective_timestamp).collect(); sqlx::query( r#" diff --git a/mobile_packet_verifier/tests/integrations/apply_multiplier.rs b/mobile_packet_verifier/tests/integrations/apply_multiplier.rs index bf111b4e5..eddb8f636 100644 --- a/mobile_packet_verifier/tests/integrations/apply_multiplier.rs +++ b/mobile_packet_verifier/tests/integrations/apply_multiplier.rs @@ -825,6 +825,113 @@ mod which_grant_wins { Ok(()) } + /// One file can carry the same ticket twice — a client that retransmits a + /// signed ticket resends the same `(hotspot, timestamp)`, and both copies + /// land in the same ingest roll. They reach `db::save` as one batch. + /// + /// Postgres refuses a statement proposing the same conflict key twice + /// ("ON CONFLICT DO UPDATE command cannot affect row a second time"), which + /// would abort the file's whole transaction and leave the poller retrying + /// it forever. So the batch is deduplicated before the insert. + #[sqlx::test] + async fn a_ticket_repeated_within_one_file_is_not_an_error(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let issued = Utc::now() - Duration::hours(1); + + let repeated = |value| GrantedMultiplier { + hotspot_pubkey: gw.clone(), + multiplier: DataTransferMultiplier::new(value).expect("valid multiplier"), + effective_timestamp: issued, + }; + + let mut txn = pool.begin().await?; + db::save(&mut txn, &[repeated(dec!(1.5)), repeated(dec!(1.5))]).await?; + txn.commit().await?; + + let stored: Vec = sqlx::query_scalar( + "SELECT multiplier FROM data_transfer_multipliers WHERE hotspot_pubkey = $1", + ) + .bind(gw.to_string()) + .fetch_all(&pool) + .await?; + + assert_eq!(stored, vec![dec!(1.5)], "one grant, recorded once"); + + Ok(()) + } + + /// The same key twice in a batch with *different* values resolves the way it + /// would across two files: the later one wins. Otherwise a duplicate would + /// mean something different depending on whether it shared an ingest roll + /// with the grant it supersedes. + #[sqlx::test] + async fn the_last_grant_in_a_batch_wins(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let issued = Utc::now() - Duration::hours(1); + + let at_issued = |value| GrantedMultiplier { + hotspot_pubkey: gw.clone(), + multiplier: DataTransferMultiplier::new(value).expect("valid multiplier"), + effective_timestamp: issued, + }; + + let mut txn = pool.begin().await?; + db::save(&mut txn, &[at_issued(dec!(5)), at_issued(dec!(1.5))]).await?; + txn.commit().await?; + + let stored: Vec = sqlx::query_scalar( + "SELECT multiplier FROM data_transfer_multipliers WHERE hotspot_pubkey = $1", + ) + .bind(gw.to_string()) + .fetch_all(&pool) + .await?; + + assert_eq!(stored, vec![dec!(1.5)], "the later grant in the batch wins"); + + Ok(()) + } + + /// Deduplication is on the whole key, not just the hotspot: two grants for + /// one hotspot at different instants are two distinct rows and must both + /// survive the batch. + #[sqlx::test] + async fn distinct_instants_are_not_collapsed(pool: PgPool) -> anyhow::Result<()> { + let gw = gateway(1); + let now = Utc::now(); + + let at = |value, effective| GrantedMultiplier { + hotspot_pubkey: gw.clone(), + multiplier: DataTransferMultiplier::new(value).expect("valid multiplier"), + effective_timestamp: effective, + }; + + let mut txn = pool.begin().await?; + db::save( + &mut txn, + &[ + at(dec!(5), now - Duration::hours(3)), + at(dec!(1.5), now - Duration::hours(1)), + ], + ) + .await?; + txn.commit().await?; + + let stored: Vec = sqlx::query_scalar( + r#" + SELECT multiplier FROM data_transfer_multipliers + WHERE hotspot_pubkey = $1 + ORDER BY effective_timestamp + "#, + ) + .bind(gw.to_string()) + .fetch_all(&pool) + .await?; + + assert_eq!(stored, vec![dec!(5), dec!(1.5)], "both grants kept"); + + Ok(()) + } + /// Values survive Postgres `NUMERIC` unchanged, including one that a float /// would mangle. The multiplier decides what a payer is charged, so an /// approximation here is a wrong bill. diff --git a/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs index 0bda1a9e5..dddd9132c 100644 --- a/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs +++ b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs @@ -350,3 +350,88 @@ mod verdict { Ok(()) } } + +/// Writing a ruled-on ticket to the Iceberg history table. +/// +/// The audit record has to hold refusals, and a refused ticket is exactly the +/// one that may carry no multiplier the column can store. That makes the null +/// case ordinary here, not an edge. +mod history_rows { + use super::*; + use mobile_packet_verifier::iceberg::{multiplier_ticket_history, IcebergMultiplierTicket}; + + async fn write(row: IcebergMultiplierTicket) -> anyhow::Result> { + let harness = common::setup_iceberg().await?; + let writer = harness + .get_table_writer::(multiplier_ticket_history::TABLE_NAME) + .await?; + + writer.write(vec![row]).await?; + + multiplier_ticket_history::get_all(harness.trino()).await + } + + /// A ticket with no usable multiplier stores a null and keeps its verdict. + /// + /// The `multiplier` column has to be optional for this. Arrow refuses a null + /// in a non-nullable field when it flushes the batch, which fails the write, + /// which fails the transaction the file was being processed in — so the + /// poller retries the same file forever and no ticket is ever recorded. + #[tokio::test] + async fn a_refused_ticket_with_no_multiplier_still_writes() -> anyhow::Result<()> { + let now = Utc::now(); + let verified = verified( + ticket_with(&hotspot(1), None, now, now), + Status::InvalidMultiplier, + ); + + let rows = write(IcebergMultiplierTicket::from(&verified)).await?; + + assert_eq!(rows.len(), 1, "the refusal is on the record"); + assert!(rows[0].multiplier.is_none(), "no multiplier to record"); + assert_eq!(rows[0].status, "invalid_multiplier"); + + Ok(()) + } + + /// A multiplier too large for `decimal(9, 6)` is refused while the row is + /// built, so it lands as a null instead of failing the flush with `parse + /// decimal overflow`. + #[tokio::test] + async fn a_multiplier_too_large_for_the_column_writes_as_null() -> anyhow::Result<()> { + let now = Utc::now(); + let verified = verified( + ticket_with(&hotspot(1), Some(dec!(10000000000)), now, now), + Status::InvalidMultiplier, + ); + + let rows = write(IcebergMultiplierTicket::from(&verified)).await?; + + assert_eq!(rows.len(), 1); + assert!( + rows[0].multiplier.is_none(), + "an unstorable value must not reach the column" + ); + + Ok(()) + } + + /// The ordinary case, so the two above are about nulls rather than the table + /// accepting anything at all. + #[tokio::test] + async fn a_granted_ticket_records_its_multiplier() -> anyhow::Result<()> { + let now = Utc::now(); + let verified = verified(ticket(&hotspot(1), dec!(1.5), now, now), Status::Valid); + + let rows = write(IcebergMultiplierTicket::from(&verified)).await?; + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].multiplier.as_ref().map(|m| m.as_string()), + Some("1.500000".to_string()) + ); + assert_eq!(rows[0].status, "valid"); + + Ok(()) + } +} From 77220589d0034f48b430fd8c81d0141ac8c34fd0 Mon Sep 17 00:00:00 2001 From: macpie Date: Mon, 31 Aug 2026 11:40:09 -0700 Subject: [PATCH 7/7] Bring back proto to latest master after https://github.com/helium/proto/pull/483 merged --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 6 +++--- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e85430f4..60ae760bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,17 +1715,17 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "beacon" version = "0.1.0" -source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" +source = "git+https://github.com/helium/proto?branch=master#e01eabb3b9fc27eb27d0a080cefb8d406a5f29d7" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "byteorder", "helium-proto", "prost", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand 0.7.3", + "rand_chacha 0.2.2", "rust_decimal", "serde", - "sha2 0.10.9", + "sha2 0.9.9", "thiserror 1.0.69", ] @@ -1767,7 +1767,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -3192,7 +3192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3344,7 +3344,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "beacon", "blake3", "bs58", @@ -3974,7 +3974,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d292d4e2852445cbb610b515543a56b10d4a6fad90cfd6d281fe870f628573e" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "bs58", "byteorder", "ed25519-compact", @@ -4055,7 +4055,7 @@ dependencies = [ "angry-purple-tiger", "async-trait", "backon", - "base64 0.22.1", + "base64 0.21.7", "bincode", "bytemuck", "chrono", @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "helium-proto" version = "0.1.0" -source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" +source = "git+https://github.com/helium/proto?branch=master#e01eabb3b9fc27eb27d0a080cefb8d406a5f29d7" dependencies = [ "bytes", "msg-signature", @@ -4430,7 +4430,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration 0.6.1", "tokio", "tower-service", @@ -4729,7 +4729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -4915,7 +4915,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5155,7 +5155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.5", ] [[package]] @@ -5471,7 +5471,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "blake3", "bs58", "chrono", @@ -5528,7 +5528,7 @@ version = "0.1.0" dependencies = [ "angry-purple-tiger", "anyhow", - "base64 0.22.1", + "base64 0.21.7", "clap", "custom-tracing", "dialoguer", @@ -5599,7 +5599,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "chrono", "clap", "config", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "msg-signature" version = "0.1.0" -source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" +source = "git+https://github.com/helium/proto?branch=master#e01eabb3b9fc27eb27d0a080cefb8d406a5f29d7" dependencies = [ "msg-signature-macro", ] @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "msg-signature-macro" version = "0.1.0" -source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" +source = "git+https://github.com/helium/proto?branch=master#e01eabb3b9fc27eb27d0a080cefb8d406a5f29d7" dependencies = [ "quote", "syn 2.0.106", @@ -6592,7 +6592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", - "itertools 0.10.5", + "itertools 0.12.1", "log", "multimap", "once_cell", @@ -6614,7 +6614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.106", @@ -6738,7 +6738,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.19", "tokio", "tracing", @@ -6778,7 +6778,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -7230,7 +7230,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "bs58", "chrono", "clap", @@ -7481,7 +7481,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8097,7 +8097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11391,7 +11391,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2736c0fe1..7160b7652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,6 +178,6 @@ anchor-lang = { git = "https://github.com/madninja/anchor.git", branch = "madnin # mj/hip-150 (helium/proto#483) and are not on master yet. # REVERT BEFORE MERGING mj/hip-150 TO main — a feature-branch patch must not # reach main. Tracked on the pre-deploy checklist. -helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } -beacon = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } -msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +# helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +# beacon = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +# msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" }