From 4701dfd9a36d87cd01de4dfcd45be2f20126233e Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Fri, 19 Jun 2026 12:57:05 +0000 Subject: [PATCH 1/4] dark launch tracker --- rs/https_outcalls/client/src/client.rs | 6 +- rs/https_outcalls/pricing/BUILD.bazel | 6 + rs/https_outcalls/pricing/Cargo.toml | 7 +- rs/https_outcalls/pricing/src/dark_launch.rs | 269 +++++++++++++++++++ rs/https_outcalls/pricing/src/legacy.rs | 7 + rs/https_outcalls/pricing/src/lib.rs | 62 ++++- rs/https_outcalls/pricing/src/metrics.rs | 36 +++ rs/https_outcalls/pricing/src/payg.rs | 268 ++++++++++++++++++ 8 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 rs/https_outcalls/pricing/src/dark_launch.rs create mode 100644 rs/https_outcalls/pricing/src/metrics.rs create mode 100644 rs/https_outcalls/pricing/src/payg.rs diff --git a/rs/https_outcalls/client/src/client.rs b/rs/https_outcalls/client/src/client.rs index 40b1f0a5ec23..e9e87e51a52e 100644 --- a/rs/https_outcalls/client/src/client.rs +++ b/rs/https_outcalls/client/src/client.rs @@ -65,6 +65,7 @@ pub struct CanisterHttpAdapterClientImpl { rx: Receiver<(CanisterHttpResponse, CanisterHttpPaymentReceipt)>, query_service: TransformExecutionService, metrics: Metrics, + pricing_factory: PricingFactory, log: ReplicaLogger, } @@ -79,6 +80,7 @@ impl CanisterHttpAdapterClientImpl { ) -> Self { let (tx, rx) = channel(inflight_requests); let metrics = Metrics::new(&metrics_registry); + let pricing_factory = PricingFactory::new(&metrics_registry, log.clone()); Self { rt_handle, grpc_channel, @@ -86,6 +88,7 @@ impl CanisterHttpAdapterClientImpl { rx, query_service, metrics, + pricing_factory, log, } } @@ -121,6 +124,7 @@ impl NonBlockingChannel for CanisterHttpAdapterClientImpl { let mut http_adapter_client = HttpsOutcallsServiceClient::new(self.grpc_channel.clone()); let query_handler = self.query_service.clone(); let metrics = self.metrics.clone(); + let pricing_factory = self.pricing_factory.clone(); let log = self.log.clone(); // Spawn an async task that sends the canister http request to the adapter and awaits the response. @@ -133,7 +137,7 @@ impl NonBlockingChannel for CanisterHttpAdapterClientImpl { socks_proxy_addrs, } = canister_http_request; - let mut budget = PricingFactory::new_tracker(&request_context); + let mut budget = pricing_factory.new_tracker(&request_context); let request_size = request_context.variable_parts_size(); let CanisterHttpRequestContext { diff --git a/rs/https_outcalls/pricing/BUILD.bazel b/rs/https_outcalls/pricing/BUILD.bazel index bb6fa3989628..2c948a5854c5 100644 --- a/rs/https_outcalls/pricing/BUILD.bazel +++ b/rs/https_outcalls/pricing/BUILD.bazel @@ -8,8 +8,14 @@ rust_library( crate_name = "ic_https_outcalls_pricing", version = "0.1.0", deps = [ + # Keep sorted. "//rs/config", + "//rs/monitoring/logger", + "//rs/monitoring/metrics", + "//rs/types/cycles", "//rs/types/types", + "@crate_index//:prometheus", + "@crate_index//:slog", ], ) diff --git a/rs/https_outcalls/pricing/Cargo.toml b/rs/https_outcalls/pricing/Cargo.toml index 1773aa69401f..3de7839e79db 100644 --- a/rs/https_outcalls/pricing/Cargo.toml +++ b/rs/https_outcalls/pricing/Cargo.toml @@ -8,4 +8,9 @@ documentation.workspace = true [dependencies] ic-config = { path = "../../config" } -ic-types = { path = "../../types/types" } \ No newline at end of file +ic-logger = { path = "../../monitoring/logger" } +ic-metrics = { path = "../../monitoring/metrics" } +ic-types = { path = "../../types/types" } +ic-types-cycles = { path = "../../types/cycles" } +prometheus = { workspace = true } +slog = { workspace = true } \ No newline at end of file diff --git a/rs/https_outcalls/pricing/src/dark_launch.rs b/rs/https_outcalls/pricing/src/dark_launch.rs new file mode 100644 index 000000000000..be4475bf4195 --- /dev/null +++ b/rs/https_outcalls/pricing/src/dark_launch.rs @@ -0,0 +1,269 @@ +use ic_logger::{ReplicaLogger, warn}; +use ic_types::{CanisterId, NumBytes, NumInstructions, canister_http::CanisterHttpPaymentReceipt}; + +use crate::{AdapterLimits, BudgetTracker, NetworkUsage, PricingError, metrics::PricingMetrics}; + +/// A [`BudgetTracker`] that runs two child trackers side by side: a `real` +/// tracker whose results are the only ones returned (and therefore the only +/// ones that affect observable behaviour), and a `shadow` tracker whose results +/// are merely compared against the real one. +/// +/// Whenever the shadow tracker disagrees with the real tracker (e.g. it returns +/// a pricing error where the real tracker succeeded), the divergence is counted +/// in a metric and logged together with the canister id, so we can measure what +/// fraction of requests would not be backwards compatible under the shadow +/// pricing and which canisters would break. +pub struct DarkLaunchTracker { + real: Box, + shadow: Box, + canister_id: CanisterId, + metrics: PricingMetrics, + log: ReplicaLogger, + /// Whether an incompatibility has already been recorded for this request. + /// Ensures we count and log at most once per request. + reported: bool, +} + +impl DarkLaunchTracker { + pub fn new( + real: Box, + shadow: Box, + canister_id: CanisterId, + metrics: PricingMetrics, + log: ReplicaLogger, + ) -> Self { + Self { + real, + shadow, + canister_id, + metrics, + log, + reported: false, + } + } + + /// Compares the results of the real and shadow trackers for a given + /// accounting `step` and records a divergence if they disagree. + fn compare( + &mut self, + step: &str, + real: &Result<(), PricingError>, + shadow: &Result<(), PricingError>, + ) { + if real.is_ok() == shadow.is_ok() { + return; + } + if self.reported { + return; + } + self.reported = true; + self.metrics + .shadow_incompatible_total + .with_label_values(&[step]) + .inc(); + warn!( + self.log, + "Canister http request would not be backwards compatible under shadow pricing: \ + canister_id {}, step {}, real_result {:?}, shadow_result {:?}", + self.canister_id, + step, + real, + shadow, + ); + } +} + +impl BudgetTracker for DarkLaunchTracker { + fn get_adapter_limits(&self) -> AdapterLimits { + // Only the real tracker drives observable behaviour. + self.real.get_adapter_limits() + } + + fn subtract_network_usage(&mut self, network_usage: NetworkUsage) -> Result<(), PricingError> { + let real = self.real.subtract_network_usage(network_usage); + let shadow = self.shadow.subtract_network_usage(network_usage); + self.compare("network_usage", &real, &shadow); + real + } + + fn get_transform_limit(&self) -> NumInstructions { + self.real.get_transform_limit() + } + + fn subtract_transform_usage(&mut self, usage: NumInstructions) -> Result<(), PricingError> { + let real = self.real.subtract_transform_usage(usage); + let shadow = self.shadow.subtract_transform_usage(usage); + self.compare("transform_usage", &real, &shadow); + real + } + + fn subtract_transformed_response_usage( + &mut self, + transformed_response_size: NumBytes, + ) -> Result<(), PricingError> { + let real = self + .real + .subtract_transformed_response_usage(transformed_response_size); + let shadow = self + .shadow + .subtract_transformed_response_usage(transformed_response_size); + self.compare("transformed_response_usage", &real, &shadow); + real + } + + fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { + // Count every request that reaches the final accounting step so the + // incompatible counter can be expressed as a fraction of the total. + self.metrics.shadow_requests_total.inc(); + self.real.create_payment_receipt() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_logger::no_op_logger; + use ic_metrics::MetricsRegistry; + use std::time::Duration; + + /// A [`BudgetTracker`] whose accounting steps return preconfigured results. + struct FakeTracker { + network: Result<(), PricingError>, + transform: Result<(), PricingError>, + transformed: Result<(), PricingError>, + } + + impl FakeTracker { + fn ok() -> Self { + Self { + network: Ok(()), + transform: Ok(()), + transformed: Ok(()), + } + } + } + + impl BudgetTracker for FakeTracker { + fn get_adapter_limits(&self) -> AdapterLimits { + AdapterLimits { + max_response_size: NumBytes::from(0), + max_response_time: Duration::ZERO, + } + } + fn subtract_network_usage(&mut self, _: NetworkUsage) -> Result<(), PricingError> { + self.network + } + fn get_transform_limit(&self) -> NumInstructions { + NumInstructions::from(0) + } + fn subtract_transform_usage(&mut self, _: NumInstructions) -> Result<(), PricingError> { + self.transform + } + fn subtract_transformed_response_usage(&mut self, _: NumBytes) -> Result<(), PricingError> { + self.transformed + } + fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { + CanisterHttpPaymentReceipt::default() + } + } + + fn dark_launch( + real: FakeTracker, + shadow: FakeTracker, + metrics: PricingMetrics, + ) -> DarkLaunchTracker { + DarkLaunchTracker::new( + Box::new(real), + Box::new(shadow), + CanisterId::from_u64(7), + metrics, + no_op_logger(), + ) + } + + fn network_usage() -> NetworkUsage { + NetworkUsage { + response_size: NumBytes::from(0), + response_time: Duration::ZERO, + } + } + + fn incompatible_count(metrics: &PricingMetrics) -> u64 { + [ + "network_usage", + "transform_usage", + "transformed_response_usage", + ] + .iter() + .map(|step| { + metrics + .shadow_incompatible_total + .with_label_values(&[*step]) + .get() + }) + .sum() + } + + #[test] + fn returns_real_result_and_counts_divergence() { + let metrics = PricingMetrics::new(&MetricsRegistry::new()); + let shadow = FakeTracker { + network: Err(PricingError::InsufficientCycles), + ..FakeTracker::ok() + }; + let mut tracker = dark_launch(FakeTracker::ok(), shadow, metrics.clone()); + + // The real (always-Ok) result is returned even though the shadow fails. + assert_eq!(tracker.subtract_network_usage(network_usage()), Ok(())); + assert_eq!( + metrics + .shadow_incompatible_total + .with_label_values(&["network_usage"]) + .get(), + 1 + ); + + let _ = tracker.create_payment_receipt(); + assert_eq!(metrics.shadow_requests_total.get(), 1); + } + + #[test] + fn counts_divergence_at_most_once_per_request() { + let metrics = PricingMetrics::new(&MetricsRegistry::new()); + let shadow = FakeTracker { + network: Err(PricingError::InsufficientCycles), + transform: Err(PricingError::InsufficientCycles), + transformed: Err(PricingError::InsufficientCycles), + }; + let mut tracker = dark_launch(FakeTracker::ok(), shadow, metrics.clone()); + + assert_eq!(tracker.subtract_network_usage(network_usage()), Ok(())); + assert_eq!( + tracker.subtract_transform_usage(NumInstructions::from(0)), + Ok(()) + ); + assert_eq!( + tracker.subtract_transformed_response_usage(NumBytes::from(0)), + Ok(()) + ); + + // Only the first divergence is recorded for the request. + assert_eq!(incompatible_count(&metrics), 1); + } + + #[test] + fn no_divergence_when_results_agree() { + let metrics = PricingMetrics::new(&MetricsRegistry::new()); + let mut tracker = dark_launch(FakeTracker::ok(), FakeTracker::ok(), metrics.clone()); + + assert_eq!(tracker.subtract_network_usage(network_usage()), Ok(())); + assert_eq!( + tracker.subtract_transform_usage(NumInstructions::from(0)), + Ok(()) + ); + let _ = tracker.create_payment_receipt(); + + assert_eq!(incompatible_count(&metrics), 0); + assert_eq!(metrics.shadow_requests_total.get(), 1); + } +} diff --git a/rs/https_outcalls/pricing/src/legacy.rs b/rs/https_outcalls/pricing/src/legacy.rs index eb272bc8a37b..6ce72888a588 100644 --- a/rs/https_outcalls/pricing/src/legacy.rs +++ b/rs/https_outcalls/pricing/src/legacy.rs @@ -45,6 +45,13 @@ impl BudgetTracker for LegacyTracker { Ok(()) } + fn subtract_transformed_response_usage( + &mut self, + _transformed_response_size: NumBytes, + ) -> Result<(), PricingError> { + Ok(()) + } + fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { // Legacy pricing does not perform cycles accounting, so no cycles // are ever refunded. diff --git a/rs/https_outcalls/pricing/src/lib.rs b/rs/https_outcalls/pricing/src/lib.rs index 827459a9bcdd..70654b653625 100644 --- a/rs/https_outcalls/pricing/src/lib.rs +++ b/rs/https_outcalls/pricing/src/lib.rs @@ -1,12 +1,21 @@ +mod dark_launch; mod legacy; +mod metrics; +mod payg; use std::time::Duration; +use ic_logger::ReplicaLogger; +use ic_metrics::MetricsRegistry; use ic_types::{ NumBytes, NumInstructions, - canister_http::{CanisterHttpPaymentReceipt, CanisterHttpRequestContext}, + canister_http::{CanisterHttpPaymentReceipt, CanisterHttpRequestContext, PricingVersion}, }; + +use dark_launch::DarkLaunchTracker; use legacy::LegacyTracker; +use metrics::PricingMetrics; +use payg::PayAsYouGoTracker; pub trait BudgetTracker: Send { /// Returns the maximum network resources the Adapter is allowed to consume. @@ -26,6 +35,15 @@ pub trait BudgetTracker: Send { /// # Invariants /// - This method returns `Ok(())` if and only if `usage <= get_transform_limit()`. fn subtract_transform_usage(&mut self, usage: NumInstructions) -> Result<(), PricingError>; + /// Deducts the cost of the final (post-transform) response that this replica + /// produced and that will be handed back to the caller. + /// + /// This is the last accounting step and is invoked once the size of the + /// response is known. + fn subtract_transformed_response_usage( + &mut self, + transformed_response_size: NumBytes, + ) -> Result<(), PricingError>; /// Produces the per-replica payment receipt that summarizes the cycles /// accounting outcome of the outcall, given the resources consumed so /// far via the `subtract_*` methods. @@ -39,6 +57,7 @@ pub struct AdapterLimits { pub max_response_time: Duration, } +#[derive(Clone, Copy)] pub struct NetworkUsage { /// The size of the HTTP response, including the headers and the body. pub response_size: NumBytes, @@ -46,16 +65,47 @@ pub struct NetworkUsage { pub response_time: Duration, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PricingError { InsufficientCycles, } -pub struct PricingFactory; +/// Builds a [`BudgetTracker`] for each canister HTTP request. +/// +/// The factory is constructed once per replica and holds the shared metrics +/// and logger needed by the dark-launch tracker. +#[derive(Clone)] +pub struct PricingFactory { + metrics: PricingMetrics, + log: ReplicaLogger, +} impl PricingFactory { - pub fn new_tracker(context: &CanisterHttpRequestContext) -> Box { - // TODO(IC-1937): This should take into account context.pricing_version and a replica config. - // Currently, we only support the legacy pricing version. - Box::new(LegacyTracker::new(context.max_response_bytes)) + pub fn new(metrics_registry: &MetricsRegistry, log: ReplicaLogger) -> Self { + Self { + metrics: PricingMetrics::new(metrics_registry), + log, + } + } + + /// Creates the tracker for a request. The subnet size (`N`) needed by the + /// pay-as-you-go formula is read from `context.subnet_size`. + pub fn new_tracker(&self, context: &CanisterHttpRequestContext) -> Box { + match context.pricing_version { + // Legacy pricing is what is actually charged today. We run the + // PayAsYouGo tracker as a shadow next to it so we can measure how + // many requests would become backwards-incompatible under the new + // pricing without changing any observable behaviour. + PricingVersion::Legacy => Box::new(DarkLaunchTracker::new( + Box::new(LegacyTracker::new(context.max_response_bytes)), + Box::new(PayAsYouGoTracker::new(context)), + context.request.sender, + self.metrics.clone(), + self.log.clone(), + )), + // PayAsYouGo requests are not served yet (the client rejects them), + // but we still hand back the matching tracker for completeness. + PricingVersion::PayAsYouGo => Box::new(PayAsYouGoTracker::new(context)), + } } } diff --git a/rs/https_outcalls/pricing/src/metrics.rs b/rs/https_outcalls/pricing/src/metrics.rs new file mode 100644 index 000000000000..df2c83b5829a --- /dev/null +++ b/rs/https_outcalls/pricing/src/metrics.rs @@ -0,0 +1,36 @@ +use ic_metrics::MetricsRegistry; +use prometheus::{IntCounter, IntCounterVec}; + +/// Label identifying the accounting step at which the shadow tracker diverged +/// from the real one. +pub const LABEL_STEP: &str = "step"; + +#[derive(Clone)] +pub struct PricingMetrics { + /// Total number of requests evaluated by the dark-launch budget tracker. + pub shadow_requests_total: IntCounter, + /// Number of requests that would be rejected (pricing error) under the + /// shadow pricing while succeeding under the real pricing, by the + /// accounting step at which the divergence was first observed. + /// + /// The fraction `shadow_incompatible_total / shadow_requests_total` is the + /// share of requests that would NOT be backwards compatible. + pub shadow_incompatible_total: IntCounterVec, +} + +impl PricingMetrics { + pub fn new(metrics_registry: &MetricsRegistry) -> Self { + Self { + shadow_requests_total: metrics_registry.int_counter( + "canister_http_pricing_shadow_requests_total", + "Total canister http requests evaluated by the dark-launch budget tracker.", + ), + shadow_incompatible_total: metrics_registry.int_counter_vec( + "canister_http_pricing_shadow_incompatible_total", + "Canister http requests that would be rejected (pricing error) under the shadow \ + pricing while succeeding under the real pricing, by accounting step.", + &[LABEL_STEP], + ), + } + } +} diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs new file mode 100644 index 000000000000..658df484e333 --- /dev/null +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -0,0 +1,268 @@ +use std::time::Duration; + +use ic_config::subnet_config::MAX_INSTRUCTIONS_PER_QUERY_MESSAGE; +use ic_types::{ + NumBytes, NumInstructions, + canister_http::{ + CanisterHttpPaymentReceipt, CanisterHttpRequestContext, MAX_CANISTER_HTTP_RESPONSE_BYTES, + Replication, + }, +}; +use ic_types_cycles::Cycles; + +use crate::{AdapterLimits, BudgetTracker, NetworkUsage, PricingError}; + +// Per-replica fee constants. +// +// A request's cost is split into three parts: +// 1. the base cost, subtracted up-front when the request context is created +// (and therefore reflected in `per_replica_allowance`); +// 2. the per-replica cost, accounted for here as-you-go; +// 3. the consensus cost, computed from the aggregated response in the block +// payload (ignored for now). +// +// This tracker only implements the per-replica part. The formula differs +// between fully/non-replicated and flexible outcalls: +// +// Fully/non-replicated per replica: +// 50 * downloaded_bytes_i + 300 * request_ms_i + transform_instructions_i / 13 +// +// Flexible per replica: +// 50 * downloaded_bytes_i + 300 * request_ms_i +// + 50 * transformed_response_bytes_i * N + transform_instructions_i / 13 +const PER_DOWNLOADED_BYTE_FEE: u128 = 50; +const PER_RESPONSE_MS_FEE: u128 = 300; +const TRANSFORM_INSTRUCTION_DIVISOR: u128 = 13; +const FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE: u128 = 50; + +pub struct PayAsYouGoTracker { + /// Number of nodes (`N`) on the subnet. + n: u64, + /// Whether this is a flexible outcall (different per-replica formula). + is_flexible: bool, + /// The cycles budget available to this replica (already net of the base + /// cost, which was subtracted when the context was created). + allowance: u128, + /// The maximum size of the HTTP response, including headers and body. + max_response_size: NumBytes, + /// The cycles charged so far against `allowance`. + spent: u128, +} + +impl PayAsYouGoTracker { + pub fn new(context: &CanisterHttpRequestContext) -> Self { + Self { + n: 13, + is_flexible: matches!(context.replication, Replication::Flexible { .. }), + allowance: context.refund_status.per_replica_allowance.get(), + max_response_size: context + .max_response_bytes + .unwrap_or(NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES)), + spent: 0, + } + } + + /// Charges `amount` against the budget. Returns an error if the total spent + /// now exceeds the available allowance. + fn charge(&mut self, amount: u128) -> Result<(), PricingError> { + self.spent = self.spent.saturating_add(amount); + if self.spent > self.allowance { + Err(PricingError::InsufficientCycles) + } else { + Ok(()) + } + } +} + +impl BudgetTracker for PayAsYouGoTracker { + fn get_adapter_limits(&self) -> AdapterLimits { + AdapterLimits { + max_response_size: self.max_response_size, + // Mirror the legacy limit: the server enforces a 30s timeout, so 60s + // here is just a safety margin. + max_response_time: Duration::from_secs(60), + } + } + + fn subtract_network_usage(&mut self, network_usage: NetworkUsage) -> Result<(), PricingError> { + let NetworkUsage { + response_size, + response_time, + } = network_usage; + let cost = PER_DOWNLOADED_BYTE_FEE + .saturating_mul(response_size.get() as u128) + .saturating_add(PER_RESPONSE_MS_FEE.saturating_mul(response_time.as_millis() as u128)); + self.charge(cost) + } + + fn get_transform_limit(&self) -> NumInstructions { + MAX_INSTRUCTIONS_PER_QUERY_MESSAGE + } + + fn subtract_transform_usage(&mut self, usage: NumInstructions) -> Result<(), PricingError> { + let cost = (usage.get() as u128) / TRANSFORM_INSTRUCTION_DIVISOR; + self.charge(cost) + } + + fn subtract_transformed_response_usage( + &mut self, + transformed_response_size: NumBytes, + ) -> Result<(), PricingError> { + // For fully/non-replicated outcalls the transformed-response term is a + // consensus cost (ignored here for now). For flexible outcalls each + // replica is charged 50 * transformed_response_bytes_i * N. + if !self.is_flexible { + return Ok(()); + } + let cost = FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE + .saturating_mul(transformed_response_size.get() as u128) + .saturating_mul(self.n as u128); + self.charge(cost) + } + + fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { + CanisterHttpPaymentReceipt { + refund: Cycles::new(self.allowance.saturating_sub(self.spent)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_types::{ + CanisterId, NodeId, PrincipalId, RegistryVersion, + canister_http::{CanisterHttpMethod, PricingVersion, RefundStatus}, + messages::{CallbackId, NO_DEADLINE, Request}, + time::UNIX_EPOCH, + }; + use std::collections::BTreeSet; + + fn context( + replication: Replication, + per_replica_allowance: u128, + ) -> CanisterHttpRequestContext { + CanisterHttpRequestContext { + request: Request { + receiver: CanisterId::from_u64(1), + sender: CanisterId::from_u64(1), + sender_reply_callback: CallbackId::from(1), + payment: Cycles::zero(), + method_name: String::new(), + method_payload: Vec::new(), + metadata: Default::default(), + deadline: NO_DEADLINE, + }, + url: String::new(), + max_response_bytes: None, + headers: vec![], + body: None, + http_method: CanisterHttpMethod::GET, + transform: None, + time: UNIX_EPOCH, + replication, + pricing_version: PricingVersion::Legacy, + refund_status: RefundStatus { + refundable_cycles: Cycles::new(per_replica_allowance), + per_replica_allowance: Cycles::new(per_replica_allowance), + refunded_cycles: Cycles::zero(), + refunding_nodes: BTreeSet::new(), + }, + registry_version: RegistryVersion::from(1), + } + } + + fn flexible(n: usize) -> Replication { + let committee: BTreeSet = (0..n as u64) + .map(|i| NodeId::from(PrincipalId::new_node_test_id(i))) + .collect(); + Replication::Flexible { + committee, + min_responses: 1, + max_responses: n as u32, + } + } + + #[test] + fn does_not_charge_base_cost() { + // The base cost is handled at context creation, so a freshly created + // tracker has spent nothing and a zero-usage request refunds everything. + let ctx = context(Replication::FullyReplicated, 1_000_000); + let tracker = PayAsYouGoTracker::new(&ctx); + assert_eq!(tracker.spent, 0); + assert_eq!( + tracker.create_payment_receipt().refund, + Cycles::new(1_000_000) + ); + } + + #[test] + fn charges_per_replica_cost_fully_replicated() { + let allowance = 1_000_000_000u128; + let ctx = context(Replication::FullyReplicated, allowance); + let mut tracker = PayAsYouGoTracker::new(&ctx); + + let response_size = 1_000u64; + let response_ms = 2_000u128; + assert_eq!( + tracker.subtract_network_usage(NetworkUsage { + response_size: NumBytes::from(response_size), + response_time: Duration::from_millis(response_ms as u64), + }), + Ok(()) + ); + let network = + PER_DOWNLOADED_BYTE_FEE * response_size as u128 + PER_RESPONSE_MS_FEE * response_ms; + + let instructions = 13_000u64; + assert_eq!( + tracker.subtract_transform_usage(NumInstructions::from(instructions)), + Ok(()) + ); + let transform = instructions as u128 / TRANSFORM_INSTRUCTION_DIVISOR; + + // For fully-replicated requests the transformed-response term is a + // consensus cost and must not be charged here. + assert_eq!( + tracker.subtract_transformed_response_usage(NumBytes::from(5_000)), + Ok(()) + ); + + assert_eq!(tracker.spent, network + transform); + assert_eq!( + tracker.create_payment_receipt().refund, + Cycles::new(allowance - network - transform) + ); + } + + #[test] + fn charges_transformed_response_for_flexible() { + let allowance = 1_000_000_000u128; + let n = 13; + let ctx = context(flexible(n), allowance); + let mut tracker = PayAsYouGoTracker::new(&ctx); + + let transformed_size = 500u64; + assert_eq!( + tracker.subtract_transformed_response_usage(NumBytes::from(transformed_size)), + Ok(()) + ); + let expected = + FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE * transformed_size as u128 * n as u128; + assert_eq!(tracker.spent, expected); + } + + #[test] + fn returns_pricing_error_when_budget_is_exceeded() { + let ctx = context(Replication::FullyReplicated, 100); + let mut tracker = PayAsYouGoTracker::new(&ctx); + assert_eq!( + tracker.subtract_network_usage(NetworkUsage { + response_size: NumBytes::from(1_000), + response_time: Duration::ZERO, + }), + Err(PricingError::InsufficientCycles) + ); + assert_eq!(tracker.create_payment_receipt().refund, Cycles::zero()); + } +} From 7d7cd45a8f13f80823261c29658a9b73be129bab Mon Sep 17 00:00:00 2001 From: IDX GitHub Automation Date: Fri, 19 Jun 2026 12:58:45 +0000 Subject: [PATCH 2/4] Automatically updated Cargo*.lock --- Cargo.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index c2e1fc00702c..29aa38ea3776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10303,7 +10303,12 @@ name = "ic-https-outcalls-pricing" version = "0.9.0" dependencies = [ "ic-config", + "ic-logger", + "ic-metrics", "ic-types", + "ic-types-cycles", + "prometheus", + "slog", ] [[package]] From 0ec21dc5f103be35e98fe7df9f3ee6131ca9376c Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Fri, 19 Jun 2026 13:16:00 +0000 Subject: [PATCH 3/4] fix --- rs/https_outcalls/pricing/src/payg.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index 658df484e333..4d2f808a0cd1 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -198,12 +198,12 @@ mod tests { #[test] fn charges_per_replica_cost_fully_replicated() { - let allowance = 1_000_000_000u128; + let allowance = 1_000_000_000_u128; let ctx = context(Replication::FullyReplicated, allowance); let mut tracker = PayAsYouGoTracker::new(&ctx); - let response_size = 1_000u64; - let response_ms = 2_000u128; + let response_size = 1_000_u64; + let response_ms = 2_000_u128; assert_eq!( tracker.subtract_network_usage(NetworkUsage { response_size: NumBytes::from(response_size), @@ -214,7 +214,7 @@ mod tests { let network = PER_DOWNLOADED_BYTE_FEE * response_size as u128 + PER_RESPONSE_MS_FEE * response_ms; - let instructions = 13_000u64; + let instructions = 13_000_u64; assert_eq!( tracker.subtract_transform_usage(NumInstructions::from(instructions)), Ok(()) @@ -237,12 +237,12 @@ mod tests { #[test] fn charges_transformed_response_for_flexible() { - let allowance = 1_000_000_000u128; + let allowance = 1_000_000_000_u128; let n = 13; let ctx = context(flexible(n), allowance); let mut tracker = PayAsYouGoTracker::new(&ctx); - let transformed_size = 500u64; + let transformed_size = 500_u64; assert_eq!( tracker.subtract_transformed_response_usage(NumBytes::from(transformed_size)), Ok(()) From cf87d44f574cb1cd7a1416b60546d9e84bf457ca Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Fri, 19 Jun 2026 13:22:55 +0000 Subject: [PATCH 4/4] fix --- rs/https_outcalls/pricing/src/payg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index 4d2f808a0cd1..76171cdd376d 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -91,7 +91,7 @@ impl BudgetTracker for PayAsYouGoTracker { } = network_usage; let cost = PER_DOWNLOADED_BYTE_FEE .saturating_mul(response_size.get() as u128) - .saturating_add(PER_RESPONSE_MS_FEE.saturating_mul(response_time.as_millis() as u128)); + .saturating_add(PER_RESPONSE_MS_FEE.saturating_mul(response_time.as_millis())); self.charge(cost) }