diff --git a/Cargo.lock b/Cargo.lock index a3b128c9a..6e85430f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,7 +1715,7 @@ 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", "byteorder", @@ -1767,7 +1767,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "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]] @@ -4032,11 +4032,15 @@ name = "helium-iceberg-oracles" version = "0.1.0" dependencies = [ "anyhow", + "bigdecimal", "chrono", "file-store-oracles", "helium-iceberg", "helium-proto", + "rust_decimal", "serde", + "serde_json", + "thiserror 1.0.69", "trino-rust-client", ] @@ -4089,7 +4093,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", @@ -4426,7 +4430,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 +4729,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 +4915,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5151,7 +5155,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]] @@ -5570,6 +5574,7 @@ dependencies = [ "prost", "reqwest 0.12.28", "retainer", + "rust_decimal", "serde", "serde_json", "sha2 0.10.9", @@ -5670,7 +5675,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 +5683,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 +6592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -6609,7 +6614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.106", @@ -6733,7 +6738,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 +6778,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -7476,7 +7481,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8092,7 +8097,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 +11391,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12548,7 +12553,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..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"] } @@ -168,6 +174,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..c9dcefa6e 100644 --- a/file_store_oracles/src/file_type.rs +++ b/file_store_oracles/src/file_type.rs @@ -153,5 +153,7 @@ 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", + 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 new file mode 100644 index 000000000..69f28a04e --- /dev/null +++ b/file_store_oracles/src/mobile/data_transfer_multiplier.rs @@ -0,0 +1,614 @@ +//! 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 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; + +/// 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 +/// 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. +/// +/// 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: 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. +/// +/// 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 { + /// 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; + + /// 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(), + } + } + + 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/mobile/mobile_transfer.rs b/file_store_oracles/src/mobile/mobile_transfer.rs index 52893ef81..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,6 +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(), + // 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/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..2de51485a 100644 --- a/file_store_oracles/src/traits/file_sink_write.rs +++ b/file_store_oracles/src/traits/file_sink_write.rs @@ -337,3 +337,13 @@ 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" +); +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..a0e616890 100644 --- a/helium_iceberg_oracles/Cargo.toml +++ b/helium_iceberg_oracles/Cargo.toml @@ -8,10 +8,16 @@ license.workspace = true [dependencies] anyhow = { workspace = true } +bigdecimal = { 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/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 404fae639..6bc751020 100644 --- a/helium_iceberg_oracles/src/data_transfer/mod.rs +++ b/helium_iceberg_oracles/src/data_transfer/mod.rs @@ -8,13 +8,17 @@ //! - `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. pub mod burned_session; pub mod invalid_session; +pub mod multiplier_ticket_history; pub mod session; pub use burned_session::IcebergBurnedDataTransferSession; pub use invalid_session::IcebergInvalidDataTransferSession; +pub use multiplier_ticket_history::IcebergMultiplierTicket; 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..7dbd4a3dc --- /dev/null +++ b/helium_iceberg_oracles/src/data_transfer/multiplier_ticket_history.rs @@ -0,0 +1,132 @@ +//! `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, and recording refusals +//! too means the record answers "why is this hotspot not multiplied" as well as +//! "why is it". +//! +//! 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::{ + ticket_status_string, 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 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, + /// 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, +} + +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"), + // 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"), + ]) + // 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() +} + +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: ticket_status_string(verified.status).to_string(), + } + } +} diff --git a/helium_iceberg_oracles/src/decimal.rs b/helium_iceberg_oracles/src/decimal.rs new file mode 100644 index 000000000..34b1b10eb --- /dev/null +++ b/helium_iceberg_oracles/src/decimal.rs @@ -0,0 +1,270 @@ +//! 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 bigdecimal::RoundingMode; +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, 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 { + self.0.clone().into_bigdecimal().to_string() + } +} + +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 { + 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)) + } +} + +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!(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/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/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(()) +} 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/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 e1a50fb05..7ade4774a 100644 --- a/mobile_packet_verifier/pkg/settings-template.toml +++ b/mobile_packet_verifier/pkg/settings-template.toml @@ -151,3 +151,47 @@ bucket = "helium-mainnet-mobile-verified" # Region for bucket. Defaults to below # region = "us-west-2" + +# HIP-150 data transfer multiplier tickets +# +# 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] + +# 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. +# +# 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. 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 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/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 db526f5cd..617aea389 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,18 @@ 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, + ) + .await?; TaskManager::builder() .add_task(file_upload_server) @@ -374,6 +400,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..03bfd5bc2 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, 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,16 @@ 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(), - )) + 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/db.rs b/mobile_packet_verifier/src/multiplier/db.rs new file mode 100644 index 000000000..54f5fb1f2 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/db.rs @@ -0,0 +1,91 @@ +//! 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 std::collections::HashMap; + +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. +/// +/// `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], +) -> anyhow::Result<()> { + if granted.is_empty() { + return Ok(()); + } + + // `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 = deduped.iter().map(|g| g.multiplier.as_decimal()).collect(); + let effective: Vec> = deduped.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 new file mode 100644 index 000000000..5ac1d3e30 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/ingestor.rs @@ -0,0 +1,265 @@ +//! 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 refusals +//! are on the record too. Every ticket also gets a row in the Iceberg history +//! table, tagged with the verdict. +//! +//! 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, ticket_status_string, + 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::{ + db::{self, GrantedMultiplier}, + TicketSigners, VerifiedTicketSink, +}; + +pub struct TicketIngestor { + /// 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, + 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"); + + // 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?; + + // 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)); + } + + 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?; + } + + 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 = ticket_status_string(status), + "rejecting data transfer multiplier ticket" + ); + } + + Ok(verified) + } +} + +/// 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 +/// 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/mod.rs b/mobile_packet_verifier/src/multiplier/mod.rs new file mode 100644 index 000000000..a698ec427 --- /dev/null +++ b/mobile_packet_verifier/src/multiplier/mod.rs @@ -0,0 +1,146 @@ +//! HIP-150 data transfer multiplier tickets. +//! +//! 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. +//! +//! 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. +//! +//! A ruled-on ticket goes to two places: +//! +//! * `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::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, + 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 db; +pub mod ingestor; + +#[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. The two are + /// deployed and configured separately, and it is this verdict that lands on + /// the record. + /// + /// Empty by default, which rejects every ticket. Nothing can be granted + /// until a signer is provisioned. + #[serde(default)] + pub authorized_keys: String, + /// How old a ticket's signed timestamp may be, measured against the time + /// ingest stamped on it, before it is refused. + /// + /// 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, +} + +fn default_ingest_start_after() -> DateTime { + DateTime::UNIX_EPOCH +} + +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()) + } +} + +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, +) -> 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, + ); + + Ok(TaskManager::builder() + .add_task(report_server) + .add_task(verified_sink_server) + .add_task(task_manager::channel_consumer(ingestor)) + .build()) +} + +/// Type alias so the sink type is spelled once. +pub type VerifiedTicketSink = FileSinkClient; 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/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/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..eddb8f636 --- /dev/null +++ b/mobile_packet_verifier/tests/integrations/apply_multiplier.rs @@ -0,0 +1,958 @@ +//! 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(()) + } + + /// 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. + #[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 e51b524fc..8f2f240c0 100644 --- a/mobile_packet_verifier/tests/integrations/common/mod.rs +++ b/mobile_packet_verifier/tests/integrations/common/mod.rs @@ -10,6 +10,7 @@ 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()?, 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..44db5f7db 100644 --- a/mobile_packet_verifier/tests/integrations/main.rs +++ b/mobile_packet_verifier/tests/integrations/main.rs @@ -1,8 +1,11 @@ pub mod common; pub mod accumulate_sessions; +pub mod apply_multiplier; 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..dddd9132c --- /dev/null +++ b/mobile_packet_verifier/tests/integrations/multiplier_tickets.rs @@ -0,0 +1,437 @@ +//! HIP-150 data transfer multiplier tickets: whether a ticket counts, and what +//! it grants. +//! +//! `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 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::{ + DataTransferMultiplier, DataTransferMultiplierTicket, DataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketReport, + VerifiedDataTransferMultiplierTicketStatus as Status, +}; +use helium_crypto::PublicKeyBinary; +use rust_decimal::dec; + +use crate::common; + +fn hotspot(byte: u8) -> PublicKeyBinary { + PublicKeyBinary::from(vec![byte]) +} + +/// 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![], + }, + } +} + +/// What the ingestor would hold after ruling on a ticket. +fn verified( + report: DataTransferMultiplierTicketReport, + status: Status, +) -> VerifiedDataTransferMultiplierTicketReport { + VerifiedDataTransferMultiplierTicketReport { + verified_timestamp: report.received_timestamp, + report, + status, + } +} + +/// What a verified ticket grants, and to whom. +/// +/// 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 mobile_packet_verifier::multiplier::ingestor::granted_multiplier; + + #[test] + fn a_valid_ticket_grants_its_multiplier() { + let now = Utc::now(); + 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!( + grant.multiplier, + DataTransferMultiplier::new(dec!(1.5)).unwrap() + ); + // 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); + } + + /// 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(); + 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() + ); + } + } + + /// 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()); + } +} + +/// 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; + 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(()) + } +} + +/// 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(()) + } +} 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..7fb28e0d3 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" @@ -409,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(), 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(()) }