From e24e5fa0abbe868a5f31f6ddb66bf825d47964d2 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 08:25:52 +0000 Subject: [PATCH 1/9] impl --- rs/config/src/execution_environment.rs | 6 + .../src/cycles_account_manager.rs | 49 +++- .../src/execution_environment.rs | 216 +++++++++++++----- .../src/execution_environment/tests.rs | 188 ++++++++++++++- .../execution_environment/src/lib.rs | 19 ++ rs/types/types/src/canister_http.rs | 20 +- 6 files changed, 418 insertions(+), 80 deletions(-) diff --git a/rs/config/src/execution_environment.rs b/rs/config/src/execution_environment.rs index 75a72fc8aacc..7d5d81a2cfa5 100644 --- a/rs/config/src/execution_environment.rs +++ b/rs/config/src/execution_environment.rs @@ -13,6 +13,8 @@ const TIB: u64 = 1024 * GIB; const REPLICATED_INTER_CANISTER_LOG_FETCH_FEATURE: FlagStatus = FlagStatus::Disabled; +const FLEXIBLE_HTTP_REQUESTS_FEATURE: FlagStatus = FlagStatus::Disabled; + // TODO(DSM-105): remove after the feature is enabled by default. pub const LOG_MEMORY_STORE_FEATURE_ENABLED: bool = false; pub const LOG_MEMORY_STORE_FEATURE: FlagStatus = if LOG_MEMORY_STORE_FEATURE_ENABLED { @@ -381,6 +383,9 @@ pub struct Config { /// Enables the log memory store feature. pub log_memory_store_feature: FlagStatus, + + /// Enables the flexible HTTP outcalls API (`flexible_http_request`). + pub flexible_http_requests: FlagStatus, } impl Default for Config { @@ -467,6 +472,7 @@ impl Default for Config { max_environment_variable_value_length: MAX_ENVIRONMENT_VARIABLE_VALUE_LENGTH, replicated_inter_canister_log_fetch: REPLICATED_INTER_CANISTER_LOG_FETCH_FEATURE, log_memory_store_feature: LOG_MEMORY_STORE_FEATURE, + flexible_http_requests: FLEXIBLE_HTTP_REQUESTS_FEATURE, } } } diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index f5c3344c6046..906bccf7bfd5 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -12,7 +12,7 @@ use ic_replicated_state::{ use ic_types::{ CanisterId, ComputeAllocation, MemoryAllocation, NumBytes, NumInstructions, PrincipalId, SubnetId, - canister_http::MAX_CANISTER_HTTP_RESPONSE_BYTES, + canister_http::{MAX_CANISTER_HTTP_RESPONSE_BYTES, Replication}, canister_log::MAX_FETCH_CANISTER_LOGS_RESPONSE_BYTES, messages::{MAX_INTER_CANISTER_PAYLOAD_IN_BYTES, Payload, SignedIngress}, }; @@ -1319,6 +1319,53 @@ impl CyclesAccountManager { CompoundCycles::new(amount, cost_schedule) } + pub fn http_request_base_fee( + &self, + request_size: NumBytes, + subnet_size: usize, + replication: &Replication, + cost_schedule: CanisterCyclesCostSchedule, + ) -> CompoundCycles { + const BASE_FEE: u128 = 1_000_000; + const PER_REQUEST_BYTE_FEE: u128 = 50; + const FULLY_REPLICATED_PER_NODE_FEE: u128 = 140_000; + const FULLY_REPLICATED_QUADRATIC_NODE_FEE: u128 = 800; + const FLEXIBLE_PER_NODE_FEE: u128 = 90_000; + const FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE: u128 = 2_000; + const FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE: u128 = 100_000; + + let n = subnet_size as u128; + let request_bytes = request_size.get() as u128; + let per_replica = match replication { + Replication::FullyReplicated => { + BASE_FEE + + PER_REQUEST_BYTE_FEE * request_bytes + + FULLY_REPLICATED_PER_NODE_FEE * n + + FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * n + } + Replication::Flexible { + min_responses: min, .. + } => { + let min = *min as u128; + BASE_FEE + + PER_REQUEST_BYTE_FEE * request_bytes + + FLEXIBLE_PER_NODE_FEE * n + + FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * min + + FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min + } + Replication::NonReplicated(_) => { + // Non-replicated is the same as flexible replication with min requests of 1. + BASE_FEE + + PER_REQUEST_BYTE_FEE * request_bytes + + FLEXIBLE_PER_NODE_FEE * n + + FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n + + FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE + } + }; + + CompoundCycles::new(Cycles::new(n * per_replica), cost_schedule) + } + pub fn http_request_fee_v2( &self, request_size: NumBytes, diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index f25a304c1b1a..41da39718268 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -61,7 +61,10 @@ use ic_replicated_state::{ CanisterState, CanisterStatus, ExecutionTask, NetworkTopology, ReplicatedState, }; use ic_types::batch::ChainKeyData; -use ic_types::canister_http::{CanisterHttpRequestContext, MAX_CANISTER_HTTP_RESPONSE_BYTES}; +use ic_types::canister_http::{ + CanisterHttpRequestContext, MAX_CANISTER_HTTP_RESPONSE_BYTES, PricingVersion, RefundStatus, + Replication, +}; use ic_types::consensus::idkg::IDkgMasterPublicKeyId; use ic_types::crypto::{ ExtendedDerivationPath, @@ -1227,25 +1230,55 @@ impl ExecutionEnvironment { } }, - Ok(Ic00Method::FlexibleHttpRequest) => match &msg { - CanisterCall::Request(_) => { - match FlexibleCanisterHttpRequestArgs::decode(payload) { - Err(err) => ExecuteSubnetMessageResult::Finished { - response: Err(err), - refund: msg.take_cycles(), - }, - Ok(_) => ExecuteSubnetMessageResult::Finished { - response: Err(UserError::new( - ErrorCode::CanisterRejectedMessage, - "FlexibleHttpRequest is not yet implemented".to_string(), - )), - refund: msg.take_cycles(), - }, + Ok(Ic00Method::FlexibleHttpRequest) => match self.config.flexible_http_requests { + FlagStatus::Disabled => ExecuteSubnetMessageResult::Finished { + response: Err(UserError::new( + ErrorCode::CanisterContractViolation, + "This API is not enabled on this subnet".to_string(), + )), + refund: msg.take_cycles(), + }, + FlagStatus::Enabled => match &msg { + CanisterCall::Request(request) => { + match FlexibleCanisterHttpRequestArgs::decode(payload) { + Err(err) => ExecuteSubnetMessageResult::Finished { + response: Err(err), + refund: msg.take_cycles(), + }, + Ok(args) => { + match CanisterHttpRequestContext::generate_from_flexible_args( + state.time(), + request.as_ref(), + args, + ®istry_settings.node_ids, + rng, + ) { + Err(err) => ExecuteSubnetMessageResult::Finished { + response: Err(err.into()), + refund: msg.take_cycles(), + }, + Ok(canister_http_request_context) => match self + .try_add_http_context_to_replicated_state( + canister_http_request_context, + &mut state, + request.as_ref(), + registry_settings, + since, + ) { + Err(err) => ExecuteSubnetMessageResult::Finished { + response: Err(err), + refund: msg.take_cycles(), + }, + Ok(()) => ExecuteSubnetMessageResult::Processing, + }, + } + } + } } - } - CanisterCall::Ingress(_) => { - self.reject_unexpected_ingress(Ic00Method::FlexibleHttpRequest) - } + CanisterCall::Ingress(_) => { + self.reject_unexpected_ingress(Ic00Method::FlexibleHttpRequest) + } + }, }, Ok(Ic00Method::HttpRequest) => match state.metadata.own_subnet_features.http_requests { @@ -2115,14 +2148,25 @@ impl ExecutionEnvironment { registry_settings: &RegistryExecutionSettings, since: Instant, ) -> Result<(), UserError> { - let http_request_fee = self.cycles_account_manager.http_request_fee( - canister_http_request_context.variable_parts_size(), + let variable_parts_size = canister_http_request_context.variable_parts_size(); + let cost_schedule = state.get_own_cost_schedule(); + let legacy_fee = self.cycles_account_manager.http_request_fee( + variable_parts_size, canister_http_request_context.max_response_bytes, registry_settings.subnet_size, - state.get_own_cost_schedule(), + cost_schedule, + ); + + // The base fee is the non-refundable part of the payment under + // pay-as-you-go pricing; under legacy pricing the full legacy fee is + // charged instead. + let base_fee = self.cycles_account_manager.http_request_base_fee( + variable_parts_size, + registry_settings.subnet_size, + &canister_http_request_context.replication, + cost_schedule, ); - let real_http_request_fee = http_request_fee.real(); - let nominal_http_request_fee = http_request_fee.nominal(); + // Here we make sure that we do not let upper layers open new // http calls while the maximum number of calls is in-flight. // Later, in the http adapter we also have a bounded queue of @@ -2138,57 +2182,109 @@ impl ExecutionEnvironment { .len() >= self.config.max_canister_http_requests_in_flight { - Err(UserError::new( + return Err(UserError::new( ErrorCode::CanisterRejectedMessage, format!( "max number ({}) of http requests in-flight reached.", self.config.max_canister_http_requests_in_flight ), - )) - } else if request.payment < real_http_request_fee { - Err(UserError::new( + )); + } + + // The cycles charged upfront depend on the pricing version: legacy + // charges the full request fee, whereas pay-as-you-go charges the base + // fee and refunds the remainder based on the resources actually + // consumed. + let charged_fee = match canister_http_request_context.pricing_version { + PricingVersion::Legacy => legacy_fee, + PricingVersion::PayAsYouGo => base_fee, + }; + if request.payment < charged_fee.real() { + return Err(UserError::new( ErrorCode::CanisterRejectedMessage, format!( "{} request sent with {} cycles, but {} cycles are required.", Ic00Method::HttpRequest, request.payment, - real_http_request_fee + charged_fee.real() ), - )) + )); + } + + // The refundable cycles are everything the payment covers beyond the + // base fee; on a free cost schedule 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 will + // actually be refunded for legacy pricing. + let refundable_cycles = if cost_schedule == CanisterCyclesCostSchedule::Free { + Cycles::new(0) } else { - canister_http_request_context.request.payment -= real_http_request_fee; - state - .metadata - .subnet_metrics - .observe_consumed_cycles_http_outcalls(nominal_http_request_fee); - state - .metadata - .subnet_metrics - .observe_consumed_cycles_with_use_case( - CyclesUseCase::HTTPOutcalls, - nominal_http_request_fee, - ); - state.metadata.subnet_call_context_manager.push_context( - SubnetCallContext::CanisterHttpRequest(canister_http_request_context), - ); - if let Some(canister_state) = state.canister_state_make_mut(&request.sender) { - canister_state - .system_state - .observe_consumed_cycles_for_https_outcall(nominal_http_request_fee); - canister_state - .system_state - .canister_metrics_mut() - .load_metrics_mut() - .observe_http_outcall(); + canister_http_request_context.request.payment - base_fee.real() + }; + let node_count = match &canister_http_request_context.replication { + Replication::Flexible { committee, .. } => committee.len().max(1), + Replication::NonReplicated(_) => 1, + Replication::FullyReplicated => registry_settings.subnet_size.max(1), + }; + canister_http_request_context.refund_status = RefundStatus { + refundable_cycles, + per_replica_allowance: refundable_cycles / node_count, + refunded_cycles: Cycles::new(0), + refunding_nodes: BTreeSet::new(), + }; + + // The payment deduction differs per pricing version. + match canister_http_request_context.pricing_version { + PricingVersion::Legacy => { + // Legacy pricing deducts the full request fee from the payment. + // The remaining payment is refunded when the response is delivered. + canister_http_request_context.request.payment -= legacy_fee.real(); } - self.metrics.observe_message_with_label( - &request.method_name, - since.elapsed().as_secs_f64(), - SUBMITTED_OUTCOME_LABEL.into(), - SUCCESS_STATUS_LABEL.into(), + PricingVersion::PayAsYouGo => { + // Take out the entire payment upfront; the refundable portion is + // returned later via the refund mechanism. On a free cost + // schedule there is nothing to charge. + if cost_schedule != CanisterCyclesCostSchedule::Free { + canister_http_request_context.request.payment.take(); + } + } + } + + // Observe the nominal cycles charged for this outcall, based on what was + // actually charged (regardless of whether a real charge happens, e.g. on + // a free cost schedule). + let nominal_consumed_cycles = charged_fee.nominal(); + state + .metadata + .subnet_metrics + .observe_consumed_cycles_http_outcalls(nominal_consumed_cycles); + state + .metadata + .subnet_metrics + .observe_consumed_cycles_with_use_case( + CyclesUseCase::HTTPOutcalls, + nominal_consumed_cycles, ); - Ok(()) + state.metadata.subnet_call_context_manager.push_context( + SubnetCallContext::CanisterHttpRequest(canister_http_request_context), + ); + if let Some(canister_state) = state.canister_state_make_mut(&request.sender) { + canister_state + .system_state + .observe_consumed_cycles_for_https_outcall(nominal_consumed_cycles); + canister_state + .system_state + .canister_metrics_mut() + .load_metrics_mut() + .observe_http_outcall(); } + self.metrics.observe_message_with_label( + &request.method_name, + since.elapsed().as_secs_f64(), + SUBMITTED_OUTCOME_LABEL.into(), + SUCCESS_STATUS_LABEL.into(), + ); + Ok(()) } /// Observes a subnet message metrics and outputs the given subnet response. diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 8afe89e0e03a..c6d181f95bef 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -8,10 +8,10 @@ use ic_management_canister_types_private::{ self as ic00, BitcoinGetUtxosArgs, BoundedHttpHeaders, CanisterChange, CanisterHttpRequestArgs, CanisterIdRecord, CanisterMetadataRequest, CanisterMetadataResponse, CanisterStatusResultV2, CanisterStatusType, CreateCanisterArgs, DerivationPath, EcdsaCurve, EcdsaKeyId, EmptyBlob, - FetchCanisterLogsRequest, HttpMethod, IC_00, LogVisibilityV2, MasterPublicKeyId, Method, - Payload as Ic00Payload, ProvisionalCreateCanisterWithCyclesArgs, ProvisionalTopUpCanisterArgs, - SchnorrAlgorithm, SchnorrKeyId, TakeCanisterSnapshotArgs, TransformContext, TransformFunc, - UploadChunkArgs, VetKdCurve, VetKdKeyId, + FetchCanisterLogsRequest, FlexibleCanisterHttpRequestArgs, HttpMethod, IC_00, LogVisibilityV2, + MasterPublicKeyId, Method, Payload as Ic00Payload, ProvisionalCreateCanisterWithCyclesArgs, + ProvisionalTopUpCanisterArgs, SchnorrAlgorithm, SchnorrKeyId, TakeCanisterSnapshotArgs, + TransformContext, TransformFunc, UploadChunkArgs, VetKdCurve, VetKdKeyId, }; use ic_registry_routing_table::{CanisterIdRange, RoutingTable, canister_id_into_u64}; use ic_registry_subnet_type::SubnetType; @@ -31,7 +31,7 @@ use ic_test_utilities_execution_environment::{ use ic_test_utilities_metrics::{fetch_histogram_vec_count, metric_vec}; use ic_types::{ CanisterId, CountBytes, PrincipalId, RegistryVersion, - canister_http::{CanisterHttpMethod, Transform}, + canister_http::{CanisterHttpMethod, PricingVersion, Replication, Transform}, consensus::idkg::{IDkgMasterPublicKeyId, PreSigId}, ingress::{IngressState, IngressStatus, WasmResult}, messages::{ @@ -3265,6 +3265,41 @@ fn execute_canister_http_request() { ); assert_eq!(http_request_context.request.payment, payment - fee.real()); + // Legacy pricing populates the refund status from the base fee: the + // refundable cycles are everything beyond the base fee (zero on a free + // cost schedule), split across the refunding nodes (the whole subnet for + // a fully replicated request). + assert_eq!( + http_request_context.replication, + Replication::FullyReplicated + ); + let base_fee = test.http_request_base_fee( + http_request_context.variable_parts_size(), + &http_request_context.replication, + ); + let expected_refundable = match cost_schedule { + CanisterCyclesCostSchedule::Free => Cycles::new(0), + CanisterCyclesCostSchedule::Normal => payment - base_fee.real(), + }; + assert_eq!( + http_request_context.refund_status.refundable_cycles, + expected_refundable + ); + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_refundable / test.subnet_size().max(1) + ); + assert_eq!( + http_request_context.refund_status.refunded_cycles, + Cycles::new(0) + ); + assert!( + http_request_context + .refund_status + .refunding_nodes + .is_empty() + ); + assert_eq!( fee.nominal(), test.state() @@ -3338,6 +3373,149 @@ fn execute_canister_http_request_disabled() { assert_eq!(canister_http_request_contexts.len(), 0); } +fn flexible_http_request_args(caller_canister: CanisterId) -> FlexibleCanisterHttpRequestArgs { + FlexibleCanisterHttpRequestArgs { + url: "https://example.com".to_string(), + headers: BoundedHttpHeaders::new(vec![]), + body: None, + method: HttpMethod::GET, + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: caller_canister.get().0, + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + replication: None, + } +} + +#[test] +fn execute_flexible_canister_http_request() { + for cost_schedule in [ + CanisterCyclesCostSchedule::Normal, + CanisterCyclesCostSchedule::Free, + ] { + 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_cost_schedule(cost_schedule) + .with_flexible_http_requests_enabled() + .build(); + + let args = flexible_http_request_args(caller_canister); + let payment = Cycles::new(1_000_000_000); + test.inject_call_to_ic00(Method::FlexibleHttpRequest, args.encode(), payment); + test.execute_all(); + + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 1); + + let http_request_context = canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap(); + // The flexible endpoint always uses pay-as-you-go pricing and flexible + // replication. + assert_eq!( + http_request_context.pricing_version, + PricingVersion::PayAsYouGo + ); + let committee_size = match &http_request_context.replication { + Replication::Flexible { committee, .. } => committee.len(), + 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. + 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()), + }; + assert_eq!(http_request_context.request.payment, expected_payment); + assert_eq!( + http_request_context.refund_status.refundable_cycles, + expected_refundable + ); + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_refundable / committee_size.max(1) + ); + assert_eq!( + http_request_context.refund_status.refunded_cycles, + Cycles::new(0) + ); + assert!( + http_request_context + .refund_status + .refunding_nodes + .is_empty() + ); + } +} + +#[test] +fn execute_flexible_canister_http_request_insufficient_payment() { + // Pay-as-you-go rejects a request whose payment does not cover the base fee. + 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(); + + let args = flexible_http_request_args(caller_canister); + test.inject_call_to_ic00(Method::FlexibleHttpRequest, args.encode(), Cycles::new(1)); + test.execute_all(); + + // The request is rejected and no context is added. + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 0); +} + +#[test] +fn execute_flexible_canister_http_request_disabled() { + // The flexible HTTP outcalls feature is gated behind a feature flag that is + // disabled by default. + 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) + .build(); + + let args = flexible_http_request_args(caller_canister); + test.inject_call_to_ic00( + Method::FlexibleHttpRequest, + args.encode(), + Cycles::new(1_000_000_000), + ); + test.execute_all(); + + // No context is added: the feature flag blocks the request. + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 0); +} + fn get_reject_message(response: RequestOrResponse) -> String { match response { RequestOrResponse::Request(_) => panic!("Expected Response"), diff --git a/rs/test_utilities/execution_environment/src/lib.rs b/rs/test_utilities/execution_environment/src/lib.rs index 33fdd2b0437a..abe67e976c9f 100644 --- a/rs/test_utilities/execution_environment/src/lib.rs +++ b/rs/test_utilities/execution_environment/src/lib.rs @@ -72,6 +72,7 @@ use ic_types::messages::{Blob, RawSignedSenderInfo, SignedIngressContent, Signed use ic_types::{ CanisterId, Height, NumInstructions, QueryStatsEpoch, Time, UserId, batch::QueryStats, + canister_http::Replication, crypto::{AlgorithmId, canister_threshold_sig::MasterPublicKey}, ingress::{IngressState, IngressStatus, WasmResult}, messages::{ @@ -571,6 +572,19 @@ impl ExecutionTest { ) } + pub fn http_request_base_fee( + &self, + request_size: NumBytes, + replication: &Replication, + ) -> CompoundCycles { + self.cycles_account_manager.http_request_base_fee( + request_size, + self.subnet_size(), + replication, + self.cost_schedule(), + ) + } + pub fn reduced_wasm_compilation_fee(&self, wasm: &[u8]) -> Cycles { let cost = wasm_compilation_cost(wasm); self.convert_instructions_to_cycles( @@ -2655,6 +2669,11 @@ impl ExecutionTestBuilder { self } + pub fn with_flexible_http_requests_enabled(mut self) -> Self { + self.execution_config.flexible_http_requests = FlagStatus::Enabled; + self + } + pub fn without_composite_queries(mut self) -> Self { self.execution_config.composite_queries = FlagStatus::Disabled; self diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index 8a3377305bff..abd087e8c4c9 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -585,13 +585,9 @@ impl CanisterHttpRequestContext { .unwrap_or(DEFAULT_HTTP_OUTCALLS_PRICING_VERSION); PricingVersion::from_repr(final_version_u32).unwrap_or(PricingVersion::Legacy) }, - refund_status: RefundStatus { - //TODO(IC-1937): subtract the base fee from the refundable amount. - refundable_cycles: request.payment, - per_replica_allowance: request.payment / node_ids.len(), - refunded_cycles: Cycles::new(0), - refunding_nodes: BTreeSet::new(), - }, + // The refund status is populated in `try_add_http_context_to_replicated_state`, + // once the base fee is subtracted from the request's payment. + refund_status: RefundStatus::default(), // TODO: populate with the actual registry version this request is processed at. registry_version: RegistryVersion::from(0), }) @@ -694,13 +690,9 @@ impl CanisterHttpRequestContext { max_responses, }, pricing_version: PricingVersion::PayAsYouGo, - refund_status: RefundStatus { - //TODO(IC-1937): subtract the base fee from the refundable amount. - refundable_cycles: request.payment, - per_replica_allowance: request.payment / (total_requests as usize).max(1), - refunded_cycles: Cycles::new(0), - refunding_nodes: BTreeSet::new(), - }, + // The refund status is populated in `try_add_http_context_to_replicated_state`, + // once the base fee is subtracted from the request's payment. + refund_status: RefundStatus::default(), // TODO: populate with the actual registry version this request is processed at. registry_version: RegistryVersion::from(0), }) From bbf91facc69081a0d7b8c32c23133358531b5a32 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 09:20:42 +0000 Subject: [PATCH 2/9] review --- rs/execution_environment/src/execution_environment.rs | 2 +- rs/types/types/src/canister_http.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 66efcd6d8580..4b98aa33746f 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -2174,7 +2174,7 @@ impl ExecutionEnvironment { ErrorCode::CanisterRejectedMessage, format!( "{} request sent with {} cycles, but {} cycles are required.", - Ic00Method::HttpRequest, + request.method_name, request.payment, charged_fee.real() ), diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index abd087e8c4c9..e43ec76d71a9 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -585,8 +585,8 @@ impl CanisterHttpRequestContext { .unwrap_or(DEFAULT_HTTP_OUTCALLS_PRICING_VERSION); PricingVersion::from_repr(final_version_u32).unwrap_or(PricingVersion::Legacy) }, - // The refund status is populated in `try_add_http_context_to_replicated_state`, - // once the base fee is subtracted from the request's payment. + // The refund status is populated in `try_add_http_context_to_replicated_state` + // based on the request's payment and the base fee. refund_status: RefundStatus::default(), // TODO: populate with the actual registry version this request is processed at. registry_version: RegistryVersion::from(0), @@ -690,8 +690,8 @@ impl CanisterHttpRequestContext { max_responses, }, pricing_version: PricingVersion::PayAsYouGo, - // The refund status is populated in `try_add_http_context_to_replicated_state`, - // once the base fee is subtracted from the request's payment. + // The refund status is populated in `try_add_http_context_to_replicated_state` + // based on the request's payment and the base fee. refund_status: RefundStatus::default(), // TODO: populate with the actual registry version this request is processed at. registry_version: RegistryVersion::from(0), From 2feac365132c28137a6abfab9587de1878f13fd2 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 09:29:50 +0000 Subject: [PATCH 3/9] fix --- rs/execution_environment/src/execution_environment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 4b98aa33746f..bbc1484bede5 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -2121,7 +2121,7 @@ impl ExecutionEnvironment { ) -> Result<(), UserError> { let variable_parts_size = canister_http_request_context.variable_parts_size(); let cycles_config = state.get_own_subnet_cycles_config(); - let cost_schedule = state.get_own_cost_schedule(); + let cost_schedule = cycles_config.cost_schedule; let legacy_fee = self.cycles_account_manager.http_request_fee( variable_parts_size, canister_http_request_context.max_response_bytes, From 2187777964c60082ccec8330cb44736877e838fc Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 10:32:19 +0000 Subject: [PATCH 4/9] fix --- rs/cycles_account_manager/src/cycles_account_manager.rs | 2 +- rs/types/types/src/canister_http.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index d143ffbff681..b42de5762289 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -1270,7 +1270,7 @@ impl CyclesAccountManager { + FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min } Replication::NonReplicated(_) => { - // Non-replicated is the same as flexible replication with min requests of 1. + // Non-replicated is equivalent to flexible replication with min_responses = 1. BASE_FEE + PER_REQUEST_BYTE_FEE * request_bytes + FLEXIBLE_PER_NODE_FEE * n diff --git a/rs/types/types/src/canister_http.rs b/rs/types/types/src/canister_http.rs index e43ec76d71a9..7a09536d538c 100644 --- a/rs/types/types/src/canister_http.rs +++ b/rs/types/types/src/canister_http.rs @@ -144,10 +144,10 @@ pub struct CanisterHttpRequestContext { #[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize, Serialize)] pub struct RefundStatus { /// The amount of cycles that are available to be refunded for this request. - /// The amount is calculated based to the payment of the request. + /// The amount is calculated based on the payment of the request. pub refundable_cycles: Cycles, /// The amount of cycles that are allowed to be refunded for this request. - /// The allowance is calculated based on the subnet size: per_replica_allowance = refundable_cycles / subnet_size. + /// The allowance is calculated based on the committee size: per_replica_allowance = refundable_cycles / committee_size. pub per_replica_allowance: Cycles, /// The amount of cycles that have already been refunded for this request. /// Invariant: refunded_cycles <= refundable_cycles From 2ab6cfe1dfce5b1eb3ec35d33c0b7a56c3604e13 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 10:33:28 +0000 Subject: [PATCH 5/9] fix --- rs/tests/networking/canister_http_flexible_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/tests/networking/canister_http_flexible_test.rs b/rs/tests/networking/canister_http_flexible_test.rs index a176fcde7a45..9848c23dbe3e 100644 --- a/rs/tests/networking/canister_http_flexible_test.rs +++ b/rs/tests/networking/canister_http_flexible_test.rs @@ -103,7 +103,7 @@ async fn test_proxy_canister(proxy_canister: &Canister<'_>, url: String, logger: .await .expect("Update call to proxy canister failed"); - let expected_error_msg = "FlexibleHttpRequest is not yet implemented"; + let expected_error_msg = "This API is not enabled on this subnet"; match res { Ok(_) => { From 62138e71e15fa9296dd0f88ea1527e52e4de803b Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 18 Jun 2026 10:51:43 +0000 Subject: [PATCH 6/9] test(https-outcalls): Add coverage for PAYG base-fee charging paths Add execution-environment unit tests for the pay-as-you-go base fee: - execute_canister_http_request_insufficient_payment: pins the legacy rejection threshold to the full legacy fee (not the smaller base fee), guarding the "legacy charging flow unchanged" invariant now that the threshold branches on pricing version. - execute_canister_http_request_non_replicated_refund_status: covers the non-replicated divisor (per_replica_allowance == refundable_cycles) and the non-replicated base-fee arm. - execute_flexible_canister_http_request_explicit_replication: covers an explicit ReplicationCounts with a committee smaller than the subnet, so the per-replica allowance is split across the committee, plus the flexible base-fee arm with a non-default min_responses. - Strengthen execute_flexible_canister_http_request_disabled to assert the specific "This API is not enabled on this subnet" reject, distinguishing the flag gate from other rejection reasons. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/execution_environment/tests.rs | 219 +++++++++++++++++- 1 file changed, 216 insertions(+), 3 deletions(-) diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 335d6a72b6c9..7c3ef06848a1 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -10,8 +10,9 @@ use ic_management_canister_types_private::{ CanisterStatusType, CreateCanisterArgs, DerivationPath, EcdsaCurve, EcdsaKeyId, EmptyBlob, FetchCanisterLogsRequest, FlexibleCanisterHttpRequestArgs, HttpMethod, IC_00, LogVisibilityV2, MasterPublicKeyId, Method, Payload as Ic00Payload, ProvisionalCreateCanisterWithCyclesArgs, - ProvisionalTopUpCanisterArgs, SchnorrAlgorithm, SchnorrKeyId, TakeCanisterSnapshotArgs, - TransformContext, TransformFunc, UploadChunkArgs, VetKdCurve, VetKdKeyId, + ProvisionalTopUpCanisterArgs, ReplicationCounts, SchnorrAlgorithm, SchnorrKeyId, + TakeCanisterSnapshotArgs, TransformContext, TransformFunc, UploadChunkArgs, VetKdCurve, + VetKdKeyId, }; use ic_registry_routing_table::{CanisterIdRange, RoutingTable, canister_id_into_u64}; use ic_registry_subnet_type::SubnetType; @@ -3373,6 +3374,148 @@ fn execute_canister_http_request_disabled() { assert_eq!(canister_http_request_contexts.len(), 0); } +#[test] +fn execute_canister_http_request_insufficient_payment() { + // Under legacy pricing the *full* request fee is charged upfront, not just + // the (smaller) base fee. A payment that covers the base fee but not the + // full legacy fee must therefore still be rejected. This pins the legacy + // threshold to the legacy fee and guards against it being accidentally + // lowered to the base fee. + let own_subnet = subnet_test_id(1); + let caller_canister = canister_test_id(10); + let legacy_http_request_args = || CanisterHttpRequestArgs { + url: "https://example.com".to_string(), + // A large response limit makes the legacy fee (which has a response-size + // term) strictly exceed the base fee (which has none). + max_response_bytes: Some(1_000_000), + headers: BoundedHttpHeaders::new(vec![]), + body: None, + method: HttpMethod::GET, + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: caller_canister.get().0, + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + is_replicated: None, + pricing_version: None, + }; + let build_test = || { + ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_caller(own_subnet, caller_canister) + .build() + }; + + // Probe with ample payment to learn the base and legacy fees for these args. + let (base_fee_real, legacy_fee_real) = { + let mut probe = build_test(); + probe.inject_call_to_ic00( + Method::HttpRequest, + legacy_http_request_args().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(); + let size = context.variable_parts_size(); + let base_fee = probe.http_request_base_fee(size, &context.replication); + let legacy_fee = probe.http_request_fee(size, context.max_response_bytes); + (base_fee.real(), legacy_fee.real()) + }; + // The test is only meaningful if the legacy fee strictly exceeds the base + // fee, so that a payment equal to the base fee discriminates between the two + // thresholds. + assert!(base_fee_real < legacy_fee_real); + + // A payment equal to the base fee covers the base fee but not the legacy + // fee, so legacy pricing must reject it without adding a context. + let mut test = build_test(); + test.inject_call_to_ic00( + Method::HttpRequest, + legacy_http_request_args().encode(), + base_fee_real, + ); + test.execute_all(); + assert_eq!( + test.state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts + .len(), + 0 + ); + assert!(get_reject_message(test.xnet_messages()[0].clone()).contains("cycles are required")); +} + +#[test] +fn execute_canister_http_request_non_replicated_refund_status() { + // A non-replicated legacy request has a single participating replica, so its + // per-replica allowance equals the full refundable amount (divisor of 1). + 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) + .build(); + + let args = CanisterHttpRequestArgs { + url: "https://example.com".to_string(), + max_response_bytes: Some(1000), + headers: BoundedHttpHeaders::new(vec![]), + body: None, + method: HttpMethod::GET, + transform: Some(TransformContext { + function: TransformFunc(candid::Func { + principal: caller_canister.get().0, + method: "transform".to_string(), + }), + context: vec![0, 1, 2], + }), + is_replicated: Some(false), + pricing_version: None, + }; + let payment = Cycles::new(1_000_000_000); + test.inject_call_to_ic00(Method::HttpRequest, args.encode(), payment); + test.execute_all(); + + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 1); + let http_request_context = canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap(); + assert!(matches!( + http_request_context.replication, + Replication::NonReplicated(_) + )); + + 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.refund_status.refundable_cycles, + expected_refundable + ); + // A single participating replica means the allowance is the full refundable + // amount. + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_refundable + ); +} + fn flexible_http_request_args(caller_canister: CanisterId) -> FlexibleCanisterHttpRequestArgs { FlexibleCanisterHttpRequestArgs { url: "https://example.com".to_string(), @@ -3464,6 +3607,71 @@ fn execute_flexible_canister_http_request() { } } +#[test] +fn execute_flexible_canister_http_request_explicit_replication() { + // An explicit replication request with total_requests < subnet_size yields a + // committee smaller than the subnet, so the per-replica allowance is split + // across the committee rather than the whole subnet. + 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(); + + let total_requests = 4; + let mut args = flexible_http_request_args(caller_canister); + args.replication = Some(ReplicationCounts { + total_requests, + min_responses: 2, + max_responses: 4, + }); + let payment = Cycles::new(1_000_000_000); + test.inject_call_to_ic00(Method::FlexibleHttpRequest, args.encode(), payment); + test.execute_all(); + + let canister_http_request_contexts = &test + .state() + .metadata + .subnet_call_context_manager + .canister_http_request_contexts; + assert_eq!(canister_http_request_contexts.len(), 1); + let http_request_context = canister_http_request_contexts + .get(&CallbackId::from(0)) + .unwrap(); + + let (committee_size, min_responses, max_responses) = match &http_request_context.replication { + Replication::Flexible { + committee, + min_responses, + max_responses, + } => (committee.len(), *min_responses, *max_responses), + other => panic!("expected flexible replication, got {other:?}"), + }; + assert_eq!(committee_size, total_requests as usize); + assert!(committee_size < test.subnet_size()); + 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. + 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)); + assert_eq!( + http_request_context.refund_status.refundable_cycles, + expected_refundable + ); + assert_eq!( + http_request_context.refund_status.per_replica_allowance, + expected_refundable / committee_size.max(1) + ); +} + #[test] fn execute_flexible_canister_http_request_insufficient_payment() { // Pay-as-you-go rejects a request whose payment does not cover the base fee. @@ -3507,13 +3715,18 @@ fn execute_flexible_canister_http_request_disabled() { ); test.execute_all(); - // No context is added: the feature flag blocks the request. + // No context is added and the request is rejected specifically because the + // feature flag is disabled (as opposed to any other rejection reason). let canister_http_request_contexts = &test .state() .metadata .subnet_call_context_manager .canister_http_request_contexts; assert_eq!(canister_http_request_contexts.len(), 0); + assert_eq!( + get_reject_message(test.xnet_messages()[0].clone()), + "This API is not enabled on this subnet" + ); } fn get_reject_message(response: RequestOrResponse) -> String { From fc2dd3853076fceb46bfce8bb7a1b0040224ca38 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Fri, 19 Jun 2026 08:48:54 +0000 Subject: [PATCH 7/9] review --- rs/config/src/subnet_config.rs | 10 +++++ .../src/cycles_account_manager.rs | 43 +++++++++---------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/rs/config/src/subnet_config.rs b/rs/config/src/subnet_config.rs index 14d51e72f421..9e5021d0d01c 100644 --- a/rs/config/src/subnet_config.rs +++ b/rs/config/src/subnet_config.rs @@ -145,6 +145,16 @@ pub const SCHNORR_SIGNATURE_FEE: Cycles = Cycles::new(10 * B as u128); /// cover the cost of the subnet. pub const VETKD_FEE: Cycles = Cycles::new(10 * B as u128); +/// Pay-as-you-go base-fee pricing constants for HTTP outcalls, charged upfront +/// for every request by `CyclesAccountManager::http_request_base_fee`. +pub const HTTP_REQUEST_BASE_FEE: u128 = 1_000_000; +pub const HTTP_REQUEST_PER_BYTE_FEE: u128 = 50; +pub const HTTP_REQUEST_FULLY_REPLICATED_PER_NODE_FEE: u128 = 140_000; +pub const HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE: u128 = 800; +pub const HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE: u128 = 90_000; +pub const HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE: u128 = 2_000; +pub const HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE: u128 = 100_000; + /// Default subnet size which is used to scale cycles cost according to a subnet replication factor. /// /// All initial costs were calculated with the assumption that a subnet had 13 replicas. diff --git a/rs/cycles_account_manager/src/cycles_account_manager.rs b/rs/cycles_account_manager/src/cycles_account_manager.rs index b42de5762289..067d7adf3511 100644 --- a/rs/cycles_account_manager/src/cycles_account_manager.rs +++ b/rs/cycles_account_manager/src/cycles_account_manager.rs @@ -1,6 +1,11 @@ use super::{CRITICAL_ERROR_EXECUTION_CYCLES_REFUND, CRITICAL_ERROR_RESPONSE_CYCLES_REFUND}; use ic_base_types::NumSeconds; -use ic_config::subnet_config::CyclesAccountManagerConfig; +use ic_config::subnet_config::{ + CyclesAccountManagerConfig, HTTP_REQUEST_BASE_FEE, HTTP_REQUEST_FLEXIBLE_PER_NODE_FEE, + HTTP_REQUEST_FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE, + HTTP_REQUEST_FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE, HTTP_REQUEST_FULLY_REPLICATED_PER_NODE_FEE, + HTTP_REQUEST_FULLY_REPLICATED_QUADRATIC_NODE_FEE, HTTP_REQUEST_PER_BYTE_FEE, +}; use ic_interfaces::execution_environment::{CanisterOutOfCyclesError, MessageMemoryUsage}; use ic_logger::{ReplicaLogger, error, info}; use ic_management_canister_types_private::Method; @@ -1242,40 +1247,32 @@ impl CyclesAccountManager { replication: &Replication, subnet_cycles_config: CyclesAccountManagerSubnetConfig, ) -> CompoundCycles { - const BASE_FEE: u128 = 1_000_000; - const PER_REQUEST_BYTE_FEE: u128 = 50; - const FULLY_REPLICATED_PER_NODE_FEE: u128 = 140_000; - const FULLY_REPLICATED_QUADRATIC_NODE_FEE: u128 = 800; - const FLEXIBLE_PER_NODE_FEE: u128 = 90_000; - const FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE: u128 = 2_000; - const FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE: u128 = 100_000; - let n = subnet_cycles_config.subnet_size as u128; let request_bytes = request_size.get() as u128; let per_replica = match replication { Replication::FullyReplicated => { - BASE_FEE - + PER_REQUEST_BYTE_FEE * request_bytes - + FULLY_REPLICATED_PER_NODE_FEE * n - + FULLY_REPLICATED_QUADRATIC_NODE_FEE * n * 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 } Replication::Flexible { min_responses: min, .. } => { let min = *min as u128; - BASE_FEE - + PER_REQUEST_BYTE_FEE * request_bytes - + FLEXIBLE_PER_NODE_FEE * n - + FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n * min - + FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE * min + 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. - BASE_FEE - + PER_REQUEST_BYTE_FEE * request_bytes - + FLEXIBLE_PER_NODE_FEE * n - + FLEXIBLE_PER_NODE_RESPONSE_CONSENSUS_FEE * n - + FLEXIBLE_PER_RESPONSE_CONSENSUS_FEE + 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 } }; From 38ae954307c6528c6f470f7c3d2f84974d831129 Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Fri, 19 Jun 2026 12:36:09 +0000 Subject: [PATCH 8/9] pricing calculator --- rs/https_outcalls/client/src/client.rs | 18 ++++++++++++-- rs/https_outcalls/pricing/BUILD.bazel | 1 + rs/https_outcalls/pricing/Cargo.toml | 1 + rs/https_outcalls/pricing/src/legacy.rs | 30 ++++++++++++++++++++-- rs/https_outcalls/pricing/src/lib.rs | 33 ++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/rs/https_outcalls/client/src/client.rs b/rs/https_outcalls/client/src/client.rs index 40b1f0a5ec23..2ecd898d8f15 100644 --- a/rs/https_outcalls/client/src/client.rs +++ b/rs/https_outcalls/client/src/client.rs @@ -15,7 +15,7 @@ use ic_logger::{ReplicaLogger, info, warn}; use ic_management_canister_types_private::{CanisterHttpResponsePayload, TransformArgs}; use ic_metrics::MetricsRegistry; use ic_types::{ - CanisterId, NumBytes, NumInstructions, + CanisterId, CountBytes, NumBytes, NumInstructions, canister_http::{ CanisterHttpHeader, CanisterHttpMethod, CanisterHttpPaymentReceipt, CanisterHttpReject, CanisterHttpRequest, CanisterHttpRequestContext, CanisterHttpResponse, @@ -203,7 +203,7 @@ impl NonBlockingChannel for CanisterHttpAdapterClientImpl { // Only apply the transform if a function name is specified let transform_timer = metrics.transform_execution_duration.start_timer(); - let max_response_size_bytes = budget.get_adapter_limits().max_response_size.get(); + let max_response_size_bytes = budget.get_gossip_limit().get(); let transformed_payload = match &request_transform { Some(transform) => { let (transform_result, instruction_count) = transform_adapter_response( @@ -262,6 +262,20 @@ impl NonBlockingChannel for CanisterHttpAdapterClientImpl { } .await; + let payload_size = match &payload { + Ok(data) => data.len(), + Err(reject) => reject.count_bytes(), + }; + let payload = match budget.subtract_gossip_usage(payload_size) { + Ok(()) => payload, + Err(PricingError::InsufficientCycles) => { + Err(CanisterHttpReject { + reject_code: RejectCode::SysFatal, + message: "Insufficient cycles".to_string(), + }) + } + }; + // Create the payment receipt after all processing is complete. let receipt = budget.create_payment_receipt(); diff --git a/rs/https_outcalls/pricing/BUILD.bazel b/rs/https_outcalls/pricing/BUILD.bazel index bb6fa3989628..68f37bf1c561 100644 --- a/rs/https_outcalls/pricing/BUILD.bazel +++ b/rs/https_outcalls/pricing/BUILD.bazel @@ -9,6 +9,7 @@ rust_library( version = "0.1.0", deps = [ "//rs/config", + "//rs/types/cycles", "//rs/types/types", ], ) diff --git a/rs/https_outcalls/pricing/Cargo.toml b/rs/https_outcalls/pricing/Cargo.toml index 1773aa69401f..815c1306fa68 100644 --- a/rs/https_outcalls/pricing/Cargo.toml +++ b/rs/https_outcalls/pricing/Cargo.toml @@ -8,4 +8,5 @@ documentation.workspace = true [dependencies] ic-config = { path = "../../config" } +ic-types-cycles = { path = "../../types/cycles" } ic-types = { path = "../../types/types" } \ No newline at end of file diff --git a/rs/https_outcalls/pricing/src/legacy.rs b/rs/https_outcalls/pricing/src/legacy.rs index eb272bc8a37b..798b45796e44 100644 --- a/rs/https_outcalls/pricing/src/legacy.rs +++ b/rs/https_outcalls/pricing/src/legacy.rs @@ -3,10 +3,28 @@ use std::time::Duration; use ic_config::subnet_config::MAX_INSTRUCTIONS_PER_QUERY_MESSAGE; use ic_types::{ NumBytes, NumInstructions, - canister_http::{CanisterHttpPaymentReceipt, MAX_CANISTER_HTTP_RESPONSE_BYTES}, + canister_http::{ + CanisterHttpPaymentReceipt, CanisterHttpRequestContext, CanisterHttpResponse, + MAX_CANISTER_HTTP_RESPONSE_BYTES, + }, }; +use ic_types_cycles::Cycles; -use crate::{AdapterLimits, BudgetTracker, NetworkUsage, PricingError}; +use crate::{AdapterLimits, BudgetTracker, NetworkUsage, PricingCalculator, PricingError}; + +pub struct LegacyCalculator; + +impl PricingCalculator for LegacyCalculator { + fn consensus_cost(&self, _response: &CanisterHttpResponse, _subnet_size: u32) -> Cycles { + // Note: the legacy pricing calculator does not calculate the cost of the response. + Cycles::zero() + } + + fn base_cost(&self, _context: &CanisterHttpRequestContext, _subnet_size: u32) -> Cycles { + // Note: the legacy pricing calculator does not calculate the cost of the request. + Cycles::zero() + } +} pub struct LegacyTracker { max_response_size: NumBytes, @@ -45,6 +63,14 @@ impl BudgetTracker for LegacyTracker { Ok(()) } + fn get_gossip_limit(&self) -> NumBytes { + self.max_response_size + } + + fn subtract_gossip_usage(&mut self, _response_size: usize) -> Result<(), PricingError> { + Ok(()) + } + fn create_payment_receipt(&self) -> CanisterHttpPaymentReceipt { // Legacy pricing does not perform cycles accounting, so no cycles // are ever refunded. diff --git a/rs/https_outcalls/pricing/src/lib.rs b/rs/https_outcalls/pricing/src/lib.rs index 827459a9bcdd..8c7c20f7f388 100644 --- a/rs/https_outcalls/pricing/src/lib.rs +++ b/rs/https_outcalls/pricing/src/lib.rs @@ -4,13 +4,26 @@ use std::time::Duration; use ic_types::{ NumBytes, NumInstructions, - canister_http::{CanisterHttpPaymentReceipt, CanisterHttpRequestContext}, + canister_http::{CanisterHttpPaymentReceipt, CanisterHttpRequestContext, CanisterHttpResponse}, }; +use ic_types_cycles::Cycles; use legacy::LegacyTracker; +use crate::legacy::LegacyCalculator; + +pub trait PricingCalculator { + /// Returns the number of cycles that the replica should charge for the given HTTP response, + /// in order to include it in a block payload. + fn consensus_cost(&self, response: &CanisterHttpResponse, subnet_size: u32) -> Cycles; + + /// Returns the base cost that should be charged for the given HTTP request. + fn base_cost(&self, context: &CanisterHttpRequestContext, subnet_size: u32) -> Cycles; +} + pub trait BudgetTracker: Send { /// Returns the maximum network resources the Adapter is allowed to consume. fn get_adapter_limits(&self) -> AdapterLimits; + /// Deducts the actual network resources consumed. /// /// # Invariants @@ -19,13 +32,25 @@ pub trait BudgetTracker: Send { /// /// Note that "<=" is used here to mean field-wise less than or equal to. fn subtract_network_usage(&mut self, network_usage: NetworkUsage) -> Result<(), PricingError>; + /// Returns the maximum instructions allowed for the transformation function. fn get_transform_limit(&self) -> NumInstructions; + /// Deducts the actual instructions consumed by the transformation. /// /// # Invariants /// - This method returns `Ok(())` if and only if `usage <= get_transform_limit()`. fn subtract_transform_usage(&mut self, usage: NumInstructions) -> Result<(), PricingError>; + + /// Returns the maximum number of bytes the response may have in order to be gossiped. + fn get_gossip_limit(&self) -> NumBytes; + + /// Deducts the actual number of bytes of the response that were gossiped. + /// + /// # Invariants + /// - This method returns `Ok(())` if and only if `response_size <= get_gossip_limit()`. + fn subtract_gossip_usage(&mut self, response_size: usize) -> Result<(), PricingError>; + /// Produces the per-replica payment receipt that summarizes the cycles /// accounting outcome of the outcall, given the resources consumed so /// far via the `subtract_*` methods. @@ -58,4 +83,10 @@ impl PricingFactory { // Currently, we only support the legacy pricing version. Box::new(LegacyTracker::new(context.max_response_bytes)) } + + pub fn new_calculator(_context: &CanisterHttpRequestContext) -> Box { + // TODO(IC-1937): This should take into account context.pricing_version and a replica config. + // Currently, we only support the legacy pricing version. + Box::new(LegacyCalculator {}) + } } From 914b60aef4a9295196b302bc934fed01b55ca2ea Mon Sep 17 00:00:00 2001 From: IDX GitHub Automation Date: Fri, 19 Jun 2026 12:38:32 +0000 Subject: [PATCH 9/9] Automatically updated Cargo*.lock --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index c2e1fc00702c..894eac4200a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10304,6 +10304,7 @@ version = "0.9.0" dependencies = [ "ic-config", "ic-types", + "ic-types-cycles", ] [[package]]