From 2a2311e1e934147a7f6ffa363c572873fb28063f Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 3 Aug 2026 13:54:56 +0000 Subject: [PATCH 01/15] upper bound --- .../src/cycles_account_manager.rs | 44 +- .../src/wasmtime_embedder/system_api.rs | 126 ++++- .../benches/system_api/execute_update.rs | 26 + .../src/execution_environment.rs | 24 +- .../src/execution_environment/tests.rs | 230 +++++++- rs/execution_environment/tests/hypervisor.rs | 97 +++- rs/https_outcalls/pricing/src/fees.rs | 497 ++++++++++++++++-- rs/https_outcalls/pricing/src/payg.rs | 25 +- .../execution_environment/src/lib.rs | 15 +- rs/types/types/src/canister_http.rs | 65 ++- 10 files changed, 1009 insertions(+), 140 deletions(-) diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index 3cebf967d5de..015476f8361d 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -10,9 +10,9 @@ use ic_replicated_state::{ CanisterState, SystemState, canister_state::execution_state::WasmExecutionMode, }; use ic_types::{ - CanisterId, ComputeAllocation, MemoryAllocation, NumBytes, NumInstructions, PrincipalId, - SubnetId, - canister_http::{MAX_CANISTER_HTTP_RESPONSE_BYTES, Replication}, + CanisterId, ComputeAllocation, MemoryAllocation, NumBytes, NumInstructions, NumberOfNodes, + PrincipalId, SubnetId, + canister_http::{MAX_CANISTER_HTTP_RESPONSE_BYTES, Replication, ReplicationKind}, messages::{MAX_INTER_CANISTER_PAYLOAD_IN_BYTES, Payload, SignedIngress}, }; use ic_types_cycles::{ @@ -1235,6 +1235,8 @@ impl CyclesAccountManager { CompoundCycles::new(amount, subnet_cycles_config.cost_schedule) } + // Returns the base fee for an HTTP outcall, which is charged for every + // request upfront. pub fn http_request_base_fee( &self, request_size: NumBytes, @@ -1244,6 +1246,20 @@ impl CyclesAccountManager { ic_https_outcalls_pricing::fees::base_fee(request_size, replication, subnet_cycles_config) } + /// Returns the most an HTTP outcall with the given `replication` and + /// `max_response_bytes` can ever spend, i.e. its worst-case cost beyond the base + /// fee, including the consensus cost of delivering the response. + pub fn max_http_request_usage_fee( + &self, + replication: &Replication, + max_response_bytes: Option, + subnet_size: NumberOfNodes, + ) -> Cycles { + ic_https_outcalls_pricing::fees::max_usage_fee(replication, max_response_bytes, subnet_size) + } + + /// Returns the estimated total fee for an HTTP outcall with the given parameters, + /// including both the base fee and the usage fee. pub fn http_request_fee_v2( &self, request_size: NumBytes, @@ -1251,20 +1267,18 @@ impl CyclesAccountManager { raw_response_size: NumBytes, transform: NumInstructions, transformed_response_size: NumBytes, + replication_kind: ReplicationKind, subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { - let n = subnet_cycles_config.subnet_size as u64; - let amount = (Cycles::new(1_000_000) - + Cycles::new(50) * request_size.get() - + Cycles::new(140_000) * n - + Cycles::new(800) * n * n - + Cycles::new(50) * raw_response_size.get() - + Cycles::new(300) * http_roundtrip_time.as_millis() as u64 - + Cycles::new(transform.get() as u128 / 13) - + (Cycles::new(10) * n + Cycles::new(650)) * transformed_response_size.get()) - * n; - - CompoundCycles::new(amount, subnet_cycles_config.cost_schedule) + ic_https_outcalls_pricing::fees::total_fee( + request_size, + http_roundtrip_time, + raw_response_size, + transform, + transformed_response_size, + replication_kind, + subnet_cycles_config, + ) } pub fn http_request_fee_beta( diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index 070ca90848e0..c7dce0501bc3 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -12,8 +12,8 @@ use ic_interfaces::execution_environment::{ }; use ic_logger::{ReplicaLogger, error}; use ic_management_canister_types_private::{ - EcdsaCurve, EcdsaKeyId, IC_00, MasterPublicKeyId, SchnorrAlgorithm, SchnorrKeyId, VetKdCurve, - VetKdKeyId, + EcdsaCurve, EcdsaKeyId, IC_00, MasterPublicKeyId, ReplicationCounts, SchnorrAlgorithm, + SchnorrKeyId, VetKdCurve, VetKdKeyId, }; use ic_registry_subnet_type::SubnetType; use ic_replicated_state::canister_state::execution_state::WasmExecutionMode; @@ -22,7 +22,8 @@ use ic_replicated_state::{ }; use ic_types::{ CanisterId, CanisterLog, CanisterTimer, ComputeAllocation, MemoryAllocation, NumBytes, - NumInstructions, NumOsPages, PrincipalId, SubnetId, Time, + NumInstructions, NumOsPages, NumberOfNodes, PrincipalId, SubnetId, Time, + canister_http::ReplicationKind, ingress::WasmResult, messages::{CallContextId, MAX_INTER_CANISTER_PAYLOAD_IN_BYTES, RejectContext, SenderInfo}, methods::{SystemMethod, WasmClosure}, @@ -1981,9 +1982,48 @@ struct CostHttpRequestV2Params { raw_response_bytes: u64, transformed_response_bytes: u64, transform_instructions: u64, + /// The replication of the outcall being priced. Absent means fully replicated. + outcall_type: Option, } -const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 79; +/// The `outcall_type` of [`CostHttpRequestV2Params`]: which of the management +/// canister's HTTP-outcall endpoints the cost is asked for. `flexible` carries the +/// response counts the request would specify, absent meaning the defaults derived +/// from the subnet size. +#[derive(CandidType, Deserialize)] +enum CostHttpRequestOutcallType { + #[serde(rename = "fully_replicated")] + FullyReplicated(candid::Reserved), + #[serde(rename = "non_replicated")] + NonReplicated(candid::Reserved), + #[serde(rename = "flexible")] + Flexible(Option), +} + +impl CostHttpRequestV2Params { + fn replication_kind(&self, subnet_size: NumberOfNodes) -> ReplicationKind { + match &self.outcall_type { + None | Some(CostHttpRequestOutcallType::FullyReplicated(_)) => { + ReplicationKind::FullyReplicated + } + Some(CostHttpRequestOutcallType::NonReplicated(_)) => ReplicationKind::NonReplicated, + Some(CostHttpRequestOutcallType::Flexible(None)) => { + ReplicationKind::default_flexible(subnet_size) + } + Some(CostHttpRequestOutcallType::Flexible(Some(counts))) => ReplicationKind::Flexible { + total_requests: counts.total_requests, + min_responses: counts.min_responses, + max_responses: counts.max_responses, + }, + } + } +} + +const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; + +/// How much work decoding [`CostHttpRequestV2Params`] may spend skipping the +/// `reserved` payload of an `outcall_type` variant, in Candid's decoding cost units. +const MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES: usize = 1; impl SystemApi for SystemApiImpl { fn set_execution_error(&mut self, error: HypervisorError) { @@ -4380,7 +4420,7 @@ impl SystemApi for SystemApiImpl { } let mut decoder_config = DecoderConfig::new(); - decoder_config.set_skipping_quota(0); + decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES); let cost_params_v2: CostHttpRequestV2Params = decode_one_with_config(params_bytes, &decoder_config).map_err(|e| { @@ -4393,6 +4433,8 @@ impl SystemApi for SystemApiImpl { })?; let subnet_cycles_config = self.sandbox_safe_system_state.subnet_cycles_config; + let replication_kind = cost_params_v2 + .replication_kind(NumberOfNodes::from(subnet_cycles_config.subnet_size as u32)); let cost = self .sandbox_safe_system_state .get_cycles_account_manager() @@ -4402,6 +4444,7 @@ impl SystemApi for SystemApiImpl { cost_params_v2.raw_response_bytes.into(), cost_params_v2.transform_instructions.into(), cost_params_v2.transformed_response_bytes.into(), + replication_kind, subnet_cycles_config, ); copy_cycles_to_heap(cost.real(), dst, heap, "ic0_cost_http_request_v2")?; @@ -4703,6 +4746,50 @@ pub(crate) fn valid_subslice<'a>( mod test { use super::*; + /// Encodes params carrying `payload` in the `reserved` slot of a + /// `fully_replicated` outcall type, and decodes them the way + /// `ic0.cost_http_request_v2` does. + fn decode_params_with_reserved_payload(payload: T) -> Result<(), candid::Error> { + #[derive(CandidType, Deserialize)] + enum OutcallType { + #[serde(rename = "fully_replicated")] + FullyReplicated(T), + } + #[derive(CandidType)] + struct Params { + request_bytes: u64, + http_roundtrip_time_ms: u64, + raw_response_bytes: u64, + transformed_response_bytes: u64, + transform_instructions: u64, + outcall_type: Option>, + } + let blob = candid::encode_one(&Params { + request_bytes: 1, + http_roundtrip_time_ms: 1, + raw_response_bytes: 1, + transformed_response_bytes: 1, + transform_instructions: 1, + outcall_type: Some(OutcallType::FullyReplicated(payload)), + }) + .unwrap(); + + let mut decoder_config = DecoderConfig::new(); + decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES); + decode_one_with_config::(&blob, &decoder_config).map(|_| ()) + } + + #[test] + fn test_cost_http_request_v2_skipping_quota_admits_null_and_stops_amplification() { + // The empty payload an actual caller sends in the `reserved` slot is skipped ... + assert!(decode_params_with_reserved_payload(()).is_ok()); + assert!(decode_params_with_reserved_payload(candid::Reserved).is_ok()); + // ... while anything larger is rejected, including a payload that is cheap to + // encode but expensive to skip. + assert!(decode_params_with_reserved_payload(vec![(); 1000]).is_err()); + assert!(decode_params_with_reserved_payload("0123456789".to_string()).is_err()); + } + #[test] fn test_cost_http_request_v2_params_size_is_constant() { let max = CostHttpRequestV2Params { @@ -4711,9 +4798,38 @@ mod test { raw_response_bytes: u64::MAX, transformed_response_bytes: u64::MAX, transform_instructions: u64::MAX, + outcall_type: Some(CostHttpRequestOutcallType::Flexible(Some( + ReplicationCounts { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + ))), }; let encoded = candid::encode_one(&max).unwrap(); assert_eq!(MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE, encoded.len()); + + // No other `outcall_type` encodes larger. + for outcall_type in [ + None, + Some(CostHttpRequestOutcallType::FullyReplicated( + candid::Reserved, + )), + Some(CostHttpRequestOutcallType::NonReplicated(candid::Reserved)), + Some(CostHttpRequestOutcallType::Flexible(None)), + ] { + let params = CostHttpRequestV2Params { + request_bytes: u64::MAX, + http_roundtrip_time_ms: u64::MAX, + raw_response_bytes: u64::MAX, + transformed_response_bytes: u64::MAX, + transform_instructions: u64::MAX, + outcall_type, + }; + assert!( + candid::encode_one(¶ms).unwrap().len() <= MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE + ); + } } #[test] diff --git a/rs/execution_environment/benches/system_api/execute_update.rs b/rs/execution_environment/benches/system_api/execute_update.rs index cb4f9d73e284..e2d205c244de 100644 --- a/rs/execution_environment/benches/system_api/execute_update.rs +++ b/rs/execution_environment/benches/system_api/execute_update.rs @@ -35,6 +35,25 @@ struct CostHttpRequestV2Params { raw_response_bytes: u64, transformed_response_bytes: u64, transform_instructions: u64, + outcall_type: Option, +} + +#[derive(CandidType, Deserialize)] +#[allow(dead_code)] +enum CostHttpRequestOutcallType { + #[serde(rename = "fully_replicated")] + FullyReplicated(candid::Reserved), + #[serde(rename = "non_replicated")] + NonReplicated(candid::Reserved), + #[serde(rename = "flexible")] + Flexible(Option), +} + +#[derive(CandidType, Deserialize)] +struct ReplicationCounts { + total_requests: u32, + min_responses: u32, + max_responses: u32, } const COST_HTTP_REQUEST_V2_PARAMS: CostHttpRequestV2Params = CostHttpRequestV2Params { @@ -43,6 +62,13 @@ const COST_HTTP_REQUEST_V2_PARAMS: CostHttpRequestV2Params = CostHttpRequestV2Pa raw_response_bytes: 3_000, transformed_response_bytes: 2_000, transform_instructions: 1_000_000, + outcall_type: Some(CostHttpRequestOutcallType::Flexible(Some( + ReplicationCounts { + total_requests: 13, + min_responses: 9, + max_responses: 13, + }, + ))), }; pub fn execute_update_bench(c: &mut Criterion) { diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index a99aba704e85..595565efa9d9 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -95,6 +95,7 @@ use num_traits::SaturatingAdd; use phantom_newtype::AmountOf; use prometheus::IntCounter; use rand::RngCore; +use std::cmp::min; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::convert::{Into, TryFrom}; use std::fmt; @@ -2255,7 +2256,7 @@ impl ExecutionEnvironment { // caller is instead refunded the unspent `request.payment` (the full // payment on free/system subnets, where the legacy fee is zero) when the // response is delivered. - let refundable_cycles = if http_outcalls_are_free { + let refundable_payment = if http_outcalls_are_free { Cycles::new(0) } else { canister_http_request_context.request.payment - base_fee.real() @@ -2263,9 +2264,20 @@ impl ExecutionEnvironment { let node_count = canister_http_request_context .replication .node_count(canister_http_request_context.subnet_size); + // Whatever the payment covers beyond the worst-case cost of the outcall can + // never be spent, so withholding it would only lock up the caller's cycles + // until the request is settled. Only the smaller of the two is split into + // per-replica allowances. + let max_usage_fee = self.cycles_account_manager.max_http_request_usage_fee( + &canister_http_request_context.replication, + canister_http_request_context.max_response_bytes, + canister_http_request_context.subnet_size, + ); + let per_replica_allowance = min(refundable_payment, max_usage_fee) / node_count; + let refundable_cycles = per_replica_allowance * node_count; canister_http_request_context.refund_status = RefundStatus { refundable_cycles, - per_replica_allowance: refundable_cycles / node_count, + per_replica_allowance, refunded_cycles: Cycles::new(0), refunding_nodes: BTreeSet::new(), }; @@ -2278,11 +2290,11 @@ impl ExecutionEnvironment { canister_http_request_context.request.payment -= legacy_fee.real(); } PricingVersion::PayAsYouGo => { - // Take out the entire payment upfront; the refundable portion is - // returned later via the refund mechanism. When the outcall is - // free there is nothing to charge. + // Take out the base fee plus the per-replica allowances, i.e. an + // upper bound on what the outcall can cost. if !http_outcalls_are_free { - canister_http_request_context.request.payment.take(); + canister_http_request_context.request.payment -= + base_fee.real() + refundable_cycles; } } } diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 1b3abc0d8597..d69a343a5892 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -3285,17 +3285,19 @@ fn execute_canister_http_request() { http_request_context.variable_parts_size(), &http_request_context.replication, ); - let expected_refundable = match cost_schedule { + let node_count = test.subnet_size().max(1); + let refundable_payment = match cost_schedule { CanisterCyclesCostSchedule::Free => Cycles::new(0), CanisterCyclesCostSchedule::Normal => payment - base_fee.real(), }; + let expected_allowance = refundable_payment / node_count; assert_eq!( - http_request_context.refund_status.refundable_cycles, - expected_refundable + http_request_context.refund_status.per_replica_allowance, + expected_allowance ); assert_eq!( - http_request_context.refund_status.per_replica_allowance, - expected_refundable / test.subnet_size().max(1) + http_request_context.refund_status.refundable_cycles, + expected_allowance * node_count ); assert_eq!( http_request_context.refund_status.refunded_cycles, @@ -3585,26 +3587,38 @@ fn execute_flexible_canister_http_request() { other => panic!("expected flexible replication, got {other:?}"), }; - // Pay-as-you-go takes out the entire payment upfront (unless the cost - // schedule is free), refunding everything beyond the base fee per - // replica. + // Pay-as-you-go takes out the base fee plus the per-replica allowances + // upfront (unless the cost schedule is free), refunding everything beyond + // the base fee per replica. let base_fee = test.http_request_base_fee( http_request_context.variable_parts_size(), &http_request_context.replication, ); - let (expected_payment, expected_refundable) = match cost_schedule { - CanisterCyclesCostSchedule::Free => (payment, Cycles::new(0)), - CanisterCyclesCostSchedule::Normal => (Cycles::new(0), payment - base_fee.real()), + let refundable_payment = match cost_schedule { + CanisterCyclesCostSchedule::Free => Cycles::new(0), + CanisterCyclesCostSchedule::Normal => payment - base_fee.real(), }; - assert_eq!(http_request_context.request.payment, expected_payment); + // Only whole per-replica allowances are refundable through the refund + // status, so `refundable_cycles` is their sum rather than the full payment + // beyond the base fee. + let expected_allowance = refundable_payment / committee_size.max(1); assert_eq!( - http_request_context.refund_status.refundable_cycles, - expected_refundable + http_request_context.refund_status.per_replica_allowance, + expected_allowance ); assert_eq!( - http_request_context.refund_status.per_replica_allowance, - expected_refundable / committee_size.max(1) + http_request_context.refund_status.refundable_cycles, + expected_allowance * committee_size.max(1) ); + // Whatever the payment covers beyond the base fee and the allowances stays + // in the payment, to be refunded along with the response. + let expected_payment = match cost_schedule { + CanisterCyclesCostSchedule::Free => payment, + CanisterCyclesCostSchedule::Normal => { + refundable_payment - expected_allowance * committee_size.max(1) + } + }; + assert_eq!(http_request_context.request.payment, expected_payment); assert_eq!( http_request_context.refund_status.refunded_cycles, Cycles::new(0) @@ -3618,6 +3632,169 @@ fn execute_flexible_canister_http_request() { } } +#[test] +fn execute_canister_http_request_refunds_truncated_allowance_remainder() { + // The payment beyond the base fee does not generally divide evenly into + // per-replica allowances, and only whole allowances are ever refunded through + // the refund status (each replica is refunded what it left of its own). The + // truncated remainder is therefore not taken out of the payment, so that it is + // refunded along with the response instead of disappearing from the cycles + // accounting. + let own_subnet = subnet_test_id(1); + let caller_canister = canister_test_id(10); + let build_test = || { + ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_caller(own_subnet, caller_canister) + .with_flexible_http_requests_enabled() + .build() + }; + + // Probe with an ample payment to learn the base fee and the committee size for + // these args. + let (base_fee, committee_size) = { + let mut probe = build_test(); + probe.inject_call_to_ic00( + Method::FlexibleHttpRequest, + flexible_http_request_args(caller_canister).encode(), + Cycles::new(100_000_000_000), + ); + probe.execute_all(); + let context = probe + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap() + .clone(); + let base_fee = + probe.http_request_base_fee(context.variable_parts_size(), &context.replication); + let committee_size = match &context.replication { + Replication::Flexible { committee, .. } => committee.len(), + other => panic!("expected flexible replication, got {other:?}"), + }; + (base_fee, committee_size) + }; + // The split only truncates if more than one replica shares the payment. + assert!(committee_size > 1); + + // Pay for one full allowance per replica, plus a remainder that is one cycle + // short of giving every replica another cycle. + let allowance = Cycles::new(1_000); + let remainder = Cycles::new(committee_size as u128 - 1); + let payment = base_fee.real() + allowance * committee_size + remainder; + + let mut test = build_test(); + test.inject_call_to_ic00( + Method::FlexibleHttpRequest, + flexible_http_request_args(caller_canister).encode(), + payment, + ); + test.execute_all(); + + let http_request_context = test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap(); + // `refundable_cycles` covers exactly the per-replica allowances, ... + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + allowance + ); + assert_eq!( + http_request_context.refund_status.refundable_cycles, + allowance * committee_size + ); + // ... and the remainder that no allowance covers was never taken out of the + // payment, so it is refunded when the response is delivered. + assert_eq!(http_request_context.request.payment, remainder); + // Only the base fee is charged (and reported as consumed) upfront. + assert_eq!( + test.state() + .metadata + .subnet_metrics + .get_consumed_cycles_http_outcalls(), + base_fee.nominal() + ); + assert_eq!( + *test + .state() + .metadata + .subnet_metrics + .get_consumed_cycles_by_use_case() + .get(&CyclesUseCase::HTTPOutcalls) + .unwrap(), + base_fee.nominal() + ); +} + +#[test] +fn execute_canister_http_request_caps_allowance_at_worst_case_cost() { + // A replica can never spend more than the worst-case cost of the outcall, so + // whatever the payment covers beyond that is not held back as allowance but + // refunded along with the response. + let own_subnet = subnet_test_id(1); + let caller_canister = canister_test_id(10); + let mut test = ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_caller(own_subnet, caller_canister) + .with_flexible_http_requests_enabled() + .build(); + + // A payment far beyond what the committee can possibly spend on this outcall. + let payment = Cycles::new(1_000_000_000_000_000); + test.inject_call_to_ic00( + Method::FlexibleHttpRequest, + flexible_http_request_args(caller_canister).encode(), + payment, + ); + test.execute_all(); + + let http_request_context = test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap(); + let committee_size = match &http_request_context.replication { + Replication::Flexible { committee, .. } => committee.len(), + other => panic!("expected flexible replication, got {other:?}"), + }; + let base_fee = test.http_request_base_fee( + http_request_context.variable_parts_size(), + &http_request_context.replication, + ); + let max_usage_fee = test.max_http_request_usage_fee( + &http_request_context.replication, + http_request_context.max_response_bytes, + http_request_context.subnet_size, + ); + let refundable_payment = payment - base_fee.real(); + // The payment covers more than the outcall can possibly spend, so the worst case + // — rather than the payment — is what gets split into allowances. + assert!(max_usage_fee < refundable_payment); + let expected_allowance = max_usage_fee / committee_size; + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_allowance + ); + assert_eq!( + http_request_context.refund_status.refundable_cycles, + expected_allowance * committee_size + ); + // Everything the payment covers beyond the base fee and the allowances stays in + // the payment, to be refunded along with the response. + assert_eq!( + http_request_context.request.payment, + refundable_payment - expected_allowance * committee_size + ); +} + #[test] fn execute_flexible_canister_http_request_free_subnet_uses_legacy() { // On a free subnet, flexible outcalls are available by default (without the @@ -3778,21 +3955,28 @@ fn execute_flexible_canister_http_request_explicit_replication() { assert_eq!(min_responses, 2); assert_eq!(max_responses, 4); - // Pay-as-you-go takes the entire payment upfront and splits the refundable - // remainder across the committee. + // Pay-as-you-go takes the base fee plus the per-replica allowances upfront and + // splits the refundable payment across the committee. let base_fee = test.http_request_base_fee( http_request_context.variable_parts_size(), &http_request_context.replication, ); - let expected_refundable = payment - base_fee.real(); - assert_eq!(http_request_context.request.payment, Cycles::new(0)); + let refundable_payment = payment - base_fee.real(); + // Only whole per-replica allowances are refundable through the refund status, + // so `refundable_cycles` is their sum rather than the full payment beyond the + // base fee; the truncated remainder stays in the payment. + let expected_allowance = refundable_payment / committee_size.max(1); + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_allowance + ); assert_eq!( http_request_context.refund_status.refundable_cycles, - expected_refundable + expected_allowance * committee_size.max(1) ); assert_eq!( - http_request_context.refund_status.per_replica_allowance, - expected_refundable / committee_size.max(1) + http_request_context.request.payment, + refundable_payment - expected_allowance * committee_size.max(1) ); } diff --git a/rs/execution_environment/tests/hypervisor.rs b/rs/execution_environment/tests/hypervisor.rs index 92123f64fc5b..a32693634e1d 100644 --- a/rs/execution_environment/tests/hypervisor.rs +++ b/rs/execution_environment/tests/hypervisor.rs @@ -15,8 +15,8 @@ use ic_interfaces::execution_environment::{HypervisorError, MessageMemoryUsage}; use ic_management_canister_types_private::Global; use ic_management_canister_types_private::{ CanisterChange, CanisterHttpResponsePayload, CanisterStatusType, CanisterUpgradeOptions, - EcdsaCurve, EcdsaKeyId, MasterPublicKeyId, Payload, SchnorrAlgorithm, SchnorrKeyId, - TakeCanisterSnapshotArgs, VetKdCurve, VetKdKeyId, + EcdsaCurve, EcdsaKeyId, MasterPublicKeyId, Payload, ReplicationCounts, SchnorrAlgorithm, + SchnorrKeyId, TakeCanisterSnapshotArgs, VetKdCurve, VetKdKeyId, }; use ic_nns_constants::CYCLES_MINTING_CANISTER_ID; use ic_registry_subnet_type::SubnetType; @@ -45,7 +45,8 @@ use ic_types::messages::{ use ic_types::time::CoarseTime; use ic_types::{ CanisterId, ComputeAllocation, MAX_STABLE_MEMORY_IN_BYTES, NumBytes, NumInstructions, - PrincipalId, Time, + NumberOfNodes, PrincipalId, Time, + canister_http::ReplicationKind, ingress::{IngressState, IngressStatus, WasmResult}, methods::WasmMethod, }; @@ -9370,16 +9371,33 @@ fn invoke_cost_http_request() { assert_eq!(actual_cost, expected_cost.real()); } -#[test] -fn invoke_cost_http_request_v2() { - #[derive(CandidType)] - struct CostHttpRequestV2Params { - request_bytes: u64, - http_roundtrip_time_ms: u64, - raw_response_bytes: u64, - transformed_response_bytes: u64, - transform_instructions: u64, - } +#[derive(CandidType)] +struct CostHttpRequestV2Params { + request_bytes: u64, + http_roundtrip_time_ms: u64, + raw_response_bytes: u64, + transformed_response_bytes: u64, + transform_instructions: u64, + outcall_type: Option, +} + +#[derive(CandidType, serde::Deserialize)] +enum CostHttpRequestOutcallType { + #[serde(rename = "fully_replicated")] + FullyReplicated(candid::Reserved), + #[serde(rename = "non_replicated")] + NonReplicated(candid::Reserved), + #[serde(rename = "flexible")] + Flexible(Option), +} + +/// Asks a canister for the cost of an HTTP outcall of the given `outcall_type` via +/// `ic0.cost_http_request_v2` and asserts that it matches the cost of the same +/// outcall with the given `replication_kind`. +fn assert_cost_http_request_v2( + outcall_type: Option, + replication_kind: ReplicationKind, +) { let mut test = ExecutionTestBuilder::new().build(); let canister_id = test.universal_canister().unwrap(); let request_bytes = 1000; @@ -9393,6 +9411,7 @@ fn invoke_cost_http_request_v2() { raw_response_bytes, transformed_response_bytes, transform_instructions, + outcall_type, }; let params_blob = Encode!(¶ms).unwrap(); @@ -9408,6 +9427,7 @@ fn invoke_cost_http_request_v2() { raw_response_bytes.into(), transform_instructions.into(), transformed_response_bytes.into(), + replication_kind, test.get_own_subnet_cycles_config(), ); let bytes = get_reply(res); @@ -9415,6 +9435,54 @@ fn invoke_cost_http_request_v2() { assert_eq!(actual_cost, expected_cost.real()); } +#[test] +fn invoke_cost_http_request_v2() { + // An absent `outcall_type` prices a fully-replicated outcall, as does the + // explicit variant. + assert_cost_http_request_v2(None, ReplicationKind::FullyReplicated); + assert_cost_http_request_v2( + Some(CostHttpRequestOutcallType::FullyReplicated( + candid::Reserved, + )), + ReplicationKind::FullyReplicated, + ); +} + +#[test] +fn invoke_cost_http_request_v2_non_replicated() { + assert_cost_http_request_v2( + Some(CostHttpRequestOutcallType::NonReplicated(candid::Reserved)), + ReplicationKind::NonReplicated, + ); +} + +#[test] +fn invoke_cost_http_request_v2_flexible() { + assert_cost_http_request_v2( + Some(CostHttpRequestOutcallType::Flexible(Some( + ReplicationCounts { + total_requests: 4, + min_responses: 2, + max_responses: 3, + }, + ))), + ReplicationKind::Flexible { + total_requests: 4, + min_responses: 2, + max_responses: 3, + }, + ); +} + +#[test] +fn invoke_cost_http_request_v2_flexible_without_counts_uses_the_defaults() { + let subnet_size = ExecutionTestBuilder::new().build().subnet_size(); + assert_cost_http_request_v2( + Some(CostHttpRequestOutcallType::Flexible(None)), + ReplicationKind::default_flexible(NumberOfNodes::from(subnet_size as u32)), + ); +} + #[test] fn cost_http_request_v2_fails_with_too_big_candid() { #[derive(CandidType)] @@ -9424,6 +9492,7 @@ fn cost_http_request_v2_fails_with_too_big_candid() { raw_response_bytes: u64, transformed_response_bytes: u64, transform_instructions: u64, + outcall_type: Option, garbage: Vec, } let mut test = ExecutionTestBuilder::new().build(); @@ -9434,6 +9503,7 @@ fn cost_http_request_v2_fails_with_too_big_candid() { let transformed_response_bytes = 800_000; let transform_instructions = 500_000_000; let garbage = "Some garbage to DoS the System API by making Candid decoding more expensive" + .repeat(10) .as_bytes() .into(); let params = CostHttpRequestV2ParamsExtended { @@ -9442,6 +9512,7 @@ fn cost_http_request_v2_fails_with_too_big_candid() { raw_response_bytes, transformed_response_bytes, transform_instructions, + outcall_type: None, garbage, }; let params_blob = Encode!(¶ms).unwrap(); diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 7da3c87b8d70..f19400552424 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -1,21 +1,36 @@ //! Pay-as-you-go pricing for HTTP outcalls. //! //! A request's cost is split into three parts, all defined here so that they -//! share a single set of constants: +//! share a single set of constants and formulas: //! 1. the base fee ([`base_fee`]), subtracted up-front when the request context //! is created (and therefore reflected in `per_replica_allowance`); -//! 2. the per-replica fee, accounted for as-you-go by the `PayAsYouGoTracker` -//! (see `payg.rs`), which uses the per-replica constants below; +//! 2. the per-replica fees ([`network_usage_fee`], [`transform_usage_fee`], +//! [`gossip_usage_fee`]), accounted for as-you-go by the +//! `PayAsYouGoTracker` (see `payg.rs`); //! 3. the consensus fee ([`non_flexible_initial_spent`] / //! [`flexible_initial_spent`]), computed from the aggregated response in the //! block payload. +//! +//! [`total_fee`] adds those three parts up for a hypothetical outcall, which is the +//! price reported to canisters; its second and third part — everything the +//! per-replica allowances have to cover — is [`usage_fee`], whose worst case +//! ([`max_usage_fee`]) in turn bounds how much of a request's payment is withheld as +//! allowances. + +use std::time::Duration; +use ic_config::subnet_config::MAX_INSTRUCTIONS_PER_QUERY_MESSAGE; use ic_types::{ - NumBytes, NumberOfNodes, - canister_http::{CanisterHttpResponseProof, CanisterHttpResponseShare, Replication}, + NumBytes, NumInstructions, NumberOfNodes, + canister_http::{ + CanisterHttpResponseProof, CanisterHttpResponseShare, MAX_CANISTER_HTTP_RESPONSE_BYTES, + Replication, ReplicationKind, + }, }; use ic_types_cycles::{CompoundCycles, Cycles, CyclesAccountManagerSubnetConfig, HTTPOutcalls}; +use crate::MAX_RESPONSE_TIME; + // ============================ Base-fee constants ============================ // Charged up-front for every request by [`base_fee`]. @@ -28,7 +43,8 @@ const HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE: u128 = 2_000; const HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE: u128 = 100_000; // ========================= Per-replica-fee constants ========================= -// Charged as-you-go by the `PayAsYouGoTracker` in `payg.rs`. +// Charged as-you-go by the `PayAsYouGoTracker` in `payg.rs`, through the +// per-replica fee functions below. pub(crate) const PER_DOWNLOADED_BYTE_FEE: u128 = 50; pub(crate) const PER_RESPONSE_MS_FEE: u128 = 300; @@ -54,50 +70,217 @@ pub fn base_fee( replication: &Replication, subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { - let n = subnet_cycles_config.subnet_size as u128; + CompoundCycles::new( + Cycles::new(base_fee_amount( + request_size, + replication.kind(), + subnet_cycles_config.subnet_size as u128, + )), + subnet_cycles_config.cost_schedule, + ) +} + +/// The [`base_fee`], on a subnet of `n` nodes. +fn base_fee_amount(request_size: NumBytes, replication_kind: ReplicationKind, n: u128) -> u128 { let request_bytes = request_size.get() as u128; - let per_replica = match replication { - Replication::FullyReplicated => { + let per_replica = match replication_kind { + ReplicationKind::FullyReplicated => { HTTP_REQUEST_BASE_FEE + HTTP_REQUEST_PER_BYTE_FEE * request_bytes + HTTP_REQUEST_FULLY_REPLICATED_PER_NODE_FEE * n + HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * n } - Replication::Flexible { - min_responses: min, .. - } => { - let min = *min as u128; - HTTP_REQUEST_BASE_FEE - + HTTP_REQUEST_PER_BYTE_FEE * request_bytes - + HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE * n - + HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * min - + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min - } - Replication::NonReplicated(_) => { - // Non-replicated is equivalent to flexible replication with min_responses = 1. - HTTP_REQUEST_BASE_FEE - + HTTP_REQUEST_PER_BYTE_FEE * request_bytes - + HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE * n - + HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n - + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE + // Non-replicated is equivalent to flexible replication with min_responses = 1. + ReplicationKind::NonReplicated => flexible_base_fee_per_replica(request_bytes, n, 1), + ReplicationKind::Flexible { min_responses, .. } => { + flexible_base_fee_per_replica(request_bytes, n, min_responses as u128) } }; - CompoundCycles::new( - Cycles::new(n * per_replica), - subnet_cycles_config.cost_schedule, - ) + n * per_replica +} + +/// The per-replica part of the base fee of a gossiping (flexible or +/// non-replicated) outcall of `request_bytes` bytes that requires +/// `min_responses` responses, on a subnet of `n` nodes. +fn flexible_base_fee_per_replica(request_bytes: u128, n: u128, min_responses: u128) -> u128 { + HTTP_REQUEST_BASE_FEE + + HTTP_REQUEST_PER_BYTE_FEE * request_bytes + + HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE * n + + HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * min_responses + + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min_responses +} + +// ============================= Per-replica fees ============================= +// The individual fees the `PayAsYouGoTracker` (see `payg.rs`) charges a replica +// as it performs the outcall. + +/// The fee for downloading a response of `response_size` in `response_time`. +pub(crate) fn network_usage_fee(response_size: NumBytes, response_time: Duration) -> u128 { + PER_DOWNLOADED_BYTE_FEE + .saturating_mul(response_size.get() as u128) + .saturating_add(PER_RESPONSE_MS_FEE.saturating_mul(response_time.as_millis())) +} + +/// The fee for running the transform function for `instructions`. +pub(crate) fn transform_usage_fee(instructions: NumInstructions) -> u128 { + (instructions.get() as u128) / TRANSFORM_INSTRUCTION_DIVISOR +} + +/// The fee for gossiping a transformed response of `transformed_response_size` to +/// all `subnet_size` nodes of the subnet. Only gossiping (flexible and +/// non-replicated) outcalls are charged this; for fully-replicated ones, +/// disseminating the response is a consensus cost instead. +pub(crate) fn gossip_usage_fee( + transformed_response_size: NumBytes, + subnet_size: NumberOfNodes, +) -> u128 { + FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE + .saturating_mul(transformed_response_size.get() as u128) + .saturating_mul(subnet_size.get() as u128) } // =============================== Consensus fee =============================== -/// Per-response-byte consensus cost coefficient `N * (10 * N + 600)`, where `N` -/// is the subnet size. -pub fn consensus_cost_coefficient(subnet_size: NumberOfNodes) -> u128 { +/// The consensus fee for putting `response_bytes` many response bytes into a +/// block on a subnet of size `subnet_size`. +fn consensus_fee(response_bytes: u128, subnet_size: NumberOfNodes) -> Cycles { let n = subnet_size.get() as u128; - n * (CONSENSUS_PER_NODE_BYTE_FEE * n + CONSENSUS_BYTE_FEE) + Cycles::from(n * (CONSENSUS_PER_NODE_BYTE_FEE * n + CONSENSUS_BYTE_FEE) * response_bytes) } +/// The additional consensus fee charged for `extra_responses` flexible responses +/// beyond the `min_responses` required to reach consensus. +fn flexible_extra_response_fee(extra_responses: u128, subnet_size: NumberOfNodes) -> Cycles { + let n = subnet_size.get() as u128; + Cycles::from( + n * (HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * extra_responses + + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * extra_responses), + ) +} + +// ================================= Total fee ================================= + +/// The total fee for an HTTP outcall of the given `replication_kind` that consumed +/// the given resources, i.e. the sum of all three parts of its price: the up-front +/// [`base_fee`], the per-replica fees of every replica attempting it, and the +/// consensus fee of putting the responses that are delivered into a block. +/// Used by canisters to estimate the cost of an outcall. +pub fn total_fee( + request_size: NumBytes, + http_roundtrip_time: Duration, + raw_response_size: NumBytes, + transform_instructions: NumInstructions, + transformed_response_size: NumBytes, + replication_kind: ReplicationKind, + subnet_cycles_config: CyclesAccountManagerSubnetConfig, +) -> CompoundCycles { + let n = subnet_cycles_config.subnet_size as u128; + let subnet_size = NumberOfNodes::from(subnet_cycles_config.subnet_size as u32); + let amount = Cycles::new(base_fee_amount(request_size, replication_kind, n)) + + usage_fee( + replication_kind, + http_roundtrip_time, + raw_response_size, + transform_instructions, + transformed_response_size, + subnet_size, + ); + + CompoundCycles::new(amount, subnet_cycles_config.cost_schedule) +} + +/// Everything an HTTP outcall of the given `replication_kind` with the given +/// resource usage costs beyond its up-front [`base_fee`]: the +/// [per-replica fee](per_replica_fee) of every replica attempting it, plus the +/// consensus fee of delivering the responses. +/// +/// This is the `initial_spent` such an outcall's response would report (see +/// [`non_flexible_initial_spent`] / [`flexible_initial_spent`]), i.e. the +/// part of the price that the per-replica allowances have to cover. +fn usage_fee( + replication_kind: ReplicationKind, + http_roundtrip_time: Duration, + raw_response_size: NumBytes, + transform_instructions: NumInstructions, + transformed_response_size: NumBytes, + subnet_size: NumberOfNodes, +) -> Cycles { + let transformed_bytes = transformed_response_size.get() as u128; + // How the responses reach the rest of the subnet, and which of them consensus + // delivers, is all the replication kind decides here. + let (gossip_fee, consensus_fee) = match replication_kind { + // Every replica produces the same response, so it doesn't need to be gossiped + // and consensus is reached on that single response. + ReplicationKind::FullyReplicated => (0, consensus_fee(transformed_bytes, subnet_size)), + // The single replica gossips its own response, which is then delivered. + ReplicationKind::NonReplicated => ( + gossip_usage_fee(transformed_response_size, subnet_size), + consensus_fee(transformed_bytes, subnet_size), + ), + // Every replica gossips its own response, and up to `max_responses` of them + // are delivered — those beyond `min_responses` at an extra fee. + ReplicationKind::Flexible { + min_responses, + max_responses, + .. + } => { + let responses = max_responses as u128; + ( + gossip_usage_fee(transformed_response_size, subnet_size), + consensus_fee( + responses * (FLEXIBLE_RESPONSE_SIZE_OVERHEAD + transformed_bytes), + subnet_size, + ) + flexible_extra_response_fee( + responses.saturating_sub(min_responses as u128), + subnet_size, + ), + ) + } + }; + + let per_replica_fee = network_usage_fee(raw_response_size, http_roundtrip_time) + + transform_usage_fee(transform_instructions) + + gossip_fee; + let replicas = replication_kind.node_count(subnet_size) as u128; + + Cycles::new(replicas * per_replica_fee) + consensus_fee +} + +// ============================= Maximum usage fee ============================= + +/// The most an HTTP outcall with the given `replication` and `max_response_bytes` +/// can ever spend, i.e. its [`usage_fee`] in the worst case: a response of the +/// largest permitted size, downloaded over the full [`MAX_RESPONSE_TIME`], +/// transformed with the full query instruction limit and delivered as a maximally +/// large transformed response. +/// +/// This is everything the per-replica allowances of a request have to cover, so +/// withholding more of its payment than this cannot buy the caller anything. +/// +/// The result is rounded up to a multiple of the number of participating replicas, +/// so that splitting it evenly among them always yields allowances that add up to at +/// least the worst case. +pub fn max_usage_fee( + replication: &Replication, + max_response_bytes: Option, + subnet_size: NumberOfNodes, +) -> Cycles { + let replication_kind = replication.kind(); + let worst_case = usage_fee( + replication_kind, + MAX_RESPONSE_TIME, + max_response_bytes.unwrap_or(NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES)), + MAX_INSTRUCTIONS_PER_QUERY_MESSAGE, + NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES), + subnet_size, + ); + let node_count = replication_kind.node_count(subnet_size) as u128; + Cycles::new(worst_case.get().div_ceil(node_count) * node_count) +} + +// =============================== Initial spent =============================== + /// Computes the collective initial spent cycles for a fully-replicated (or /// non-replicated) HTTP outcall response. /// @@ -113,9 +296,7 @@ pub fn non_flexible_initial_spent( .values() .map(|sig| sig.payment_receipt.spent) .sum(); - let consensus_cost = - Cycles::from(consensus_cost_coefficient(subnet_size) * proof.metadata.content_size as u128); - spent_sum + consensus_cost + spent_sum + consensus_fee(proof.metadata.content_size as u128, subnet_size) } /// Computes the collective initial spent cycles for a group of flexible HTTP @@ -143,14 +324,10 @@ pub fn flexible_initial_spent<'a>( size_term += FLEXIBLE_RESPONSE_SIZE_OVERHEAD + share.content.content_size() as u128; count += 1; } - let n = subnet_size.get() as u128; - let consensus_cost = Cycles::from(consensus_cost_coefficient(subnet_size) * size_term); let extra_responses = count.saturating_sub(min_responses) as u128; - let extra_cost = Cycles::from( - n * (HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * extra_responses - + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * extra_responses), - ); - spent_sum + consensus_cost + extra_cost + spent_sum + + consensus_fee(size_term, subnet_size) + + flexible_extra_response_fee(extra_responses, subnet_size) } #[cfg(test)] @@ -170,6 +347,7 @@ mod tests { messages::CallbackId, signature::BasicSignature, }; + use ic_types_cycles::CanisterCyclesCostSchedule; use std::collections::BTreeMap; fn node(i: u64) -> NodeId { @@ -227,14 +405,6 @@ mod tests { } } - #[test] - fn consensus_cost_coefficient_matches_formula() { - // N * (10 * N + 600). - assert_eq!(consensus_cost_coefficient(NumberOfNodes::from(1)), 610); - assert_eq!(consensus_cost_coefficient(NumberOfNodes::from(13)), 9_490); - assert_eq!(consensus_cost_coefficient(NumberOfNodes::from(40)), 40_000); - } - #[test] fn non_flexible_initial_spent_sums_receipts_plus_consensus_cost() { // Two signers spending distinct amounts (so a dropped or double-counted @@ -292,4 +462,227 @@ mod tests { let spent = flexible_initial_spent(std::iter::empty(), NumberOfNodes::from(13), 0); assert_eq!(spent, Cycles::zero()); } + + /// The terms shared by every worst case at N = 13: the 60 s latency cap and the + /// full 5 B-instruction query limit. + const MAX_LATENCY_FEE: u128 = 300 * 60_000; // 18_000_000 + const MAX_TRANSFORM_FEE: u128 = 5_000_000_000 / 13; // 384_615_384 + /// `50 * 2_000_000 * 13`, the gossip fee for a maximally large transformed + /// response at N = 13. + const MAX_GOSSIP_FEE: u128 = 1_300_000_000; + /// `9_490 * 2_000_000`, the consensus fee for a maximally large response at + /// N = 13. + const MAX_NON_FLEXIBLE_CONSENSUS_FEE: u128 = 18_980_000_000; + + /// The resource usage the `total_fee` tests below price, and the config they + /// price it on: a 100-byte request whose 1_000-byte response arrived in 2_000 ms, + /// was transformed with 26 instructions and ended up 500 bytes, at N = 13. + fn total_fee_of(replication_kind: ReplicationKind) -> CompoundCycles { + total_fee( + NumBytes::from(100), + Duration::from_millis(2_000), + NumBytes::from(1_000), + NumInstructions::from(26), + NumBytes::from(500), + replication_kind, + CyclesAccountManagerSubnetConfig::new(13, CanisterCyclesCostSchedule::Normal, 13), + ) + } + + /// `50 * 1_000 + 300 * 2_000 + 26 / 13`, the network and transform fees of a + /// single replica for the usage priced by [`total_fee_of`]. + const USAGE_FEE: u128 = 650_002; + + #[test] + fn total_fee_fully_replicated_charges_no_gossip() { + // All 13 replicas produce the same response, whose dissemination is charged + // as a consensus fee rather than a per-replica gossip fee. + // base fee = 13 * (1_000_000 + 50*100 + 140_000*13 + 800*13*13) + // = 38_482_600 + // per replica = 13 * 650_002 = 8_450_026 + // consensus = 9_490 * 500 = 4_745_000 + let fee = total_fee_of(ReplicationKind::FullyReplicated); + assert_eq!( + fee.real(), + Cycles::new(38_482_600 + 13 * USAGE_FEE + 4_745_000) + ); + + // The parts add up to the single-expression formula of + // `ic0.cost_http_request_v2`, with the consensus fee's `10 * N + 600` as the + // per-transformed-byte coefficient. (The formula was originally specified + // with `10 * N + 650`, charging every replica another 50 per transformed + // byte; that is not what `PayAsYouGoTracker` charges a fully-replicated + // replica, so it is not reported either.) + let n = 13_u128; + assert_eq!( + fee.real(), + Cycles::new( + n * (1_000_000 + + 50 * 100 + + 140_000 * n + + 800 * n * n + + 50 * 1_000 + + 300 * 2_000 + + 26 / 13 + + (10 * n + 600) * 500) + ) + ); + } + + #[test] + fn total_fee_non_replicated_charges_one_replica_that_gossips() { + // A single replica performs the outcall and gossips its response to all 13 + // nodes; the base fee is the flexible one with `min_responses = 1`. + // base fee = 13 * (1_000_000 + 50*100 + 90_000*13 + 2_000*13 + 100_000) + // = 29_913_000 + // per replica = 1 * (650_002 + 50*500*13) = 975_002 + // consensus = 9_490 * 500 = 4_745_000 + let fee = total_fee_of(ReplicationKind::NonReplicated); + assert_eq!( + fee.real(), + Cycles::new(29_913_000 + (USAGE_FEE + 50 * 500 * 13) + 4_745_000) + ); + } + + #[test] + fn total_fee_flexible_charges_every_delegated_replica_and_response() { + // 3 delegated replicas, 2 of whose responses suffice, so all 3 perform the + // outcall and gossip their own response, and up to 3 responses are delivered + // — one of them beyond `min_responses`. + // base fee = 13 * (1_000_000 + 50*100 + 90_000*13 + 2_000*13*2 + 100_000*2) + // = 31_551_000 + // per replica = 3 * (650_002 + 50*500*13) = 2_925_006 + // consensus = 9_490 * 3 * (181 + 500) = 19_388_070 + // extra resp. = 13 * (2_000*13 + 100_000) = 1_638_000 + let fee = total_fee_of(ReplicationKind::Flexible { + total_requests: 3, + min_responses: 2, + max_responses: 3, + }); + assert_eq!( + fee.real(), + Cycles::new(31_551_000 + 3 * (USAGE_FEE + 50 * 500 * 13) + 19_388_070 + 1_638_000) + ); + } + + #[test] + fn total_fee_is_zero_on_a_free_subnet() { + let fee = total_fee( + NumBytes::from(100), + Duration::from_millis(2_000), + NumBytes::from(1_000), + NumInstructions::from(26), + NumBytes::from(500), + ReplicationKind::FullyReplicated, + CyclesAccountManagerSubnetConfig::new(13, CanisterCyclesCostSchedule::Free, 13), + ); + assert_eq!(fee.real(), Cycles::zero()); + // The nominal cost is still reported. + assert!(!fee.nominal().is_zero()); + } + + #[test] + fn max_usage_fee_fully_replicated() { + // A fully-replicated outcall at N = 13 without a response limit, so the + // download term uses the full 2 MB. All 13 replicas perform it, and their + // single agreed-on response is disseminated by consensus rather than + // gossiped. + // per replica = 50 * 2_000_000 = 100_000_000 + // + latency = 18_000_000 + // + transform = 384_615_384 + // consensus = 9_490 * 2_000_000 = 18_980_000_000 + let per_replica = 100_000_000 + MAX_LATENCY_FEE + MAX_TRANSFORM_FEE; + assert_eq!( + max_usage_fee(&Replication::FullyReplicated, None, NumberOfNodes::from(13)), + Cycles::new(13 * per_replica + MAX_NON_FLEXIBLE_CONSENSUS_FEE) + ); + } + + #[test] + fn max_usage_fee_non_replicated_charges_one_replica_that_gossips() { + // A non-replicated outcall with a 1 KB response limit at N = 13: a single + // replica performs it and gossips a maximally large transformed response to + // the whole subnet. + // per replica = 50 * 1_000 = 50_000 + // + latency = 18_000_000 + // + transform = 384_615_384 + // + gossip = 1_300_000_000 + // consensus = 9_490 * 2_000_000 = 18_980_000_000 + let per_replica = 50_000 + MAX_LATENCY_FEE + MAX_TRANSFORM_FEE + MAX_GOSSIP_FEE; + assert_eq!( + max_usage_fee( + &Replication::NonReplicated(node(0)), + Some(NumBytes::from(1_000)), + NumberOfNodes::from(13) + ), + Cycles::new(per_replica + MAX_NON_FLEXIBLE_CONSENSUS_FEE) + ); + } + + #[test] + fn max_usage_fee_flexible_covers_all_delegated_replicas_and_responses() { + // A flexible outcall with a committee of 3 (2 of which suffice) and a 1 KB + // response limit at N = 13: all 3 replicas perform it and gossip their own + // maximally large response, and all 3 responses are delivered, one of them + // beyond `min_responses`. + // per replica = 50_000 + latency + transform + gossip + // consensus = 9_490 * 3 * (181 + 2_000_000) = 56_945_153_070 + // extra response = 13 * (2_000 * 13 + 100_000) = 1_638_000 + let per_replica = 50_000 + MAX_LATENCY_FEE + MAX_TRANSFORM_FEE + MAX_GOSSIP_FEE; + let consensus = 9_490 * 3 * (181 + 2_000_000) + 13 * (2_000 * 13 + 100_000); + let replication = Replication::Flexible { + committee: (0..3).map(node).collect(), + min_responses: 2, + max_responses: 3, + }; + assert_eq!( + max_usage_fee( + &replication, + Some(NumBytes::from(1_000)), + NumberOfNodes::from(13) + ), + Cycles::new(3 * per_replica + consensus) + ); + } + + #[test] + fn max_usage_fee_is_rounded_up_to_whole_allowances() { + // Whatever the worst case, the reported maximum is a multiple of the number of + // participating replicas and at most `node_count - 1` cycles above the actual + // worst case, so that splitting it evenly always covers that worst case. + for replication in [ + Replication::FullyReplicated, + Replication::NonReplicated(node(0)), + // A committee of 4, whose worst case is not a multiple of 4. + Replication::Flexible { + committee: (0..4).map(node).collect(), + min_responses: 2, + max_responses: 3, + }, + ] { + let subnet_size = NumberOfNodes::from(13); + let replication_kind = replication.kind(); + let node_count = replication_kind.node_count(subnet_size) as u128; + let worst_case = usage_fee( + replication_kind, + MAX_RESPONSE_TIME, + NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES), + MAX_INSTRUCTIONS_PER_QUERY_MESSAGE, + NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES), + subnet_size, + ); + let rounded = max_usage_fee(&replication, None, subnet_size); + + assert_eq!(rounded.get() % node_count, 0, "{replication:?}"); + assert!( + rounded >= worst_case && rounded.get() - worst_case.get() < node_count, + "{replication:?}: {rounded} not within a rounding step of {worst_case}" + ); + // Splitting it evenly covers the worst case, which is the point. + assert!( + rounded / node_count * node_count >= worst_case, + "{replication:?}" + ); + } + } } diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index e61195c2cb7b..8ca5d200dc5c 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -8,10 +8,7 @@ use ic_types::{ }; use ic_types_cycles::{CanisterCyclesCostSchedule, Cycles}; -use crate::fees::{ - FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE, PER_DOWNLOADED_BYTE_FEE, PER_RESPONSE_MS_FEE, - TRANSFORM_INSTRUCTION_DIVISOR, -}; +use crate::fees::{gossip_usage_fee, network_usage_fee, transform_usage_fee}; use crate::{AdapterLimits, BudgetTracker, MAX_RESPONSE_TIME, NetworkUsage, PricingError}; /// This tracker implements the per-replica part of pay-as-you-go pricing. The formula @@ -97,10 +94,7 @@ impl BudgetTracker for PayAsYouGoTracker { 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())); - self.charge(cost) + self.charge(network_usage_fee(response_size, response_time)) } fn get_transform_limit(&self) -> NumInstructions { @@ -109,8 +103,7 @@ impl BudgetTracker for PayAsYouGoTracker { } fn subtract_transform_usage(&mut self, usage: NumInstructions) -> Result<(), PricingError> { - let cost = (usage.get() as u128) / TRANSFORM_INSTRUCTION_DIVISOR; - self.charge(cost) + self.charge(transform_usage_fee(usage)) } fn subtract_gossip_usage( @@ -123,10 +116,10 @@ impl BudgetTracker for PayAsYouGoTracker { if !self.is_gossiping { return Ok(()); } - let cost = FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE - .saturating_mul(transformed_response_size.get() as u128) - .saturating_mul(self.subnet_size.get() as u128); - self.charge(cost) + self.charge(gossip_usage_fee( + transformed_response_size, + self.subnet_size, + )) } fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { @@ -149,6 +142,10 @@ impl BudgetTracker for PayAsYouGoTracker { #[cfg(test)] mod tests { use super::*; + use crate::fees::{ + FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE, PER_DOWNLOADED_BYTE_FEE, PER_RESPONSE_MS_FEE, + TRANSFORM_INSTRUCTION_DIVISOR, + }; use ic_types::{ CanisterId, NodeId, PrincipalId, RegistryVersion, canister_http::{CanisterHttpMethod, PricingVersion, RefundStatus}, diff --git a/rs/test_utilities/execution_environment/src/lib.rs b/rs/test_utilities/execution_environment/src/lib.rs index 4fffd9477e91..9df2133ce6e9 100644 --- a/rs/test_utilities/execution_environment/src/lib.rs +++ b/rs/test_utilities/execution_environment/src/lib.rs @@ -85,7 +85,7 @@ use ic_types::{ }, time::UNIX_EPOCH, }; -use ic_types::{ExecutionRound, RegistryVersion, ReplicaVersion}; +use ic_types::{ExecutionRound, NumberOfNodes, RegistryVersion, ReplicaVersion}; use ic_types_cycles::{ CanisterCreation, CanisterCyclesCostSchedule, CompoundCycles, Cycles, CyclesUseCase, HTTPOutcalls, Instructions, NominalCycles, RequestAndResponseTransmission, @@ -583,6 +583,19 @@ impl ExecutionTest { ) } + pub fn max_http_request_usage_fee( + &self, + replication: &Replication, + max_response_bytes: Option, + subnet_size: NumberOfNodes, + ) -> Cycles { + self.cycles_account_manager.max_http_request_usage_fee( + replication, + max_response_bytes, + subnet_size, + ) + } + pub fn reduced_wasm_compilation_fee(&self, wasm: &[u8]) -> Cycles { let cost = wasm_compilation_cost(wasm); self.convert_instructions_to_cycles( diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index c2eec62ee9c6..e33b2bd9075b 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -194,8 +194,16 @@ impl Replication { pub fn kind(&self) -> ReplicationKind { match self { Replication::FullyReplicated => ReplicationKind::FullyReplicated, - Replication::Flexible { .. } => ReplicationKind::Flexible, Replication::NonReplicated(_) => ReplicationKind::NonReplicated, + Replication::Flexible { + committee, + min_responses, + max_responses, + } => ReplicationKind::Flexible { + total_requests: committee.len() as u32, + min_responses: *min_responses, + max_responses: *max_responses, + }, } } @@ -206,19 +214,25 @@ impl Replication { /// its `per_replica_allowance` and the number of allowances that have to be /// accounted for before the request is fully settled. pub fn node_count(&self, subnet_size: NumberOfNodes) -> usize { - match self { - Replication::FullyReplicated => (subnet_size.get() as usize).max(1), - Replication::NonReplicated(_) => 1, - Replication::Flexible { committee, .. } => committee.len().max(1), - } + self.kind().node_count(subnet_size) } } -/// The kind of replication of a request. +/// The kind of replication of a request, along with the response counts of a +/// flexible one. +/// +/// This is everything the pricing of an outcall depends on. Unlike [`Replication`] +/// it does not identify the participating nodes, so it can also describe a +/// hypothetical outcall — e.g. one whose cost a canister asks for via +/// `ic0.cost_http_request_v2`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReplicationKind { FullyReplicated, - Flexible, + Flexible { + total_requests: u32, + min_responses: u32, + max_responses: u32, + }, NonReplicated, } @@ -226,10 +240,32 @@ impl ReplicationKind { pub fn as_str(&self) -> &'static str { match self { ReplicationKind::FullyReplicated => "fully_replicated", - ReplicationKind::Flexible => "flexible", + ReplicationKind::Flexible { .. } => "flexible", ReplicationKind::NonReplicated => "non_replicated", } } + + /// The flexible replication a request that does not specify its response counts + /// gets on a subnet of `subnet_size` nodes: every node attempts the outcall and + /// `floor(2/3 * N) + 1` responses are required. + pub fn default_flexible(subnet_size: NumberOfNodes) -> Self { + let n = subnet_size.get(); + Self::Flexible { + total_requests: n, + min_responses: (2 * n) / 3 + 1, + max_responses: n, + } + } + + /// The number of replicas that will attempt the outcall, see + /// [`Replication::node_count`]. + pub fn node_count(&self, subnet_size: NumberOfNodes) -> usize { + match self { + Self::FullyReplicated => (subnet_size.get() as usize).max(1), + Self::NonReplicated => 1, + Self::Flexible { total_requests, .. } => (*total_requests as usize).max(1), + } + } } #[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize, Serialize, FromRepr)] @@ -708,8 +744,15 @@ impl CanisterHttpRequestContext { if n == 0 { return Err(CanisterHttpRequestContextError::NoNodesAvailableForDelegation); } - let default_min = (2 * n) / 3 + 1; // floor(2/3 * n) + 1 - (n, default_min, n) + let ReplicationKind::Flexible { + total_requests, + min_responses, + max_responses, + } = ReplicationKind::default_flexible(NumberOfNodes::from(n)) + else { + unreachable!("`default_flexible` returns flexible replication"); + }; + (total_requests, min_responses, max_responses) } }; // From here, the following invariants are expected to hold: From 4c29ddc0e6618eb07ee1dbd73da5461e69873809 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 3 Aug 2026 14:33:34 +0000 Subject: [PATCH 02/15] clean --- rs/embedders/src/wasmtime_embedder/system_api.rs | 4 ---- .../src/execution_environment.rs | 5 +++-- .../src/execution_environment/tests.rs | 13 +++---------- rs/types/types/src/canister_http.rs | 8 +------- 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index c7dce0501bc3..67caf8f3402e 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -1986,10 +1986,6 @@ struct CostHttpRequestV2Params { outcall_type: Option, } -/// The `outcall_type` of [`CostHttpRequestV2Params`]: which of the management -/// canister's HTTP-outcall endpoints the cost is asked for. `flexible` carries the -/// response counts the request would specify, absent meaning the defaults derived -/// from the subnet size. #[derive(CandidType, Deserialize)] enum CostHttpRequestOutcallType { #[serde(rename = "fully_replicated")] diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 595565efa9d9..627e124b6ca5 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -2290,8 +2290,9 @@ impl ExecutionEnvironment { canister_http_request_context.request.payment -= legacy_fee.real(); } PricingVersion::PayAsYouGo => { - // Take out the base fee plus the per-replica allowances, i.e. an - // upper bound on what the outcall can cost. + // Deduct the base fee plus the per-replica allowances. + // The remaining payment is refunded when the response is delivered. + // Part of the per-replica allowances may be refunded after the response is delivered. if !http_outcalls_are_free { canister_http_request_context.request.payment -= base_fee.real() + refundable_cycles; diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index d69a343a5892..096fba47b09a 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -3598,9 +3598,6 @@ fn execute_flexible_canister_http_request() { CanisterCyclesCostSchedule::Free => Cycles::new(0), CanisterCyclesCostSchedule::Normal => payment - base_fee.real(), }; - // Only whole per-replica allowances are refundable through the refund - // status, so `refundable_cycles` is their sum rather than the full payment - // beyond the base fee. let expected_allowance = refundable_payment / committee_size.max(1); assert_eq!( http_request_context.refund_status.per_replica_allowance, @@ -3636,10 +3633,9 @@ fn execute_flexible_canister_http_request() { fn execute_canister_http_request_refunds_truncated_allowance_remainder() { // The payment beyond the base fee does not generally divide evenly into // per-replica allowances, and only whole allowances are ever refunded through - // the refund status (each replica is refunded what it left of its own). The - // truncated remainder is therefore not taken out of the payment, so that it is - // refunded along with the response instead of disappearing from the cycles - // accounting. + // the refund status. The truncated remainder is therefore not taken out of + // the payment, so that it is refunded along with the response instead of + // disappearing from the cycles accounting. let own_subnet = subnet_test_id(1); let caller_canister = canister_test_id(10); let build_test = || { @@ -3962,9 +3958,6 @@ fn execute_flexible_canister_http_request_explicit_replication() { &http_request_context.replication, ); let refundable_payment = payment - base_fee.real(); - // Only whole per-replica allowances are refundable through the refund status, - // so `refundable_cycles` is their sum rather than the full payment beyond the - // base fee; the truncated remainder stays in the payment. let expected_allowance = refundable_payment / committee_size.max(1); assert_eq!( http_request_context.refund_status.per_replica_allowance, diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index e33b2bd9075b..9fa348e774af 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -218,13 +218,7 @@ impl Replication { } } -/// The kind of replication of a request, along with the response counts of a -/// flexible one. -/// -/// This is everything the pricing of an outcall depends on. Unlike [`Replication`] -/// it does not identify the participating nodes, so it can also describe a -/// hypothetical outcall — e.g. one whose cost a canister asks for via -/// `ic0.cost_http_request_v2`. +/// The kind of replication of a request. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReplicationKind { FullyReplicated, From d72f5d51e0bfcce06426e59a08a8703a32d1b085 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 3 Aug 2026 20:28:45 +0000 Subject: [PATCH 03/15] review --- .../src/cycles_account_manager.rs | 4 +- .../src/wasmtime_embedder/system_api.rs | 51 +++++++++++++---- .../src/execution_environment.rs | 5 +- rs/https_outcalls/pricing/src/fees.rs | 56 +++++++++++++------ rs/https_outcalls/pricing/src/payg.rs | 52 ++++++++++++++++- rs/types/types/src/canister_http.rs | 45 +++++++++------ 6 files changed, 162 insertions(+), 51 deletions(-) diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index 015476f8361d..9bd9d3d8ff28 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -1235,8 +1235,8 @@ impl CyclesAccountManager { CompoundCycles::new(amount, subnet_cycles_config.cost_schedule) } - // Returns the base fee for an HTTP outcall, which is charged for every - // request upfront. + /// Returns the base fee for an HTTP outcall, which is charged for every + /// request upfront. pub fn http_request_base_fee( &self, request_size: NumBytes, diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index 67caf8f3402e..6a8022a465f0 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -1975,6 +1975,25 @@ impl SystemApiImpl { /// Parameters of `ic0.cost_http_request_v2`, mirrors the /// management canister's HTTP-request cost arguments. +/// +/// ```text +/// record { +/// request_bytes : nat64; +/// http_roundtrip_time_ms : nat64; +/// raw_response_bytes : nat64; +/// transformed_response_bytes : nat64; +/// transform_instructions : nat64; +/// outcall_type : opt variant { +/// fully_replicated : reserved; +/// non_replicated : reserved; +/// flexible : opt record { +/// total_requests : nat32; +/// min_responses : nat32; +/// max_responses : nat32; +/// }; +/// }; +/// } +/// ``` #[derive(CandidType, Deserialize)] struct CostHttpRequestV2Params { request_bytes: u64, @@ -1982,16 +2001,22 @@ struct CostHttpRequestV2Params { raw_response_bytes: u64, transformed_response_bytes: u64, transform_instructions: u64, - /// The replication of the outcall being priced. Absent means fully replicated. + /// The replication of the outcall being priced. Absent means fully replicated, + /// so that params encoded before this field existed keep their meaning. outcall_type: Option, } +/// The replication of the outcall to be priced. The payloads of the two +/// non-flexible variants are `reserved` (rather than `null`) so that they can be +/// given a meaning later on without breaking callers. #[derive(CandidType, Deserialize)] enum CostHttpRequestOutcallType { #[serde(rename = "fully_replicated")] FullyReplicated(candid::Reserved), #[serde(rename = "non_replicated")] NonReplicated(candid::Reserved), + /// A flexible outcall with the given response counts; absent counts are the + /// defaults a `flexible_http_request` without a `replication` field gets. #[serde(rename = "flexible")] Flexible(Option), } @@ -2006,20 +2031,24 @@ impl CostHttpRequestV2Params { Some(CostHttpRequestOutcallType::Flexible(None)) => { ReplicationKind::default_flexible(subnet_size) } - Some(CostHttpRequestOutcallType::Flexible(Some(counts))) => ReplicationKind::Flexible { - total_requests: counts.total_requests, - min_responses: counts.min_responses, - max_responses: counts.max_responses, - }, + // Note that the counts are not validated here (unlike the ones of an + // actual request): a caller asking for the price of an impossible + // outcall simply gets a price that no outcall will ever cost. + Some(CostHttpRequestOutcallType::Flexible(Some(counts))) => { + ReplicationKind::from(counts) + } } } } const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; -/// How much work decoding [`CostHttpRequestV2Params`] may spend skipping the -/// `reserved` payload of an `outcall_type` variant, in Candid's decoding cost units. -const MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES: usize = 1; +/// How much work decoding [`CostHttpRequestV2Params`] may spend skipping values it +/// has no type for — i.e. the `reserved` payload of an `outcall_type` variant — in +/// Candid's decoding cost units. Skipping an empty payload is within this quota, +/// while anything larger (in particular a payload that is cheap to encode but +/// expensive to skip) is rejected. +const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 1; impl SystemApi for SystemApiImpl { fn set_execution_error(&mut self, error: HypervisorError) { @@ -4416,7 +4445,7 @@ impl SystemApi for SystemApiImpl { } let mut decoder_config = DecoderConfig::new(); - decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES); + decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA); let cost_params_v2: CostHttpRequestV2Params = decode_one_with_config(params_bytes, &decoder_config).map_err(|e| { @@ -4771,7 +4800,7 @@ mod test { .unwrap(); let mut decoder_config = DecoderConfig::new(); - decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPED_VALUES); + decoder_config.set_skipping_quota(MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA); decode_one_with_config::(&blob, &decoder_config).map(|_| ()) } diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 627e124b6ca5..afeff10dac37 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -95,7 +95,6 @@ use num_traits::SaturatingAdd; use phantom_newtype::AmountOf; use prometheus::IntCounter; use rand::RngCore; -use std::cmp::min; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::convert::{Into, TryFrom}; use std::fmt; @@ -2248,7 +2247,7 @@ impl ExecutionEnvironment { let http_outcalls_are_free = self.http_outcalls_are_free(cost_schedule); - // The refundable cycles are everything the payment covers beyond the + // The refundable payment is everything the payment covers beyond the // base fee; when the outcall is free nothing is charged, so nothing is // refundable. We set the refund status even for legacy pricing in order // to enable observability during the dark launch. However, nothing is @@ -2273,7 +2272,7 @@ impl ExecutionEnvironment { canister_http_request_context.max_response_bytes, canister_http_request_context.subnet_size, ); - let per_replica_allowance = min(refundable_payment, max_usage_fee) / node_count; + let per_replica_allowance = refundable_payment.min(max_usage_fee) / node_count; let refundable_cycles = per_replica_allowance * node_count; canister_http_request_context.refund_status = RefundStatus { refundable_cycles, diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index f19400552424..1a9c842f51d0 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -60,6 +60,11 @@ const CONSENSUS_PER_NODE_BYTE_FEE: u128 = 10; const CONSENSUS_BYTE_FEE: u128 = 600; const FLEXIBLE_RESPONSE_SIZE_OVERHEAD: u128 = 181; +/// The number of nodes of the subnet described by `subnet_cycles_config`. +fn subnet_size_of(subnet_cycles_config: CyclesAccountManagerSubnetConfig) -> NumberOfNodes { + NumberOfNodes::from(subnet_cycles_config.subnet_size as u32) +} + // ================================= Base fee ================================= /// Computes the up-front base fee for an HTTP outcall request of size @@ -74,14 +79,19 @@ pub fn base_fee( Cycles::new(base_fee_amount( request_size, replication.kind(), - subnet_cycles_config.subnet_size as u128, + subnet_size_of(subnet_cycles_config), )), subnet_cycles_config.cost_schedule, ) } -/// The [`base_fee`], on a subnet of `n` nodes. -fn base_fee_amount(request_size: NumBytes, replication_kind: ReplicationKind, n: u128) -> u128 { +/// The [`base_fee`], on a subnet of `subnet_size` nodes. +fn base_fee_amount( + request_size: NumBytes, + replication_kind: ReplicationKind, + subnet_size: NumberOfNodes, +) -> u128 { + let n = subnet_size.get() as u128; let request_bytes = request_size.get() as u128; let per_replica = match replication_kind { ReplicationKind::FullyReplicated => { @@ -146,7 +156,11 @@ pub(crate) fn gossip_usage_fee( /// block on a subnet of size `subnet_size`. fn consensus_fee(response_bytes: u128, subnet_size: NumberOfNodes) -> Cycles { let n = subnet_size.get() as u128; - Cycles::from(n * (CONSENSUS_PER_NODE_BYTE_FEE * n + CONSENSUS_BYTE_FEE) * response_bytes) + Cycles::from( + (CONSENSUS_PER_NODE_BYTE_FEE * n + CONSENSUS_BYTE_FEE) + .saturating_mul(n) + .saturating_mul(response_bytes), + ) } /// The additional consensus fee charged for `extra_responses` flexible responses @@ -154,8 +168,10 @@ fn consensus_fee(response_bytes: u128, subnet_size: NumberOfNodes) -> Cycles { fn flexible_extra_response_fee(extra_responses: u128, subnet_size: NumberOfNodes) -> Cycles { let n = subnet_size.get() as u128; Cycles::from( - n * (HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * extra_responses - + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * extra_responses), + (HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n + + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE) + .saturating_mul(n) + .saturating_mul(extra_responses), ) } @@ -175,9 +191,8 @@ pub fn total_fee( replication_kind: ReplicationKind, subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { - let n = subnet_cycles_config.subnet_size as u128; - let subnet_size = NumberOfNodes::from(subnet_cycles_config.subnet_size as u32); - let amount = Cycles::new(base_fee_amount(request_size, replication_kind, n)) + let subnet_size = subnet_size_of(subnet_cycles_config); + let amount = Cycles::new(base_fee_amount(request_size, replication_kind, subnet_size)) + usage_fee( replication_kind, http_roundtrip_time, @@ -195,9 +210,7 @@ pub fn total_fee( /// [per-replica fee](per_replica_fee) of every replica attempting it, plus the /// consensus fee of delivering the responses. /// -/// This is the `initial_spent` such an outcall's response would report (see -/// [`non_flexible_initial_spent`] / [`flexible_initial_spent`]), i.e. the -/// part of the price that the per-replica allowances have to cover. +/// This is the part of the price that the per-replica allowances have to cover. fn usage_fee( replication_kind: ReplicationKind, http_roundtrip_time: Duration, @@ -229,7 +242,9 @@ fn usage_fee( ( gossip_usage_fee(transformed_response_size, subnet_size), consensus_fee( - responses * (FLEXIBLE_RESPONSE_SIZE_OVERHEAD + transformed_bytes), + responses.saturating_mul( + FLEXIBLE_RESPONSE_SIZE_OVERHEAD.saturating_add(transformed_bytes), + ), subnet_size, ) + flexible_extra_response_fee( responses.saturating_sub(min_responses as u128), @@ -240,18 +255,18 @@ fn usage_fee( }; let per_replica_fee = network_usage_fee(raw_response_size, http_roundtrip_time) - + transform_usage_fee(transform_instructions) - + gossip_fee; + .saturating_add(transform_usage_fee(transform_instructions)) + .saturating_add(gossip_fee); let replicas = replication_kind.node_count(subnet_size) as u128; - Cycles::new(replicas * per_replica_fee) + consensus_fee + Cycles::new(replicas.saturating_mul(per_replica_fee)) + consensus_fee } // ============================= Maximum usage fee ============================= /// The most an HTTP outcall with the given `replication` and `max_response_bytes` /// can ever spend, i.e. its [`usage_fee`] in the worst case: a response of the -/// largest permitted size, downloaded over the full [`MAX_RESPONSE_TIME`], +/// largest permitted size, downloaded over the full `MAX_RESPONSE_TIME`, /// transformed with the full query instruction limit and delivered as a maximally /// large transformed response. /// @@ -276,7 +291,12 @@ pub fn max_usage_fee( subnet_size, ); let node_count = replication_kind.node_count(subnet_size) as u128; - Cycles::new(worst_case.get().div_ceil(node_count) * node_count) + Cycles::new( + worst_case + .get() + .div_ceil(node_count) + .saturating_mul(node_count), + ) } // =============================== Initial spent =============================== diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index 8ca5d200dc5c..de4a8955506a 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -144,7 +144,7 @@ mod tests { use super::*; use crate::fees::{ FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE, PER_DOWNLOADED_BYTE_FEE, PER_RESPONSE_MS_FEE, - TRANSFORM_INSTRUCTION_DIVISOR, + TRANSFORM_INSTRUCTION_DIVISOR, max_usage_fee, }; use ic_types::{ CanisterId, NodeId, PrincipalId, RegistryVersion, @@ -378,4 +378,54 @@ mod tests { MAX_HTTP_OUTCALL_SPEND_FREE_SUBNET ); } + + #[test] + fn allowance_derived_from_max_usage_fee_covers_the_largest_possible_spend() { + // `max_usage_fee` bounds how much of a request's payment is withheld as + // per-replica allowances, so it has to cover everything this tracker can + // charge a replica: the largest response the adapter may return, taking the + // longest it may take, transformed with the whole instruction limit into a + // maximally large response. If it didn't, a request paying for the worst case + // could still run out of cycles. + let node = NodeId::from(PrincipalId::new_node_test_id(0)); + for replication in [ + Replication::FullyReplicated, + Replication::NonReplicated(node), + flexible(4), + ] { + for max_response_bytes in [None, Some(NumBytes::from(1_000))] { + let subnet_size = NumberOfNodes::from(13); + let node_count = replication.node_count(subnet_size); + let allowance = + max_usage_fee(&replication, max_response_bytes, subnet_size) / node_count; + let ctx = CanisterHttpRequestContext { + max_response_bytes, + ..context(replication.clone(), allowance.get()) + }; + assert_eq!(ctx.subnet_size, subnet_size); + let mut tracker = PayAsYouGoTracker::new(&ctx); + + let limits = tracker.get_adapter_limits(); + assert_eq!( + tracker.subtract_network_usage(NetworkUsage { + response_size: limits.max_response_size, + response_time: limits.max_response_time, + }), + Ok(()), + "{replication:?}, {max_response_bytes:?}" + ); + let transform_limit = tracker.get_transform_limit(); + assert_eq!( + tracker.subtract_transform_usage(transform_limit), + Ok(()), + "{replication:?}, {max_response_bytes:?}" + ); + assert_eq!( + tracker.subtract_gossip_usage(NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES)), + Ok(()), + "{replication:?}, {max_response_bytes:?}" + ); + } + } + } } diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index 9fa348e774af..93be5dc6c2d0 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -56,7 +56,8 @@ use ic_exhaustive_derive::ExhaustiveSet; use ic_management_canister_types_private::{ ALLOWED_HTTP_OUTCALLS_PRICING_VERSIONS, CanisterHttpRequestArgs, DEFAULT_HTTP_OUTCALLS_PRICING_VERSION, DataSize, FlexibleCanisterHttpRequestArgs, HttpHeader, - HttpMethod, PRICING_VERSION_LEGACY, PRICING_VERSION_PAY_AS_YOU_GO, TransformContext, + HttpMethod, PRICING_VERSION_LEGACY, PRICING_VERSION_PAY_AS_YOU_GO, ReplicationCounts, + TransformContext, }; use ic_protobuf::{ proxy::{ProxyDecodeError, try_from_option_field}, @@ -239,18 +240,24 @@ impl ReplicationKind { } } - /// The flexible replication a request that does not specify its response counts - /// gets on a subnet of `subnet_size` nodes: every node attempts the outcall and + /// The response counts a flexible request that does not specify its own gets on + /// a subnet of `subnet_size` nodes: every node attempts the outcall and /// `floor(2/3 * N) + 1` responses are required. - pub fn default_flexible(subnet_size: NumberOfNodes) -> Self { + pub fn default_flexible_counts(subnet_size: NumberOfNodes) -> ReplicationCounts { let n = subnet_size.get(); - Self::Flexible { + ReplicationCounts { total_requests: n, min_responses: (2 * n) / 3 + 1, max_responses: n, } } + /// The flexible replication a request that does not specify its response counts + /// gets on a subnet of `subnet_size` nodes, see [`Self::default_flexible_counts`]. + pub fn default_flexible(subnet_size: NumberOfNodes) -> Self { + Self::from(&Self::default_flexible_counts(subnet_size)) + } + /// The number of replicas that will attempt the outcall, see /// [`Replication::node_count`]. pub fn node_count(&self, subnet_size: NumberOfNodes) -> usize { @@ -262,6 +269,16 @@ impl ReplicationKind { } } +impl From<&ReplicationCounts> for ReplicationKind { + fn from(counts: &ReplicationCounts) -> Self { + Self::Flexible { + total_requests: counts.total_requests, + min_responses: counts.min_responses, + max_responses: counts.max_responses, + } + } +} + #[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize, Serialize, FromRepr)] #[repr(u32)] pub enum PricingVersion { @@ -704,7 +721,11 @@ impl CanisterHttpRequestContext { let max_response_bytes = validate_max_response_bytes(args.max_response_bytes)?; let n = node_ids.len() as u32; - let (total_requests, min_responses, max_responses) = match args.replication { + let ReplicationCounts { + total_requests, + min_responses, + max_responses, + } = match args.replication { Some(counts) => { let total = counts.total_requests; let min = counts.min_responses; @@ -732,21 +753,13 @@ impl CanisterHttpRequestContext { format!("max_responses ({max}) must not exceed total_requests ({total})"), )); } - (total, min, max) + counts } None => { if n == 0 { return Err(CanisterHttpRequestContextError::NoNodesAvailableForDelegation); } - let ReplicationKind::Flexible { - total_requests, - min_responses, - max_responses, - } = ReplicationKind::default_flexible(NumberOfNodes::from(n)) - else { - unreachable!("`default_flexible` returns flexible replication"); - }; - (total_requests, min_responses, max_responses) + ReplicationKind::default_flexible_counts(NumberOfNodes::from(n)) } }; // From here, the following invariants are expected to hold: From 41cf2c54457ee3b3ae4407a168df107ea786d5ac Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Mon, 3 Aug 2026 20:36:49 +0000 Subject: [PATCH 04/15] review --- .../benches/system_api/execute_update.rs | 8 +------- rs/https_outcalls/pricing/src/fees.rs | 6 +++--- rs/types/types/src/canister_http.rs | 6 +++--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/rs/execution_environment/benches/system_api/execute_update.rs b/rs/execution_environment/benches/system_api/execute_update.rs index e2d205c244de..e825b75f9374 100644 --- a/rs/execution_environment/benches/system_api/execute_update.rs +++ b/rs/execution_environment/benches/system_api/execute_update.rs @@ -20,6 +20,7 @@ use ic_execution_environment::{ as_num_instructions, as_round_instructions, }; use ic_limits::SMALL_APP_SUBNET_MAX_SIZE; +use ic_management_canister_types_private::ReplicationCounts; use ic_types::{ ingress::{IngressState, IngressStatus}, messages::CanisterMessageOrTask, @@ -49,13 +50,6 @@ enum CostHttpRequestOutcallType { Flexible(Option), } -#[derive(CandidType, Deserialize)] -struct ReplicationCounts { - total_requests: u32, - min_responses: u32, - max_responses: u32, -} - const COST_HTTP_REQUEST_V2_PARAMS: CostHttpRequestV2Params = CostHttpRequestV2Params { request_bytes: 1_000, http_roundtrip_time_ms: 2_000, diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 1a9c842f51d0..985ef58d4ce5 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -206,9 +206,9 @@ pub fn total_fee( } /// Everything an HTTP outcall of the given `replication_kind` with the given -/// resource usage costs beyond its up-front [`base_fee`]: the -/// [per-replica fee](per_replica_fee) of every replica attempting it, plus the -/// consensus fee of delivering the responses. +/// resource usage costs beyond its up-front [`base_fee`]: the per-replica fee of +/// every replica attempting it, plus the consensus fee of delivering the responses. +/// /// /// This is the part of the price that the per-replica allowances have to cover. fn usage_fee( diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index 93be5dc6c2d0..3df0d148d2c8 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -240,8 +240,8 @@ impl ReplicationKind { } } - /// The response counts a flexible request that does not specify its own gets on - /// a subnet of `subnet_size` nodes: every node attempts the outcall and + /// The response counts for a flexible request that does not specify its own gets + /// on a subnet of `subnet_size` nodes: every node attempts the outcall and /// `floor(2/3 * N) + 1` responses are required. pub fn default_flexible_counts(subnet_size: NumberOfNodes) -> ReplicationCounts { let n = subnet_size.get(); @@ -252,7 +252,7 @@ impl ReplicationKind { } } - /// The flexible replication a request that does not specify its response counts + /// The flexible replication for a request that does not specify its response counts /// gets on a subnet of `subnet_size` nodes, see [`Self::default_flexible_counts`]. pub fn default_flexible(subnet_size: NumberOfNodes) -> Self { Self::from(&Self::default_flexible_counts(subnet_size)) From 19c7e4e5e4969fc0a52390b9614d3728f3d6c548 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Tue, 4 Aug 2026 08:14:49 +0000 Subject: [PATCH 05/15] fix --- .../src/wasmtime_embedder/system_api.rs | 12 +--- rs/execution_environment/tests/hypervisor.rs | 72 ++++++++++++++----- rs/https_outcalls/pricing/src/fees.rs | 41 ++++++++++- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index 6a8022a465f0..c54f6ce1809a 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -2006,17 +2006,14 @@ struct CostHttpRequestV2Params { outcall_type: Option, } -/// The replication of the outcall to be priced. The payloads of the two -/// non-flexible variants are `reserved` (rather than `null`) so that they can be -/// given a meaning later on without breaking callers. +/// The replication of the HTTP outcall to be priced. #[derive(CandidType, Deserialize)] enum CostHttpRequestOutcallType { #[serde(rename = "fully_replicated")] FullyReplicated(candid::Reserved), #[serde(rename = "non_replicated")] NonReplicated(candid::Reserved), - /// A flexible outcall with the given response counts; absent counts are the - /// defaults a `flexible_http_request` without a `replication` field gets. + /// `None` defaults to n, (2 * n) / 3 + 1, n #[serde(rename = "flexible")] Flexible(Option), } @@ -2044,10 +2041,7 @@ impl CostHttpRequestV2Params { const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; /// How much work decoding [`CostHttpRequestV2Params`] may spend skipping values it -/// has no type for — i.e. the `reserved` payload of an `outcall_type` variant — in -/// Candid's decoding cost units. Skipping an empty payload is within this quota, -/// while anything larger (in particular a payload that is cheap to encode but -/// expensive to skip) is rejected. +/// has no type for — i.e. the `reserved` payload of an `outcall_type` variant. const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 1; impl SystemApi for SystemApiImpl { diff --git a/rs/execution_environment/tests/hypervisor.rs b/rs/execution_environment/tests/hypervisor.rs index a32693634e1d..42f9666079e6 100644 --- a/rs/execution_environment/tests/hypervisor.rs +++ b/rs/execution_environment/tests/hypervisor.rs @@ -9397,22 +9397,29 @@ enum CostHttpRequestOutcallType { fn assert_cost_http_request_v2( outcall_type: Option, replication_kind: ReplicationKind, +) { + assert_cost_http_request_v2_params( + CostHttpRequestV2Params { + request_bytes: 1000, + http_roundtrip_time_ms: 2_000, + raw_response_bytes: 1_000_000, + transformed_response_bytes: 800_000, + transform_instructions: 500_000_000, + outcall_type, + }, + replication_kind, + ); +} + +/// Asks a canister for the cost of the HTTP outcall described by `params` via +/// `ic0.cost_http_request_v2` and asserts that it matches the cost of the same +/// outcall with the given `replication_kind`. +fn assert_cost_http_request_v2_params( + params: CostHttpRequestV2Params, + replication_kind: ReplicationKind, ) { let mut test = ExecutionTestBuilder::new().build(); let canister_id = test.universal_canister().unwrap(); - let request_bytes = 1000; - let http_roundtrip_time_ms = 2_000; - let raw_response_bytes = 1_000_000; - let transformed_response_bytes = 800_000; - let transform_instructions = 500_000_000; - let params = CostHttpRequestV2Params { - request_bytes, - http_roundtrip_time_ms, - raw_response_bytes, - transformed_response_bytes, - transform_instructions, - outcall_type, - }; let params_blob = Encode!(¶ms).unwrap(); let payload = wasm() @@ -9422,11 +9429,11 @@ fn assert_cost_http_request_v2( .build(); let res = test.ingress(canister_id, "update", payload); let expected_cost = test.cycles_account_manager().http_request_fee_v2( - request_bytes.into(), - Duration::from_millis(http_roundtrip_time_ms), - raw_response_bytes.into(), - transform_instructions.into(), - transformed_response_bytes.into(), + params.request_bytes.into(), + Duration::from_millis(params.http_roundtrip_time_ms), + params.raw_response_bytes.into(), + params.transform_instructions.into(), + params.transformed_response_bytes.into(), replication_kind, test.get_own_subnet_cycles_config(), ); @@ -9483,6 +9490,35 @@ fn invoke_cost_http_request_v2_flexible_without_counts_uses_the_defaults() { ); } +#[test] +fn cost_http_request_v2_accepts_maximal_params() { + // The largest params a caller can send: every value at its maximum, with the + // `outcall_type` variant that encodes largest. This is exactly + // `MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE` bytes, so it has to pass the size check + // and decode within the skipping quota rather than being rejected as too large. + assert_cost_http_request_v2_params( + CostHttpRequestV2Params { + request_bytes: u64::MAX, + http_roundtrip_time_ms: u64::MAX, + raw_response_bytes: u64::MAX, + transformed_response_bytes: u64::MAX, + transform_instructions: u64::MAX, + outcall_type: Some(CostHttpRequestOutcallType::Flexible(Some( + ReplicationCounts { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + ))), + }, + ReplicationKind::Flexible { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + ); +} + #[test] fn cost_http_request_v2_fails_with_too_big_candid() { #[derive(CandidType)] diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 985ef58d4ce5..0714a10bb344 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -209,7 +209,6 @@ pub fn total_fee( /// resource usage costs beyond its up-front [`base_fee`]: the per-replica fee of /// every replica attempting it, plus the consensus fee of delivering the responses. /// -/// /// This is the part of the price that the per-replica allowances have to cover. fn usage_fee( replication_kind: ReplicationKind, @@ -585,6 +584,46 @@ mod tests { ); } + /// [`total_fee`] of the largest outcall a canister can ask the price of: every + /// byte and instruction count at `u64::MAX`, every response count at `u32::MAX`. + fn total_fee_of_maximal_params(subnet_size: usize) -> Cycles { + total_fee( + NumBytes::from(u64::MAX), + Duration::from_millis(u64::MAX), + NumBytes::from(u64::MAX), + NumInstructions::from(u64::MAX), + NumBytes::from(u64::MAX), + ReplicationKind::Flexible { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + CyclesAccountManagerSubnetConfig::new( + subnet_size, + CanisterCyclesCostSchedule::Normal, + 13, + ), + ) + .real() + } + + #[test] + fn total_fee_of_maximal_params_saturates_instead_of_overflowing() { + // The cost doesn't overflow on realistic subnet sizes + for subnet_size in [0, 1, 13, 40, 1_000] { + let fee = total_fee_of_maximal_params(subnet_size); + assert!( + fee < Cycles::new(u128::MAX), + "subnet size {subnet_size}: price saturated at {fee}" + ); + } + // The cost saturates at the maximum value + assert_eq!( + total_fee_of_maximal_params(u32::MAX as usize), + Cycles::new(u128::MAX) + ); + } + #[test] fn total_fee_is_zero_on_a_free_subnet() { let fee = total_fee( From f2c2b909d2c1d63cd2b5ccfcab1b376ea5539ac8 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Tue, 4 Aug 2026 08:55:37 +0000 Subject: [PATCH 06/15] clean --- rs/embedders/src/wasmtime_embedder/system_api.rs | 2 +- .../src/execution_environment/tests.rs | 3 ++- rs/https_outcalls/pricing/src/fees.rs | 9 +++------ rs/https_outcalls/pricing/src/payg.rs | 5 +++++ rs/types/types/src/canister_http.rs | 9 ++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index c54f6ce1809a..beed07954313 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -2013,7 +2013,7 @@ enum CostHttpRequestOutcallType { FullyReplicated(candid::Reserved), #[serde(rename = "non_replicated")] NonReplicated(candid::Reserved), - /// `None` defaults to n, (2 * n) / 3 + 1, n + /// `None` defaults to n, floor(2/3 * N) + 1, n #[serde(rename = "flexible")] Flexible(Option), } diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 096fba47b09a..c61bc427e07f 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -3285,7 +3285,7 @@ fn execute_canister_http_request() { http_request_context.variable_parts_size(), &http_request_context.replication, ); - let node_count = test.subnet_size().max(1); + let node_count = test.subnet_size(); let refundable_payment = match cost_schedule { CanisterCyclesCostSchedule::Free => Cycles::new(0), CanisterCyclesCostSchedule::Normal => payment - base_fee.real(), @@ -3615,6 +3615,7 @@ fn execute_flexible_canister_http_request() { refundable_payment - expected_allowance * committee_size.max(1) } }; + assert_ne!(expected_payment, Cycles::new(0)); assert_eq!(http_request_context.request.payment, expected_payment); assert_eq!( http_request_context.refund_status.refunded_cycles, diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 0714a10bb344..050c5acc6d1c 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -101,9 +101,9 @@ fn base_fee_amount( + HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * n } // Non-replicated is equivalent to flexible replication with min_responses = 1. - ReplicationKind::NonReplicated => flexible_base_fee_per_replica(request_bytes, n, 1), + ReplicationKind::NonReplicated => gossipping_base_fee_per_replica(request_bytes, n, 1), ReplicationKind::Flexible { min_responses, .. } => { - flexible_base_fee_per_replica(request_bytes, n, min_responses as u128) + gossipping_base_fee_per_replica(request_bytes, n, min_responses as u128) } }; @@ -113,7 +113,7 @@ fn base_fee_amount( /// The per-replica part of the base fee of a gossiping (flexible or /// non-replicated) outcall of `request_bytes` bytes that requires /// `min_responses` responses, on a subnet of `n` nodes. -fn flexible_base_fee_per_replica(request_bytes: u128, n: u128, min_responses: u128) -> u128 { +fn gossipping_base_fee_per_replica(request_bytes: u128, n: u128, min_responses: u128) -> u128 { HTTP_REQUEST_BASE_FEE + HTTP_REQUEST_PER_BYTE_FEE * request_bytes + HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE * n @@ -269,9 +269,6 @@ fn usage_fee( /// transformed with the full query instruction limit and delivered as a maximally /// large transformed response. /// -/// This is everything the per-replica allowances of a request have to cover, so -/// withholding more of its payment than this cannot buy the caller anything. -/// /// The result is rounded up to a multiple of the number of participating replicas, /// so that splitting it evenly among them always yields allowances that add up to at /// least the worst case. diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index de4a8955506a..5cfecbd92df2 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -425,6 +425,11 @@ mod tests { Ok(()), "{replication:?}, {max_response_bytes:?}" ); + // There should still be allowance left for the consensus cost. + assert!( + tracker.spent < allowance.get(), + "{replication:?}, {max_response_bytes:?}" + ); } } } diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index 3df0d148d2c8..b4e158a922c8 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -240,9 +240,8 @@ impl ReplicationKind { } } - /// The response counts for a flexible request that does not specify its own gets - /// on a subnet of `subnet_size` nodes: every node attempts the outcall and - /// `floor(2/3 * N) + 1` responses are required. + /// The response counts for a flexible request that does not specify its own: + /// Every node attempts the outcall and `floor(2/3 * N) + 1` responses are required. pub fn default_flexible_counts(subnet_size: NumberOfNodes) -> ReplicationCounts { let n = subnet_size.get(); ReplicationCounts { @@ -252,8 +251,8 @@ impl ReplicationKind { } } - /// The flexible replication for a request that does not specify its response counts - /// gets on a subnet of `subnet_size` nodes, see [`Self::default_flexible_counts`]. + /// The response counts for a flexible request that does not specify its own: + /// Every node attempts the outcall and `floor(2/3 * N) + 1` responses are required. pub fn default_flexible(subnet_size: NumberOfNodes) -> Self { Self::from(&Self::default_flexible_counts(subnet_size)) } From 1d5640756917c02d00b6834798bad1d07c34b168 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Tue, 4 Aug 2026 09:25:43 +0000 Subject: [PATCH 07/15] fix --- rs/https_outcalls/consensus/src/payload_builder/tests.rs | 6 ++---- rs/https_outcalls/pricing/src/fees.rs | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/rs/https_outcalls/consensus/src/payload_builder/tests.rs b/rs/https_outcalls/consensus/src/payload_builder/tests.rs index f219e7e85adf..acb2b116d64c 100644 --- a/rs/https_outcalls/consensus/src/payload_builder/tests.rs +++ b/rs/https_outcalls/consensus/src/payload_builder/tests.rs @@ -14,7 +14,7 @@ use ic_artifact_pool::canister_http_pool::CanisterHttpPoolImpl; use ic_consensus_mocks::{Dependencies, dependencies_with_subnet_params}; use ic_error_types::RejectCode; use ic_https_outcalls_pricing::fees::{ - consensus_cost_coefficient, flexible_initial_spent, non_flexible_initial_spent, + consensus_fee, flexible_initial_spent, non_flexible_initial_spent, }; use ic_interfaces::{ batch_payload::{BatchPayloadBuilder, IntoMessages, PastPayload, ProposalContext}, @@ -5273,9 +5273,7 @@ fn with_payg_allowance( /// a subnet of `num_nodes` nodes, i.e. the part of the collective initial spend /// that the signing replicas' unspent allowances have to cover. fn non_flexible_consensus_cost(num_nodes: usize, content_size: u32) -> Cycles { - Cycles::from( - consensus_cost_coefficient(NumberOfNodes::from(num_nodes as u32)) * content_size as u128, - ) + consensus_fee(content_size as u128, NumberOfNodes::from(num_nodes as u32)) } /// A fully-replicated response is only delivered once the signing replicas have diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 050c5acc6d1c..441664bf6273 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -153,8 +153,8 @@ pub(crate) fn gossip_usage_fee( // =============================== Consensus fee =============================== /// The consensus fee for putting `response_bytes` many response bytes into a -/// block on a subnet of size `subnet_size`. -fn consensus_fee(response_bytes: u128, subnet_size: NumberOfNodes) -> Cycles { +/// block on a subnet of size `subnet_size`, i.e. `N * (10 * N + 600) * response_bytes`. +pub fn consensus_fee(response_bytes: u128, subnet_size: NumberOfNodes) -> Cycles { let n = subnet_size.get() as u128; Cycles::from( (CONSENSUS_PER_NODE_BYTE_FEE * n + CONSENSUS_BYTE_FEE) From 9572d0222e55a2b56ee319def0b5dd62b9c4f0d5 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Tue, 4 Aug 2026 12:27:41 +0000 Subject: [PATCH 08/15] fix --- rs/execution_environment/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/rs/execution_environment/BUILD.bazel b/rs/execution_environment/BUILD.bazel index 4c6a50be63a2..9fef4afe67b7 100644 --- a/rs/execution_environment/BUILD.bazel +++ b/rs/execution_environment/BUILD.bazel @@ -369,6 +369,7 @@ rust_ic_bench( "//rs/execution_environment/benches/lib:execution_environment_bench", "//rs/limits", "//rs/types/cycles", + "//rs/types/management_canister_types", "//rs/types/types", "@crate_index//:candid", "@crate_index//:criterion", From b3e1e84b2ffeaee87e159147cb502e0c83cba67d Mon Sep 17 00:00:00 2001 From: Leo Eichhorn <99166915+eichhorl@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:09:57 +0200 Subject: [PATCH 09/15] Update rs/https_outcalls/pricing/src/fees.rs Co-authored-by: Pierugo Pace --- rs/https_outcalls/pricing/src/fees.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 441664bf6273..33d66f82aa22 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -683,7 +683,7 @@ mod tests { // beyond `min_responses`. // per replica = 50_000 + latency + transform + gossip // consensus = 9_490 * 3 * (181 + 2_000_000) = 56_945_153_070 - // extra response = 13 * (2_000 * 13 + 100_000) = 1_638_000 + // extra response = (3 - 2) * 13 * (2_000 * 13 + 100_000) = 1_638_000 let per_replica = 50_000 + MAX_LATENCY_FEE + MAX_TRANSFORM_FEE + MAX_GOSSIP_FEE; let consensus = 9_490 * 3 * (181 + 2_000_000) + 13 * (2_000 * 13 + 100_000); let replication = Replication::Flexible { From afa429d41fe81ae7c984bbf7659d3790fd9ca6d8 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn <99166915+eichhorl@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:10:07 +0200 Subject: [PATCH 10/15] Update rs/https_outcalls/pricing/src/fees.rs Co-authored-by: Pierugo Pace --- rs/https_outcalls/pricing/src/fees.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 33d66f82aa22..7a9e305cddc9 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -569,7 +569,7 @@ mod tests { // = 31_551_000 // per replica = 3 * (650_002 + 50*500*13) = 2_925_006 // consensus = 9_490 * 3 * (181 + 500) = 19_388_070 - // extra resp. = 13 * (2_000*13 + 100_000) = 1_638_000 + // extra resp. = (3 - 2) * 13 * (2_000*13 + 100_000) = 1_638_000 let fee = total_fee_of(ReplicationKind::Flexible { total_requests: 3, min_responses: 2, From ae03cec3aed6b71a0ec76042ad90438b37c74a50 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 6 Aug 2026 11:34:32 +0000 Subject: [PATCH 11/15] review --- rs/https_outcalls/pricing/src/fees.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 7a9e305cddc9..8381e90b27e7 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -76,11 +76,11 @@ pub fn base_fee( subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { CompoundCycles::new( - Cycles::new(base_fee_amount( + base_fee_amount( request_size, replication.kind(), subnet_size_of(subnet_cycles_config), - )), + ), subnet_cycles_config.cost_schedule, ) } @@ -90,7 +90,7 @@ fn base_fee_amount( request_size: NumBytes, replication_kind: ReplicationKind, subnet_size: NumberOfNodes, -) -> u128 { +) -> Cycles { let n = subnet_size.get() as u128; let request_bytes = request_size.get() as u128; let per_replica = match replication_kind { @@ -107,7 +107,7 @@ fn base_fee_amount( } }; - n * per_replica + Cycles::new(n * per_replica) } /// The per-replica part of the base fee of a gossiping (flexible or @@ -177,11 +177,15 @@ fn flexible_extra_response_fee(extra_responses: u128, subnet_size: NumberOfNodes // ================================= Total fee ================================= -/// The total fee for an HTTP outcall of the given `replication_kind` that consumed -/// the given resources, i.e. the sum of all three parts of its price: the up-front -/// [`base_fee`], the per-replica fees of every replica attempting it, and the -/// consensus fee of putting the responses that are delivered into a block. -/// Used by canisters to estimate the cost of an outcall. +/// The total fee for an HTTP outcall of the given `replication_kind` where every +/// participating replica consumed the given resources, i.e. the sum of all three +/// parts of its price: the up-front [`base_fee`], the per-replica fees of every +/// replica attempting it, and the consensus fee of putting the responses that are +/// delivered into a block. Used by canisters to estimate the cost of an outcall. +/// +/// Since how many replicas will respond is not known ahead of time, this is the most +/// such an outcall can cost: every replica of the (flexible) committee is assumed to +/// attempt it and `max_responses` of their responses to be delivered. pub fn total_fee( request_size: NumBytes, http_roundtrip_time: Duration, @@ -192,7 +196,7 @@ pub fn total_fee( subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { let subnet_size = subnet_size_of(subnet_cycles_config); - let amount = Cycles::new(base_fee_amount(request_size, replication_kind, subnet_size)) + let amount = base_fee_amount(request_size, replication_kind, subnet_size) + usage_fee( replication_kind, http_roundtrip_time, @@ -231,7 +235,8 @@ fn usage_fee( consensus_fee(transformed_bytes, subnet_size), ), // Every replica gossips its own response, and up to `max_responses` of them - // are delivered — those beyond `min_responses` at an extra fee. + // are delivered — those beyond `min_responses` at an extra fee. How many + // actually will be is not known here, so all `max_responses` are priced. ReplicationKind::Flexible { min_responses, max_responses, From e2eb6eb04347a738bb38fddbab35cbce1061cc44 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 13 Aug 2026 12:42:55 +0000 Subject: [PATCH 12/15] review --- .../src/cycles_account_manager.rs | 7 +- .../src/wasmtime_embedder/system_api.rs | 7 +- .../src/execution_environment/tests.rs | 83 +++++++--------- rs/execution_environment/tests/hypervisor.rs | 48 ++++++---- rs/https_outcalls/pricing/src/fees.rs | 96 ++++++++++--------- rs/https_outcalls/pricing/src/payg.rs | 29 +++++- 6 files changed, 148 insertions(+), 122 deletions(-) diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index 43f3c2e50748..e730f565e808 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -1296,9 +1296,10 @@ impl CyclesAccountManager { ic_https_outcalls_pricing::fees::base_fee(request_size, replication, subnet_cycles_config) } - /// Returns the most an HTTP outcall with the given `replication` and - /// `max_response_bytes` can ever spend, i.e. its worst-case cost beyond the base - /// fee, including the consensus cost of delivering the response. + /// Returns the maximum amount of cycles an HTTP outcall with the given + /// `replication` and `max_response_bytes` can ever spend, i.e. its worst-case + /// cost beyond the base fee, including the consensus cost of delivering the + /// response. pub fn max_http_request_usage_fee( &self, replication: &Replication, diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index beed07954313..6bcb4ee10e13 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -2038,10 +2038,15 @@ impl CostHttpRequestV2Params { } } -const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; +/// The Candid encoding of the largest [`CostHttpRequestV2Params`] there is: every +/// field at its maximum value, with the `outcall_type` variant that encodes largest. +pub const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; /// How much work decoding [`CostHttpRequestV2Params`] may spend skipping values it /// has no type for — i.e. the `reserved` payload of an `outcall_type` variant. +/// +/// A well form request contains `null` for the reserved payload, which costs +/// exactly 1 in Candid's cost model (see `DecoderConfig::set_decoding_quota`). const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 1; impl SystemApi for SystemApiImpl { diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index dbe7c1ae4578..54346e1cec90 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -3668,57 +3668,21 @@ fn execute_flexible_canister_http_request() { #[test] fn execute_canister_http_request_refunds_truncated_allowance_remainder() { - // The payment beyond the base fee does not generally divide evenly into - // per-replica allowances, and only whole allowances are ever refunded through - // the refund status. The truncated remainder is therefore not taken out of - // the payment, so that it is refunded along with the response instead of - // disappearing from the cycles accounting. + // Regression test: the payment beyond the base fee does not generally divide + // evenly into per-replica allowances, and the truncated remainder must not be + // lost. It is not taken out of the payment, so that it is refunded along with + // the response. let own_subnet = subnet_test_id(1); let caller_canister = canister_test_id(10); - let build_test = || { - ExecutionTestBuilder::new() - .with_own_subnet_id(own_subnet) - .with_caller(own_subnet, caller_canister) - .with_flexible_http_requests_enabled() - .build() - }; - - // Probe with an ample payment to learn the base fee and the committee size for - // these args. - let (base_fee, committee_size) = { - let mut probe = build_test(); - probe.inject_call_to_ic00( - Method::FlexibleHttpRequest, - flexible_http_request_args(caller_canister).encode(), - Cycles::new(100_000_000_000), - ); - probe.execute_all(); - let context = probe - .state() - .metadata - .subnet_call_context_manager - .canister_http_request_contexts - .get(&CallbackId::from(0)) - .unwrap() - .clone(); - let base_fee = - probe.http_request_base_fee(context.variable_parts_size(), &context.replication); - let committee_size = match &context.replication { - Replication::Flexible { committee, .. } => committee.len(), - other => panic!("expected flexible replication, got {other:?}"), - }; - (base_fee, committee_size) - }; - // The split only truncates if more than one replica shares the payment. - assert!(committee_size > 1); - - // Pay for one full allowance per replica, plus a remainder that is one cycle - // short of giving every replica another cycle. - let allowance = Cycles::new(1_000); - let remainder = Cycles::new(committee_size as u128 - 1); - let payment = base_fee.real() + allowance * committee_size + remainder; + let mut test = ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_caller(own_subnet, caller_canister) + .with_flexible_http_requests_enabled() + .build(); - let mut test = build_test(); + // A payment far below what the committee can possibly spend on this outcall, so + // that the payment — rather than the worst-case cost — is split into allowances. + let payment = Cycles::new(1_000_000_000); test.inject_call_to_ic00( Method::FlexibleHttpRequest, flexible_http_request_args(caller_canister).encode(), @@ -3733,6 +3697,21 @@ fn execute_canister_http_request_refunds_truncated_allowance_remainder() { .canister_http_request_contexts .get(&CallbackId::from(0)) .unwrap(); + let committee_size = match &http_request_context.replication { + Replication::Flexible { committee, .. } => committee.len(), + other => panic!("expected flexible replication, got {other:?}"), + }; + let base_fee = test.http_request_base_fee( + http_request_context.variable_parts_size(), + &http_request_context.replication, + ); + let allowance = (payment - base_fee.real()) / committee_size; + let remainder = payment - base_fee.real() - allowance * committee_size; + // Sanity checks that these args actually exercise a truncated split: the payment + // is shared by more than one replica and does not divide evenly among them. + assert!(committee_size > 1); + assert_ne!(remainder, Cycles::new(0)); + // `refundable_cycles` covers exactly the per-replica allowances, ... assert_eq!( http_request_context.refund_status.per_replica_allowance, @@ -3745,6 +3724,14 @@ fn execute_canister_http_request_refunds_truncated_allowance_remainder() { // ... and the remainder that no allowance covers was never taken out of the // payment, so it is refunded when the response is delivered. assert_eq!(http_request_context.request.payment, remainder); + // Nothing disappears from the cycles accounting: the base fee, the allowances + // and what is left of the payment add up to what the caller paid. + assert_eq!( + base_fee.real() + + http_request_context.refund_status.refundable_cycles + + http_request_context.request.payment, + payment + ); // Only the base fee is charged (and reported as consumed) upfront. assert_eq!( test.state() diff --git a/rs/execution_environment/tests/hypervisor.rs b/rs/execution_environment/tests/hypervisor.rs index 73ed65aadf86..7448b7689e2b 100644 --- a/rs/execution_environment/tests/hypervisor.rs +++ b/rs/execution_environment/tests/hypervisor.rs @@ -8,7 +8,9 @@ use ic_config::{ use ic_cycles_account_manager::ResourceSaturation; use ic_embedders::{ wasm_utils::instrumentation::{WasmMemoryType, instruction_to_cost}, - wasmtime_embedder::system_api::MAX_CALL_TIMEOUT_SECONDS, + wasmtime_embedder::system_api::{ + MAX_CALL_TIMEOUT_SECONDS, MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE, + }, }; use ic_error_types::{ErrorCode, RejectCode, UserError}; use ic_interfaces::execution_environment::{HypervisorError, MessageMemoryUsage}; @@ -9493,24 +9495,29 @@ fn invoke_cost_http_request_v2_flexible_without_counts_uses_the_defaults() { #[test] fn cost_http_request_v2_accepts_maximal_params() { // The largest params a caller can send: every value at its maximum, with the - // `outcall_type` variant that encodes largest. This is exactly - // `MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE` bytes, so it has to pass the size check + // `outcall_type` variant that encodes largest. + let params = CostHttpRequestV2Params { + request_bytes: u64::MAX, + http_roundtrip_time_ms: u64::MAX, + raw_response_bytes: u64::MAX, + transformed_response_bytes: u64::MAX, + transform_instructions: u64::MAX, + outcall_type: Some(CostHttpRequestOutcallType::Flexible(Some( + ReplicationCounts { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + ))), + }; + // They encode to exactly the maximum size, so they have to pass the size check // and decode within the skipping quota rather than being rejected as too large. + assert_eq!( + Encode!(¶ms).unwrap().len(), + MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE + ); assert_cost_http_request_v2_params( - CostHttpRequestV2Params { - request_bytes: u64::MAX, - http_roundtrip_time_ms: u64::MAX, - raw_response_bytes: u64::MAX, - transformed_response_bytes: u64::MAX, - transform_instructions: u64::MAX, - outcall_type: Some(CostHttpRequestOutcallType::Flexible(Some( - ReplicationCounts { - total_requests: u32::MAX, - min_responses: u32::MAX, - max_responses: u32::MAX, - }, - ))), - }, + params, ReplicationKind::Flexible { total_requests: u32::MAX, min_responses: u32::MAX, @@ -9538,10 +9545,8 @@ fn cost_http_request_v2_fails_with_too_big_candid() { let raw_response_bytes = 1_000_000; let transformed_response_bytes = 800_000; let transform_instructions = 500_000_000; - let garbage = "Some garbage to DoS the System API by making Candid decoding more expensive" - .repeat(10) - .as_bytes() - .into(); + // Some garbage to DoS the System API by making Candid decoding more expensive. + let garbage = vec![b'x'; 2 * MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE]; let params = CostHttpRequestV2ParamsExtended { request_bytes, http_roundtrip_time_ms, @@ -9552,6 +9557,7 @@ fn cost_http_request_v2_fails_with_too_big_candid() { garbage, }; let params_blob = Encode!(¶ms).unwrap(); + assert!(params_blob.len() > 2 * MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE); let payload = wasm() .cost_http_request_v2(¶ms_blob) diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 0c76b8647512..293d5d5d6bb2 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -94,32 +94,32 @@ fn base_fee_amount( ) -> Cycles { let n = subnet_size.get() as u128; let request_bytes = request_size.get() as u128; - let per_replica = match replication_kind { + let amount = match replication_kind { ReplicationKind::FullyReplicated => { - HTTP_REQUEST_BASE_FEE + n * (HTTP_REQUEST_BASE_FEE + HTTP_REQUEST_PER_BYTE_FEE * request_bytes + HTTP_REQUEST_FULLY_REPLICATED_PER_NODE_FEE * n - + HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * n + + HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * n) } // Non-replicated is equivalent to flexible replication with min_responses = 1. - ReplicationKind::NonReplicated => gossipping_base_fee_per_replica(request_bytes, n, 1), + ReplicationKind::NonReplicated => gossipping_base_fee(request_bytes, n, 1), ReplicationKind::Flexible { min_responses, .. } => { - gossipping_base_fee_per_replica(request_bytes, n, min_responses as u128) + gossipping_base_fee(request_bytes, n, min_responses as u128) } }; - Cycles::new(n * per_replica) + Cycles::new(amount) } -/// The per-replica part of the base fee of a gossiping (flexible or -/// non-replicated) outcall of `request_bytes` bytes that requires -/// `min_responses` responses, on a subnet of `n` nodes. -fn gossipping_base_fee_per_replica(request_bytes: u128, n: u128, min_responses: u128) -> u128 { - HTTP_REQUEST_BASE_FEE +/// The base fee of a gossiping (flexible or non-replicated) outcall of +/// `request_bytes` bytes that requires `min_responses` responses, on a subnet of +/// `n` nodes. +fn gossipping_base_fee(request_bytes: u128, n: u128, min_responses: u128) -> u128 { + n * (HTTP_REQUEST_BASE_FEE + HTTP_REQUEST_PER_BYTE_FEE * request_bytes + HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE * n + HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * min_responses - + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min_responses + + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min_responses) } // ============================= Per-replica fees ============================= @@ -222,49 +222,57 @@ fn usage_fee( transform_instructions: NumInstructions, transformed_response_size: NumBytes, subnet_size: NumberOfNodes, +) -> Cycles { + let gossip_fee = match replication_kind { + ReplicationKind::FullyReplicated => 0, + ReplicationKind::NonReplicated | ReplicationKind::Flexible { .. } => { + gossip_usage_fee(transformed_response_size, subnet_size) + } + }; + let consensus_fee = max_consensus_fee(replication_kind, transformed_response_size, subnet_size); + + let per_replica_fee = network_usage_fee(raw_response_size, http_roundtrip_time) + .saturating_add(transform_usage_fee(transform_instructions)) + .saturating_add(gossip_fee); + let replicas = replication_kind.node_count(subnet_size) as u128; + + Cycles::new(replicas.saturating_mul(per_replica_fee)) + consensus_fee +} + +/// The most the consensus fee of delivering the response(s) of an outcall of the +/// given `replication_kind` can be, each response `transformed_response_size` bytes +/// large. +/// +/// How many of the responses consensus delivers is not known ahead of time, so a +/// flexible outcall is priced for all `max_responses` of them, those beyond +/// `min_responses` at an extra fee. +pub(crate) fn max_consensus_fee( + replication_kind: ReplicationKind, + transformed_response_size: NumBytes, + subnet_size: NumberOfNodes, ) -> Cycles { let transformed_bytes = transformed_response_size.get() as u128; - // How the responses reach the rest of the subnet, and which of them consensus - // delivers, is all the replication kind decides here. - let (gossip_fee, consensus_fee) = match replication_kind { - // Every replica produces the same response, so it doesn't need to be gossiped - // and consensus is reached on that single response. - ReplicationKind::FullyReplicated => (0, consensus_fee(transformed_bytes, subnet_size)), - // The single replica gossips its own response, which is then delivered. - ReplicationKind::NonReplicated => ( - gossip_usage_fee(transformed_response_size, subnet_size), - consensus_fee(transformed_bytes, subnet_size), - ), - // Every replica gossips its own response, and up to `max_responses` of them - // are delivered — those beyond `min_responses` at an extra fee. How many - // actually will be is not known here, so all `max_responses` are priced. + match replication_kind { + ReplicationKind::FullyReplicated | ReplicationKind::NonReplicated => { + consensus_fee(transformed_bytes, subnet_size) + } ReplicationKind::Flexible { min_responses, max_responses, .. } => { let responses = max_responses as u128; - ( - gossip_usage_fee(transformed_response_size, subnet_size), - consensus_fee( - responses.saturating_mul( - FLEXIBLE_RESPONSE_SIZE_OVERHEAD.saturating_add(transformed_bytes), - ), - subnet_size, - ) + flexible_extra_response_fee( - responses.saturating_sub(min_responses as u128), - subnet_size, + consensus_fee( + responses.saturating_mul( + FLEXIBLE_RESPONSE_SIZE_OVERHEAD.saturating_add(transformed_bytes), ), + subnet_size, + ) + flexible_extra_response_fee( + responses.saturating_sub(min_responses as u128), + subnet_size, ) } - }; - - let per_replica_fee = network_usage_fee(raw_response_size, http_roundtrip_time) - .saturating_add(transform_usage_fee(transform_instructions)) - .saturating_add(gossip_fee); - let replicas = replication_kind.node_count(subnet_size) as u128; - - Cycles::new(replicas.saturating_mul(per_replica_fee)) + consensus_fee + } } // ============================= Maximum usage fee ============================= diff --git a/rs/https_outcalls/pricing/src/payg.rs b/rs/https_outcalls/pricing/src/payg.rs index 5cfecbd92df2..a388d81bb34d 100644 --- a/rs/https_outcalls/pricing/src/payg.rs +++ b/rs/https_outcalls/pricing/src/payg.rs @@ -144,7 +144,7 @@ mod tests { use super::*; use crate::fees::{ FLEXIBLE_PER_TRANSFORMED_BYTE_NODE_FEE, PER_DOWNLOADED_BYTE_FEE, PER_RESPONSE_MS_FEE, - TRANSFORM_INSTRUCTION_DIVISOR, max_usage_fee, + TRANSFORM_INSTRUCTION_DIVISOR, max_consensus_fee, max_usage_fee, }; use ic_types::{ CanisterId, NodeId, PrincipalId, RegistryVersion, @@ -385,8 +385,10 @@ mod tests { // per-replica allowances, so it has to cover everything this tracker can // charge a replica: the largest response the adapter may return, taking the // longest it may take, transformed with the whole instruction limit into a - // maximally large response. If it didn't, a request paying for the worst case - // could still run out of cycles. + // maximally large response. On top of that, what the allowances of all + // replicas together leave unspent has to cover the consensus fee of + // delivering the response(s). If it didn't, a request paying for the worst + // case could still run out of cycles. let node = NodeId::from(PrincipalId::new_node_test_id(0)); for replication in [ Replication::FullyReplicated, @@ -425,9 +427,26 @@ mod tests { Ok(()), "{replication:?}, {max_response_bytes:?}" ); - // There should still be allowance left for the consensus cost. + // What every replica leaves unspent adds up to at least the consensus + // fee of delivering a maximally large response from each of them. + let consensus_fee = max_consensus_fee( + replication.kind(), + NumBytes::from(MAX_CANISTER_HTTP_RESPONSE_BYTES), + subnet_size, + ); + let worst_case = tracker + .spent + .saturating_mul(node_count as u128) + .saturating_add(consensus_fee.get()); + let allowances = allowance.get().saturating_mul(node_count as u128); + assert!( + allowances >= worst_case, + "{replication:?}, {max_response_bytes:?}" + ); + // And they cover no more than that: the only slack is what rounding + // the worst case up to a whole number of allowances adds. assert!( - tracker.spent < allowance.get(), + allowances - worst_case < node_count as u128, "{replication:?}, {max_response_bytes:?}" ); } From d8bb97dac97ef37d36c822120e8a4bbde4fcf2bd Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 13 Aug 2026 12:57:12 +0000 Subject: [PATCH 13/15] overflow test for all replications --- rs/https_outcalls/pricing/src/fees.rs | 49 ++++++++++++++++----------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/rs/https_outcalls/pricing/src/fees.rs b/rs/https_outcalls/pricing/src/fees.rs index 293d5d5d6bb2..5c4b9b10f9ce 100644 --- a/rs/https_outcalls/pricing/src/fees.rs +++ b/rs/https_outcalls/pricing/src/fees.rs @@ -942,20 +942,20 @@ mod tests { ); } - /// [`total_fee`] of the largest outcall a canister can ask the price of: every - /// byte and instruction count at `u64::MAX`, every response count at `u32::MAX`. - fn total_fee_of_maximal_params(subnet_size: usize) -> Cycles { + /// [`total_fee`] of the largest outcall a canister can ask the price of, with the + /// given `replication_kind`: every byte and instruction count at `u64::MAX`, and + /// (where the kind has them) every response count at `u32::MAX`. + fn total_fee_of_maximal_params( + replication_kind: ReplicationKind, + subnet_size: usize, + ) -> Cycles { total_fee( NumBytes::from(u64::MAX), Duration::from_millis(u64::MAX), NumBytes::from(u64::MAX), NumInstructions::from(u64::MAX), NumBytes::from(u64::MAX), - ReplicationKind::Flexible { - total_requests: u32::MAX, - min_responses: u32::MAX, - max_responses: u32::MAX, - }, + replication_kind, CyclesAccountManagerSubnetConfig::new( subnet_size, CanisterCyclesCostSchedule::Normal, @@ -967,19 +967,30 @@ mod tests { #[test] fn total_fee_of_maximal_params_saturates_instead_of_overflowing() { - // The cost doesn't overflow on realistic subnet sizes - for subnet_size in [0, 1, 13, 40, 1_000] { - let fee = total_fee_of_maximal_params(subnet_size); - assert!( - fee < Cycles::new(u128::MAX), - "subnet size {subnet_size}: price saturated at {fee}" + for replication_kind in [ + ReplicationKind::FullyReplicated, + ReplicationKind::NonReplicated, + ReplicationKind::Flexible { + total_requests: u32::MAX, + min_responses: u32::MAX, + max_responses: u32::MAX, + }, + ] { + // The cost doesn't overflow on realistic subnet sizes + for subnet_size in [0, 1, 13, 40, 1_000] { + let fee = total_fee_of_maximal_params(replication_kind, subnet_size); + assert!( + fee < Cycles::new(u128::MAX), + "{replication_kind:?} at subnet size {subnet_size}: price saturated at {fee}" + ); + } + // The cost saturates at the maximum value + assert_eq!( + total_fee_of_maximal_params(replication_kind, u32::MAX as usize), + Cycles::new(u128::MAX), + "{replication_kind:?}" ); } - // The cost saturates at the maximum value - assert_eq!( - total_fee_of_maximal_params(u32::MAX as usize), - Cycles::new(u128::MAX) - ); } #[test] From 7a15258dcdc13164c2a978f3391a1f2673a1974b Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 13 Aug 2026 14:11:56 +0000 Subject: [PATCH 14/15] ic0_subnet_self_node_count --- rs/embedders/src/wasm_utils/validation.rs | 10 +++ rs/embedders/src/wasmtime_embedder/linker.rs | 9 +++ .../src/wasmtime_embedder/system_api.rs | 28 +++++++- .../system_api_complexity.rs | 1 + rs/embedders/tests/system_api.rs | 11 +++ rs/embedders/tests/wasmtime_embedder.rs | 55 ++++++++++++++ .../src/query_handler/query_cache/tests.rs | 1 + rs/execution_environment/tests/hypervisor.rs | 71 +++++++++++++++++++ rs/interfaces/src/execution_environment.rs | 6 ++ .../execution_environment/src/lib.rs | 9 +++ 10 files changed, 200 insertions(+), 1 deletion(-) diff --git a/rs/embedders/src/wasm_utils/validation.rs b/rs/embedders/src/wasm_utils/validation.rs index 299695b5c748..b6089a077424 100644 --- a/rs/embedders/src/wasm_utils/validation.rs +++ b/rs/embedders/src/wasm_utils/validation.rs @@ -805,6 +805,16 @@ fn get_valid_system_apis_common( }, )], ), + ( + "subnet_self_node_count", + vec![( + API_VERSION_IC0, + FunctionSignature { + param_types: vec![], + return_type: vec![DataType::I32], + }, + )], + ), ( "cost_call", vec![( diff --git a/rs/embedders/src/wasmtime_embedder/linker.rs b/rs/embedders/src/wasmtime_embedder/linker.rs index 5897f2dd7574..c8d7975e0821 100644 --- a/rs/embedders/src/wasmtime_embedder/linker.rs +++ b/rs/embedders/src/wasmtime_embedder/linker.rs @@ -1022,6 +1022,15 @@ pub fn syscalls< }) .unwrap(); + linker + .func_wrap("ic0", "subnet_self_node_count", { + move |mut caller: Caller<'_, StoreData>| { + charge_for_cpu(&mut caller, overhead::SUBNET_SELF_NODE_COUNT)?; + with_system_api(&mut caller, |s| s.ic0_subnet_self_node_count()) + } + }) + .unwrap(); + match main_memory_type { WasmMemoryType::Wasm32 => { linker diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index 6bcb4ee10e13..13f3f0616b4f 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -2047,7 +2047,7 @@ pub const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; /// /// A well form request contains `null` for the reserved payload, which costs /// exactly 1 in Candid's cost model (see `DecoderConfig::set_decoding_quota`). -const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 1; +const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 0; impl SystemApi for SystemApiImpl { fn set_execution_error(&mut self, error: HypervisorError) { @@ -4672,6 +4672,32 @@ impl SystemApi for SystemApiImpl { result } + + fn ic0_subnet_self_node_count(&self) -> HypervisorResult { + let result = match &self.api_type { + ApiType::Start { .. } => Err(self.error_for("ic0.subnet_self_node_count")), + ApiType::Init { .. } + | ApiType::SystemTask { .. } + | ApiType::Cleanup { .. } + | ApiType::CompositeCleanup { .. } + | ApiType::Update { .. } + | ApiType::ReplicatedQuery { .. } + | ApiType::NonReplicatedQuery { .. } + | ApiType::CompositeQuery { .. } + | ApiType::ReplyCallback { .. } + | ApiType::CompositeReplyCallback { .. } + | ApiType::RejectCallback { .. } + | ApiType::CompositeRejectCallback { .. } + | ApiType::PreUpgrade { .. } + | ApiType::InspectMessage { .. } => Ok(self + .sandbox_safe_system_state + .subnet_cycles_config + .subnet_size as u32), + }; + + trace_syscall!(self, SubnetSelfNodeCount, result); + result + } } /// The default implementation of the `OutOfInstructionHandler` trait. diff --git a/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs b/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs index 00ac349a6293..fd97965ceeea 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api_complexity.rs @@ -78,6 +78,7 @@ pub mod overhead { pub const PERFORMANCE_COUNTER: NumInstructions = NumInstructions::new(200); pub const SUBNET_SELF_SIZE: NumInstructions = NumInstructions::new(500); pub const SUBNET_SELF_COPY: NumInstructions = NumInstructions::new(500); + pub const SUBNET_SELF_NODE_COUNT: NumInstructions = NumInstructions::new(500); pub const STABLE_GROW: NumInstructions = NumInstructions::new(500); pub const STABLE_READ: NumInstructions = NumInstructions::new(20); pub const STABLE_SIZE: NumInstructions = NumInstructions::new(20); diff --git a/rs/embedders/tests/system_api.rs b/rs/embedders/tests/system_api.rs index 1e9484d88ed0..da0afe019c47 100644 --- a/rs/embedders/tests/system_api.rs +++ b/rs/embedders/tests/system_api.rs @@ -260,6 +260,7 @@ fn is_supported(api_type: SystemApiCallId, context: &str) -> bool { SystemApiCallId::MintCycles128 => vec!["U", "Ry", "Rt", "T"], SystemApiCallId::SubnetSelfSize => vec!["*"], SystemApiCallId::SubnetSelfCopy => vec!["*"], + SystemApiCallId::SubnetSelfNodeCount => vec!["*"], SystemApiCallId::EnvVarCount => vec!["*"], SystemApiCallId::EnvVarNameSize => vec!["*"], SystemApiCallId::EnvVarNameCopy => vec!["*"], @@ -843,6 +844,16 @@ fn api_availability_test( context, ); } + SystemApiCallId::SubnetSelfNodeCount => { + assert_api_availability( + |api| api.ic0_subnet_self_node_count(), + api_type, + &system_state, + cycles_account_manager, + api_type_enum, + context, + ); + } SystemApiCallId::EnvVarCount => { assert_api_availability( |api| api.ic0_env_var_count(), diff --git a/rs/embedders/tests/wasmtime_embedder.rs b/rs/embedders/tests/wasmtime_embedder.rs index 4bcc659068fd..87d3e5ea3e53 100644 --- a/rs/embedders/tests/wasmtime_embedder.rs +++ b/rs/embedders/tests/wasmtime_embedder.rs @@ -2174,6 +2174,8 @@ fn wasm64_import_system_api_functions() { (func $ic0_subnet_self_size (result i64))) (import "ic0" "subnet_self_copy" (func $ic0_subnet_self_copy (param i64) (param i64) (param i64))) + (import "ic0" "subnet_self_node_count" + (func $ic0_subnet_self_node_count (result i32))) (global $g1 (export "g1") (mut i64) (i64.const 0)) (func $test (export "canister_update test") @@ -2801,6 +2803,59 @@ fn wasm64_subnet_self_size() { ); } +#[test] +fn wasm64_subnet_self_node_count() { + // `ic0.subnet_self_node_count` returns an `i32` irrespective of the main + // memory type, so a Wasm64 canister imports it with an `i32` result too. + let wat = r#" + (module + (import "ic0" "subnet_self_node_count" + (func $ic0_subnet_self_node_count (result i32))) + + (global $g1 (export "g1") (mut i32) (i32.const 0)) + (func $test (export "canister_update test") + (call $ic0_subnet_self_node_count) + global.set $g1 + ) + + (memory (export "memory") i64 1) + )"#; + + let caller = user_test_id(24).get(); + let payload: Vec = vec![1, 3, 5, 7]; + let api = ApiType::update( + UNIX_EPOCH, + payload.clone(), + Cycles::zero(), + caller, + call_context_test_id(13), + None, + ); + + let config = ic_config::embedders::Config::default(); + let mut instance = WasmtimeInstanceBuilder::new() + .with_config(config) + .with_wat(wat) + .with_api_type(api) + .build(); + + let res = instance + .run(FuncRef::Method(WasmMethod::Update("test".to_string()))) + .unwrap(); + + assert_eq!( + res.exported_globals[0], + Global::I32( + instance + .store_data() + .system_api() + .unwrap() + .ic0_subnet_self_node_count() + .unwrap() as i32 + ) + ); +} + #[test] fn wasm64_subnet_self_copy() { let wat = r#" diff --git a/rs/execution_environment/src/query_handler/query_cache/tests.rs b/rs/execution_environment/src/query_handler/query_cache/tests.rs index 191b86ed0a16..622320999292 100644 --- a/rs/execution_environment/src/query_handler/query_cache/tests.rs +++ b/rs/execution_environment/src/query_handler/query_cache/tests.rs @@ -1794,6 +1794,7 @@ fn query_cache_future_proof_test() { | SystemApiCallId::PerformanceCounter | SystemApiCallId::SubnetSelfSize | SystemApiCallId::SubnetSelfCopy + | SystemApiCallId::SubnetSelfNodeCount | SystemApiCallId::Stable64Grow | SystemApiCallId::Stable64Read | SystemApiCallId::Stable64Size diff --git a/rs/execution_environment/tests/hypervisor.rs b/rs/execution_environment/tests/hypervisor.rs index 7448b7689e2b..3c329e8afc82 100644 --- a/rs/execution_environment/tests/hypervisor.rs +++ b/rs/execution_environment/tests/hypervisor.rs @@ -3295,6 +3295,77 @@ fn ic0_subnet_self_copy_works() { ); } +const SUBNET_SELF_NODE_COUNT_WAT: &str = r#" + (module + (import "ic0" "subnet_self_node_count" + (func $subnet_self_node_count (result i32)) + ) + (import "ic0" "msg_reply" (func $msg_reply)) + (import "ic0" "msg_reply_data_append" + (func $msg_reply_data_append (param i32 i32))) + (func (export "canister_update test") + ;; heap[0..4] = $subnet_self_node_count() + (i32.store (i32.const 0) (call $subnet_self_node_count)) + ;; return heap[0..4] + (call $msg_reply_data_append (i32.const 0) (i32.const 4)) + (call $msg_reply) + ) + (memory 1 1) + )"#; + +#[test] +fn ic0_subnet_self_node_count_works() { + let mut test = ExecutionTestBuilder::new().build(); + let canister_id = test.canister_from_wat(SUBNET_SELF_NODE_COUNT_WAT).unwrap(); + let result = test.ingress(canister_id, "test", vec![]).unwrap(); + assert_eq!( + WasmResult::Reply((test.subnet_size() as u32).to_le_bytes().to_vec()), + result + ); +} + +#[test] +fn ic0_subnet_self_node_count_reflects_subnet_size() { + // A size that differs from the default, so that the returned value cannot + // accidentally match a hard-coded default. + const SUBNET_SIZE: usize = 34; + let mut test = ExecutionTestBuilder::new() + .with_subnet_size(SUBNET_SIZE) + .build(); + assert_eq!(test.subnet_size(), SUBNET_SIZE); + let canister_id = test.canister_from_wat(SUBNET_SELF_NODE_COUNT_WAT).unwrap(); + let result = test.ingress(canister_id, "test", vec![]).unwrap(); + assert_eq!( + WasmResult::Reply((SUBNET_SIZE as u32).to_le_bytes().to_vec()), + result + ); +} + +#[test] +fn ic0_subnet_self_node_count_traps_in_start() { + let mut test = ExecutionTestBuilder::new().build(); + let wat = r#" + (module + (import "ic0" "subnet_self_node_count" + (func $subnet_self_node_count (result i32)) + ) + (func $start + (drop (call $subnet_self_node_count)) + ) + (start $start) + (func (export "canister_update test")) + (memory 1 1) + )"#; + let err = test.canister_from_wat(wat).unwrap_err(); + assert_eq!(err.code(), ErrorCode::CanisterContractViolation); + assert!( + err.description() + .contains(r#""ic0.subnet_self_node_count" cannot be executed in start mode"#), + "unexpected error: {}", + err.description() + ); +} + #[test] fn ic0_call_new_must_be_called_first() { let mut test = ExecutionTestBuilder::new().build(); diff --git a/rs/interfaces/src/execution_environment.rs b/rs/interfaces/src/execution_environment.rs index 92af0f7ca271..7ce479aba039 100644 --- a/rs/interfaces/src/execution_environment.rs +++ b/rs/interfaces/src/execution_environment.rs @@ -270,6 +270,8 @@ pub enum SystemApiCallId { SubnetSelfSize, /// Tracker for `ic0.subnet_self_copy()` SubnetSelfCopy, + /// Tracker for `ic0.subnet_self_node_count()` + SubnetSelfNodeCount, /// Tracker for `ic0.stable64_grow()` Stable64Grow, /// Tracker for `ic0.stable64_read()` @@ -1432,6 +1434,10 @@ pub trait SystemApi { size: usize, heap: &mut [u8], ) -> HypervisorResult<()>; + + /// Used to look up the number of nodes currently on the subnet that the + /// calling canister is running on. + fn ic0_subnet_self_node_count(&self) -> HypervisorResult; } #[derive(Copy, Clone, Eq, PartialEq, Debug)] diff --git a/rs/test_utilities/execution_environment/src/lib.rs b/rs/test_utilities/execution_environment/src/lib.rs index c05c3e5ebfd7..fd6115b98948 100644 --- a/rs/test_utilities/execution_environment/src/lib.rs +++ b/rs/test_utilities/execution_environment/src/lib.rs @@ -2863,6 +2863,15 @@ impl ExecutionTestBuilder { self } + /// Sets the number of nodes on the own subnet. This is reflected both in the + /// registry settings and in the nodes of the own subnet's network topology. + pub fn with_subnet_size(mut self, subnet_size: usize) -> Self { + self.registry_settings.subnet_size = subnet_size; + self.registry_settings.node_ids = + (0..subnet_size).map(|i| node_test_id(i as u64)).collect(); + self + } + pub fn with_resource_limits(mut self, resource_limits: ResourceLimits) -> Self { self.resource_limits = resource_limits; self From 961aaf82ab33e336e8eaf074a792eb5e953b8aba Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 13 Aug 2026 15:19:40 +0000 Subject: [PATCH 15/15] fix --- rs/embedders/src/wasmtime_embedder/system_api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/embedders/src/wasmtime_embedder/system_api.rs b/rs/embedders/src/wasmtime_embedder/system_api.rs index 13f3f0616b4f..66a27e583578 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api.rs @@ -2047,7 +2047,7 @@ pub const MAX_COST_HTTP_REQUEST_V2_PARAMS_SIZE: usize = 144; /// /// A well form request contains `null` for the reserved payload, which costs /// exactly 1 in Candid's cost model (see `DecoderConfig::set_decoding_quota`). -const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 0; +const MAX_COST_HTTP_REQUEST_V2_SKIPPING_QUOTA: usize = 1; impl SystemApi for SystemApiImpl { fn set_execution_error(&mut self, error: HypervisorError) {