From e4e2716e456d867fcd7fc7ea29cd8d57e5e38ec8 Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Tue, 25 Aug 2026 11:32:13 -0700 Subject: [PATCH 1/2] HIP-150: accept data transfer multiplier tickets in ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds submit_data_transfer_multiplier_ticket. A ticket grants one hotspot a multiplier on the data credits derived from its rewardable bytes. Ingest verifies the signer and the timestamp, then persists the ticket verbatim. It does not parse the multiplier — the packet verifier decides whether one is acceptable and records that verdict in a verified report, so a rejection stays as auditable as a grant. Notes: - Ticket signers get their own allow-list and is_ticket_signer() check, rather than a NetworkKeyRole variant: that enum belongs to the decommissioned mobile-config. A carrier key must not be able to grant a multiplier. - The list may be empty and defaults to empty, since the release ships before any ticket can be issued. Empty rejects every ticket and warns at startup. - Tickets older than data_transfer_multiplier_ticket_max_age (default 10 minutes) or dated in the future are refused. Signatures never expire, so without this a captured ticket is replayable forever. - valid_data_transfer_session gained a multiplier field with the proto bump; set to None here, so behaviour is unchanged. Cargo.toml patches helium-proto to proto's mj/hip-150 (helium/proto#483). REVERT BEFORE MERGING mj/hip-150 TO main. --- Cargo.lock | 38 ++--- Cargo.toml | 10 +- file_store_oracles/src/file_type.rs | 1 + .../src/mobile/mobile_transfer.rs | 5 + .../src/traits/file_sink_write.rs | 5 + ingest/pkg/settings-template.toml | 16 ++ ingest/src/authorization.rs | 50 +++++- ingest/src/server_mobile.rs | 110 ++++++++++++- ingest/src/settings.rs | 65 ++++++-- ingest/tests/common/mod.rs | 94 ++++++++++- ingest/tests/mobile_ingest.rs | 147 +++++++++++++++++- 11 files changed, 491 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3b128c9a..a1482e714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,17 +1715,17 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "beacon" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#e81cec790eca9a5bd3bebc8df78b4eb190949013" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "byteorder", "helium-proto", "prost", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand 0.7.3", + "rand_chacha 0.2.2", "rust_decimal", "serde", - "sha2 0.10.9", + "sha2 0.9.9", "thiserror 1.0.69", ] @@ -1767,7 +1767,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -3344,7 +3344,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "beacon", "blake3", "bs58", @@ -3974,7 +3974,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d292d4e2852445cbb610b515543a56b10d4a6fad90cfd6d281fe870f628573e" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "bs58", "byteorder", "ed25519-compact", @@ -4051,7 +4051,7 @@ dependencies = [ "angry-purple-tiger", "async-trait", "backon", - "base64 0.22.1", + "base64 0.21.7", "bincode", "bytemuck", "chrono", @@ -4089,7 +4089,7 @@ dependencies = [ [[package]] name = "helium-proto" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "bytes", "msg-signature", @@ -5467,7 +5467,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "blake3", "bs58", "chrono", @@ -5524,7 +5524,7 @@ version = "0.1.0" dependencies = [ "angry-purple-tiger", "anyhow", - "base64 0.22.1", + "base64 0.21.7", "clap", "custom-tracing", "dialoguer", @@ -5594,7 +5594,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "chrono", "clap", "config", @@ -5670,7 +5670,7 @@ dependencies = [ [[package]] name = "msg-signature" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "msg-signature-macro", ] @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "msg-signature-macro" version = "0.1.0" -source = "git+https://github.com/helium/proto?branch=master#ba28806c19158db7e25e8a7f462a3c3cb2e7b85f" +source = "git+https://www.github.com/helium/proto.git?branch=mj%2Fhip-150#0e1d14433cc7350cc4a13938fe23b6f12e4b3d7d" dependencies = [ "quote", "syn 2.0.106", @@ -6587,7 +6587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", - "itertools 0.14.0", + "itertools 0.12.1", "log", "multimap", "once_cell", @@ -6609,7 +6609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.106", @@ -7225,7 +7225,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.21.7", "bs58", "chrono", "clap", @@ -12548,7 +12548,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 22c9614e6..5c71a43a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,10 @@ anchor-lang = { git = "https://github.com/madninja/anchor.git", branch = "madnin # beacon = { path = "../proto/beacon" } [patch.'https://github.com/helium/proto'] -# helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } -# beacon = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } -# msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "gateway-info-v4" } +# HIP-150: the data transfer multiplier ticket messages live on proto's +# mj/hip-150 (helium/proto#483) and are not on master yet. +# REVERT BEFORE MERGING mj/hip-150 TO main — a feature-branch patch must not +# reach main. Tracked on the pre-deploy checklist. +helium-proto = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +beacon = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } +msg-signature = { git = "https://www.github.com/helium/proto.git", branch = "mj/hip-150" } diff --git a/file_store_oracles/src/file_type.rs b/file_store_oracles/src/file_type.rs index 1c18573ca..c5b779074 100644 --- a/file_store_oracles/src/file_type.rs +++ b/file_store_oracles/src/file_type.rs @@ -153,5 +153,6 @@ make_string_mapped_enum! { EntityOwnershipChangeReport => "entity_ownership_change_report", EntityRewardDestinationChangeReport => "entity_reward_destination_change_report", EnabledCarriersInfoReport => "enabled_carriers_report", + DataTransferMultiplierTicketIngestReport => "data_transfer_multiplier_ticket_ingest_report", } } diff --git a/file_store_oracles/src/mobile/mobile_transfer.rs b/file_store_oracles/src/mobile/mobile_transfer.rs index 52893ef81..aab9fb89d 100644 --- a/file_store_oracles/src/mobile/mobile_transfer.rs +++ b/file_store_oracles/src/mobile/mobile_transfer.rs @@ -57,6 +57,11 @@ impl From for proto::ValidDataTransferSession { last_timestamp: v.last_timestamp.encode_timestamp_millis(), rewardable_bytes: v.rewardable_bytes, burn_timestamp: v.burn_timestamp.encode_timestamp_millis(), + // HIP-150: populated once mobile-packet-verifier applies multipliers. + // Absent means no multiplier was in force, which is what every + // session is until then — so this preserves current behaviour + // exactly rather than asserting a 1x that was never looked up. + multiplier: None, } } } diff --git a/file_store_oracles/src/traits/file_sink_write.rs b/file_store_oracles/src/traits/file_sink_write.rs index cf0b33948..4db2ef651 100644 --- a/file_store_oracles/src/traits/file_sink_write.rs +++ b/file_store_oracles/src/traits/file_sink_write.rs @@ -337,3 +337,8 @@ impl_file_sink!( FileType::EnabledCarriersInfoReport.to_str(), "enabled_carriers_report" ); +impl_file_sink!( + poc_mobile::DataTransferMultiplierTicketIngestReportV1, + FileType::DataTransferMultiplierTicketIngestReport.to_str(), + "data_transfer_multiplier_ticket_ingest_report" +); diff --git a/ingest/pkg/settings-template.toml b/ingest/pkg/settings-template.toml index ca0a07f5d..9e0019327 100644 --- a/ingest/pkg/settings-template.toml +++ b/ingest/pkg/settings-template.toml @@ -31,6 +31,22 @@ 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. +# +# 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..49c8288f3 100644 --- a/ingest/src/server_mobile.rs +++ b/ingest/src/server_mobile.rs @@ -8,9 +8,11 @@ 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 +31,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 +60,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 +113,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 +139,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 +189,46 @@ 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 dated in the future, or older than the + /// configured window. + /// + /// 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. + fn verify_ticket_freshness(&self, signed_ms: u64, received_ms: u64) -> VerifyResult<()> { + let Some(age_ms) = received_ms.checked_sub(signed_ms) else { + return Err(Status::invalid_argument( + "ticket timestamp_ms is in the future", + )); + }; + + 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 +728,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 +927,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 +972,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 +1004,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..22645ec45 100644 --- a/ingest/tests/mobile_ingest.rs +++ b/ingest/tests/mobile_ingest.rs @@ -2,8 +2,9 @@ use chrono::{TimeZone, Utc}; use common::generate_keypair; 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 +469,145 @@ 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 future-dated ticket is refused outright rather than treated as maximally +/// fresh — 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(()) +} + +/// A ticket right at the edge of the window is still accepted — pins that the +/// comparison is inclusive and that the window is not accidentally zero. +#[tokio::test] +async fn multiplier_ticket_accepts_timestamp_inside_window() -> anyhow::Result<()> { + let (mut client, trigger) = common::setup_mobile().await?; + + let pubkey = PublicKeyBinary::from_str(PUBKEY1)?; + let inside = (Utc::now() - chrono::Duration::from_std(common::TICKET_MAX_AGE)? + + chrono::Duration::minutes(1)) + .timestamp_millis() as u64; + + client + .submit_multiplier_ticket(pubkey.into(), "1.5", inside) + .await?; + + let report = client.multiplier_ticket_recv().await?; + assert_eq!(report.report.expect("inner").timestamp_ms, inside); + + trigger.trigger(); + Ok(()) +} From 5d5c5bdd3e7f57a8f558cd4872befc2894617a33 Mon Sep 17 00:00:00 2001 From: Michael Jeffrey Date: Wed, 26 Aug 2026 10:19:22 -0700 Subject: [PATCH 2/2] HIP-150: tolerate client clock drift on multiplier tickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client does not share a clock with ingest, so a ticket signed at what the client believes is "now" can arrive stamped slightly ahead of us. Those were refused as post-dated, which is a confusing failure for an honest client with a drifting clock. Tickets up to MAX_CLOCK_DRIFT (1 minute) in the future are now treated as current. Beyond that they are still refused: post-dating must not buy an attacker a longer replay window than an honest client gets, and a ticket inside the allowance still ages out of the freshness window at the same rate, it just starts a minute earlier. The allowance is a shared constant in file-store-oracles rather than a setting in each service. The packet verifier checks freshness too, and measures a ticket's age against the timestamp ingest stamped on it — so if ingest tolerated drift the verifier did not, every ticket ingest accepted from a fast client would be refused downstream. One value, not two that can be configured apart. --- .../src/mobile/data_transfer_multiplier.rs | 21 ++++++++ file_store_oracles/src/mobile/mod.rs | 1 + ingest/pkg/settings-template.toml | 5 ++ ingest/src/server_mobile.rs | 29 +++++++--- ingest/tests/mobile_ingest.rs | 53 +++++++++++++++++-- 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 file_store_oracles/src/mobile/data_transfer_multiplier.rs diff --git a/file_store_oracles/src/mobile/data_transfer_multiplier.rs b/file_store_oracles/src/mobile/data_transfer_multiplier.rs new file mode 100644 index 000000000..5b29c507c --- /dev/null +++ b/file_store_oracles/src/mobile/data_transfer_multiplier.rs @@ -0,0 +1,21 @@ +//! HIP-150 data transfer multipliers, and the tickets that grant them. + +use std::time::Duration; + +/// How far ahead of the receiving oracle's clock a ticket may be stamped. +/// +/// Clients do not share a clock with ingest, so a ticket signed at what the +/// client believes is "now" can arrive stamped slightly in the future. Without +/// some tolerance those are rejected as post-dated, which is a confusing failure +/// for an honest client with a drifting clock. +/// +/// One minute is enough for ordinary NTP-less drift and short enough that it +/// buys an attacker nothing: a post-dated ticket still ages out of the freshness +/// window at the same rate, it just starts a minute earlier. +/// +/// **Shared deliberately.** Ingest and the packet verifier both check freshness, +/// and the verifier measures a ticket's age against the timestamp *ingest* +/// stamped on it. If ingest tolerated drift the verifier did not, every ticket +/// ingest accepted from a fast client would then be refused downstream — so the +/// two must use one value, not two settings that can be configured apart. +pub const MAX_CLOCK_DRIFT: Duration = Duration::from_secs(60); diff --git a/file_store_oracles/src/mobile/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/ingest/pkg/settings-template.toml b/ingest/pkg/settings-template.toml index 9e0019327..2895dcd79 100644 --- a/ingest/pkg/settings-template.toml +++ b/ingest/pkg/settings-template.toml @@ -45,6 +45,11 @@ carrier_authorized_keys = "key1,key2" # 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] diff --git a/ingest/src/server_mobile.rs b/ingest/src/server_mobile.rs index 49c8288f3..74a8a0124 100644 --- a/ingest/src/server_mobile.rs +++ b/ingest/src/server_mobile.rs @@ -2,6 +2,7 @@ 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}; @@ -204,18 +205,34 @@ where Ok(()) } - /// HIP-150: refuse a ticket dated in the future, or older than the - /// configured window. + /// 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 Some(age_ms) = received_ms.checked_sub(signed_ms) else { - return Err(Status::invalid_argument( - "ticket timestamp_ms is in the future", - )); + 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; diff --git a/ingest/tests/mobile_ingest.rs b/ingest/tests/mobile_ingest.rs index 22645ec45..d3a39cc4b 100644 --- a/ingest/tests/mobile_ingest.rs +++ b/ingest/tests/mobile_ingest.rs @@ -1,5 +1,6 @@ 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, DataTransferMultiplierTicketReqV1, DataTransferRadioAccessTechnology, @@ -570,9 +571,9 @@ async fn multiplier_ticket_rejects_stale_timestamp() -> anyhow::Result<()> { Ok(()) } -/// A future-dated ticket is refused outright rather than treated as maximally -/// fresh — otherwise post-dating would buy an attacker an arbitrarily long -/// replay window. +/// 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?; @@ -590,6 +591,52 @@ async fn multiplier_ticket_rejects_future_timestamp() -> anyhow::Result<()> { 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]