From dfa04d1230aebe2a1e15c5913d0c1f15107de2bd Mon Sep 17 00:00:00 2001 From: Leo Eichhorn Date: Thu, 17 Sep 2026 12:34:40 +0000 Subject: [PATCH] feat: pay-as-you-go pricing and flexible outcalls (5.0.0) Adds builders for both outcall methods and moves HTTPS outcalls to pricing version 2, which charges for the resources a call consumes rather than for max_response_bytes. - Call.httpRequest / Call.flexibleHttpRequest return builders whose send() computes the cost with ic0.cost_http_request_v2 and attaches it. The four withExpected* setters narrow the reservation from the worst case to what the call is expected to use; anything left unset falls back to the maximum. - Cost.httpRequest (version 1) is removed, and the builders always set pricing_version = 2. Migrating deliberately is the reason this is a major bump rather than a silent switch. - Types regenerated from did/ic.did, which now carries flexible_http_request, pricing_version, and the per-node resource reports. The flexible cost parameters tag the variant as #_351978059_, the Candid field hash of "flexible", because flexible is a reserved word in Motoko and the grammar admits only identifiers as variant labels. didc confirms it encodes byte-identically. The tag is confined to a private type; callers never see it. Requires a moc release carrying the subnetSelfNodeCount and costHttpRequestV2 primitives. [toolchain] moc is still 1.7.0, so CI fails until that lands, and [requirements] moc must be raised before publishing. --- CHANGELOG.md | 67 ++++++++++ did/ic.did | 68 +++++++++- mops.toml | 2 +- src/Call.mo | 308 +++++++++++++++++++++++++++++++++++++++++----- src/Types.mo | 89 +++++++++++++- test/Call.test.mo | 103 +++++++++++----- 6 files changed, 568 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f9df5..488c97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +## 5.0.0 + +Adds the pay-as-you-go pricing field and the flexible HTTPS outcall interface, and picks up +`status_visibility`, which had not been regenerated since it landed upstream. + +### Breaking type changes (aligned to upstream `.did`) + +- `HttpRequestArgs` gains `pricing_version : ?Nat32`, selecting the pricing mechanism for an + outcall: `1` (legacy, the default, deprecated) or `2` (pay-as-you-go). Motoko requires every + field in a record literal, so existing callers that construct `HttpRequestArgs` by hand must + add `pricing_version = null` to construct one. Callers using the `Call` builders do not need to: + the builders set it themselves. + +### Additive changes + +- New method: `flexible_http_request` — a variant of `http_request` in which a committee of nodes + return their individual HTTP responses instead of the subnet reaching consensus on one. +- New types: `FlexibleHttpRequestArgs`, `FlexibleHttpRequestResult`, `FlexibleHttpRequestErr`, + `HttpRequestResourceReport`. +- New type: `StatusVisibility`, and `status_visibility` on `CanisterSettings` and + `DefiniteCanisterSettings`. + +### Builders, replacing the record wrappers + +`Call.httpRequest` is now a builder that always selects pricing version `2`, and +`Call.flexibleHttpRequest` is its counterpart for the new endpoint. + +```motoko +let response = await Call.httpRequest("https://example.com/api") + .withMethod(#post) + .withMaxResponseBytes(4_000) + .withExpectedRoundtripTimeMs(300) + .send(); +``` + +- `Call.httpRequest` changes from `(IC.HttpRequestArgs) -> async IC.HttpRequestResult` to + `(Text) -> HttpRequest`, so every call site fails to compile until it is migrated. Switching the + pricing version underneath an unchanged call would have been silent; this is deliberate. +- `Call.httpRequestFromArgs(args)` and `Call.flexibleHttpRequestFromArgs(args)` take existing + arguments, so a call site can migrate without rewriting how it builds them. Both overwrite + `pricing_version` with `2`. +- `withExpected*` on both builders narrows the reservation. Under version `2` the attached cycles + are also the budget each node may spend, so anything left unset falls back to the most that + parameter could reach. Two defaults are worth knowing: a request with no `transform` reserves + nothing for one, and a flexible outcall's expected transformed size is capped at the block + budget divided by `min_responses`, since responses larger than that average cannot be delivered + together. +- `getCost()` returns what `send()` will attach; `args()` returns the arguments as they stand. +- `Call.Cost.httpRequestV2` and `Call.Cost.flexibleHttpRequest` price an outcall directly, via the + new `ic0.cost_http_request_v2` system API. + +### Removed + +- `Call.Cost.httpRequest`, which priced version `1`. Version `2` is the only pricing the package + wraps. `Prim.costHttpRequest` still exposes version `1` for callers that need it. + +### Toolchain + +These wrappers require a `moc` that surfaces `ic0.cost_http_request_v2` and +`ic0.subnet_self_node_count` as the `costHttpRequestV2` and `subnetSelfNodeCount` primitives. +`[requirements] moc` must be raised to that release before publishing; the pinned `1.7.0` does not +have them. + +### Housekeeping + +- Refreshed `did/ic.did` from `dfinity/developer-docs`. + ## 4.2.0 ### Additive changes diff --git a/did/ic.did b/did/ic.did index 519d1c0..8918b2f 100644 --- a/did/ic.did +++ b/did/ic.did @@ -14,7 +14,13 @@ type snapshot_visibility = variant { allowed_viewers : vec principal; }; -type environment_variable = record { +type status_visibility = variant { + controllers; + public; + allowed_viewers : vec principal; +}; + +type environment_variable = record { name: text; value: text; }; @@ -25,8 +31,11 @@ type canister_settings = record { memory_allocation : opt nat; freezing_threshold : opt nat; reserved_cycles_limit : opt nat; + minimum_incoming_canister_call_cycles : opt nat; log_visibility : opt log_visibility; + log_memory_limit : opt nat; snapshot_visibility : opt snapshot_visibility; + status_visibility : opt status_visibility; wasm_memory_limit : opt nat; wasm_memory_threshold : opt nat; environment_variables : opt vec environment_variable; @@ -38,8 +47,11 @@ type definite_canister_settings = record { memory_allocation : nat; freezing_threshold : nat; reserved_cycles_limit : nat; + minimum_incoming_canister_call_cycles : nat; log_visibility : log_visibility; + log_memory_limit : nat; snapshot_visibility : snapshot_visibility; + status_visibility : status_visibility; wasm_memory_limit : nat; wasm_memory_threshold : nat; environment_variables : vec environment_variable; @@ -112,6 +124,34 @@ type http_request_result = record { body : blob; }; +type http_request_resource_report = record { + raw_response_bytes : opt variant { used : nat64; exceeded : reserved }; + http_roundtrip_time_ms : opt variant { used : nat64; exceeded : reserved }; + transform_instructions : opt variant { used : nat64; exceeded : reserved }; + transformed_response_bytes : opt variant { used : nat64; exceeded : reserved }; + cycles : opt variant { used : nat; exceeded : reserved }; +}; + +type flexible_http_request_err = record { + global_error : opt variant { + timeout : reserved; + out_of_cycles : reserved; + responses_too_large : reserved; + too_many_rejects : reserved; + }; + node_details : vec record { + node_id : principal; + report : http_request_resource_report; + error : opt record { code : text; message : text }; + }; + message : text; +}; + +type flexible_http_request_result = variant { + ok : vec http_request_result; + err : flexible_http_request_err; +}; + type ecdsa_curve = variant { secp256k1; }; @@ -300,6 +340,7 @@ type canister_status_result = record { canister_history_size : nat; wasm_chunk_store_size : nat; snapshots_size : nat; + log_memory_store_size : nat; }; cycles : nat; reserved_cycles : nat; @@ -352,6 +393,24 @@ type http_request_args = record { context : blob; }; is_replicated : opt bool; + pricing_version : opt nat32; +}; + +type flexible_http_request_args = record { + url : text; + max_response_bytes : opt nat64; + method : variant { get; head; post; put; delete; patch }; + headers : vec http_header; + body : opt blob; + transform : opt record { + function : func(record { response : http_request_result; context : blob }) -> (http_request_result) query; + context : blob; + }; + replication : opt record { + min_responses : nat32; + max_responses : nat32; + total_requests : nat32; + }; }; type ecdsa_public_key_args = record { @@ -499,6 +558,10 @@ type delete_canister_snapshot_args = record { type fetch_canister_logs_args = record { canister_id : canister_id; + filter : opt variant { + by_idx : record { start : nat64; end : nat64 }; + by_timestamp_nanos : record { start : nat64; end : nat64 }; + }; }; type canister_log_record = record { @@ -672,9 +735,10 @@ service ic : { deposit_cycles : (deposit_cycles_args) -> (); raw_rand : () -> (raw_rand_result); http_request : (http_request_args) -> (http_request_result); + flexible_http_request : (flexible_http_request_args) -> (flexible_http_request_result); // Public canister data - canister_info : (canister_info_args) -> (canister_info_result); + canister_info : (canister_info_args) -> (canister_info_result) query; canister_metadata : (canister_metadata_args) -> (canister_metadata_result); // Threshold ECDSA signature diff --git a/mops.toml b/mops.toml index 628c98d..301f272 100644 --- a/mops.toml +++ b/mops.toml @@ -1,6 +1,6 @@ [package] name = "ic" -version = "4.2.0" +version = "5.0.0" description = "IC management canister interface (aaaaa-aa)" repository = "https://github.com/caffeinelabs/mops-ic" keywords = ["ic", "types", "interface", "http"] diff --git a/src/Call.mo b/src/Call.mo index 45a95dc..fb4d4ab 100644 --- a/src/Call.mo +++ b/src/Call.mo @@ -1,3 +1,4 @@ +import Array "mo:core/Array"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; import Prim "mo:prim"; @@ -16,9 +17,142 @@ module { await (with cycles = Cost.createCanister()) ic.create_canister(args); }; - /// Invokes the `http_request` method of the IC management canister and automatically adds the necessary cycles to the call. - public func httpRequest(args : IC.HttpRequestArgs) : async IC.HttpRequestResult { - await (with cycles = Cost.httpRequest(args)) ic.http_request(args); + /// The HTTP method of an outcall. `didc` inlines this in `Types.mo`, so it is named here. + public type HttpMethod = { #get; #put; #head; #post; #delete; #patch }; + + /// A `transform` function together with its context. + public type Transform = { + function : shared query { context : Blob; response : IC.HttpRequestResult } -> async IC.HttpRequestResult; + context : Blob; + }; + + /// A builder for an HTTPS outcall via `http_request`. + /// + /// The outcall is always made with pricing version `2` ("pay-as-you-go"), which charges for the + /// resources the call actually consumes rather than for `max_response_bytes`. + /// + /// Because the cycles attached to a version `2` outcall are also the budget each node may spend + /// on it, the amount to attach depends on how much the call is expected to consume. Every + /// `withExpected*` method narrows that estimate; whatever is left unset falls back to the most + /// the outcall could consume. + /// + /// ```motoko no-repl + /// let response = await Call.httpRequest("https://example.com/api") + /// .withMethod(#post) + /// .withMaxResponseBytes(4_000) + /// .withExpectedRoundtripTimeMs(300) + /// .send(); + /// ``` + public type HttpRequest = { + withMethod : HttpMethod -> HttpRequest; + withHeaders : [IC.HttpHeader] -> HttpRequest; + withHeader : (Text, Text) -> HttpRequest; + withBody : Blob -> HttpRequest; + withMaxResponseBytes : Nat64 -> HttpRequest; + withTransform : Transform -> HttpRequest; + /// Makes the outcall non-replicated: one node performs it and its answer is delivered. + nonReplicated : () -> HttpRequest; + withExpectedRoundtripTimeMs : Nat64 -> HttpRequest; + withExpectedRawResponseBytes : Nat64 -> HttpRequest; + withExpectedTransformedResponseBytes : Nat64 -> HttpRequest; + withExpectedTransformInstructions : Nat64 -> HttpRequest; + /// The arguments as they stand, with `pricing_version` already set to `2`. + args : () -> IC.HttpRequestArgs; + /// The cycles `send` will attach. + getCost : () -> Nat; + /// Makes the outcall, attaching `getCost()` cycles. + send : () -> async IC.HttpRequestResult; + }; + + /// Starts building an outcall to `url`. + public func httpRequest(url : Text) : HttpRequest = httpRequestFromArgs({ + url; + method = #get; + headers = []; + body = null; + max_response_bytes = null; + transform = null; + is_replicated = null; + pricing_version = ?2; + }); + + /// Starts building an outcall from existing arguments, so a call site can migrate without + /// rewriting how it builds them. `pricing_version` is overwritten with `2`. + public func httpRequestFromArgs(args : IC.HttpRequestArgs) : HttpRequest = + buildHttpRequest({ args with pricing_version = ?2 }, Cost.worstCase); + + func buildHttpRequest(a : IC.HttpRequestArgs, e : Cost.ExpectedUsage) : HttpRequest = object { + public func withMethod(m : HttpMethod) : HttpRequest = buildHttpRequest({ a with method = m }, e); + public func withHeaders(h : [IC.HttpHeader]) : HttpRequest = buildHttpRequest({ a with headers = h }, e); + public func withHeader(name : Text, value : Text) : HttpRequest = + buildHttpRequest({ a with headers = a.headers.concat([{ name; value }]) }, e); + public func withBody(b : Blob) : HttpRequest = buildHttpRequest({ a with body = ?b }, e); + public func withMaxResponseBytes(n : Nat64) : HttpRequest = buildHttpRequest({ a with max_response_bytes = ?n }, e); + public func withTransform(t : Transform) : HttpRequest = buildHttpRequest({ a with transform = ?t }, e); + public func nonReplicated() : HttpRequest = buildHttpRequest({ a with is_replicated = ?false }, e); + public func withExpectedRoundtripTimeMs(v : Nat64) : HttpRequest = buildHttpRequest(a, { e with roundtripTimeMs = ?v }); + public func withExpectedRawResponseBytes(v : Nat64) : HttpRequest = buildHttpRequest(a, { e with rawResponseBytes = ?v }); + public func withExpectedTransformedResponseBytes(v : Nat64) : HttpRequest = buildHttpRequest(a, { e with transformedResponseBytes = ?v }); + public func withExpectedTransformInstructions(v : Nat64) : HttpRequest = buildHttpRequest(a, { e with transformInstructions = ?v }); + public func args() : IC.HttpRequestArgs = a; + public func getCost() : Nat = Cost.httpRequestV2(a, e); + public func send() : async IC.HttpRequestResult = async { await (with cycles = getCost()) ic.http_request(a) }; + }; + + /// A builder for a flexible HTTPS outcall via `flexible_http_request`. + /// + /// A committee of nodes make the request and the canister receives their individual responses + /// rather than one the subnet agreed on. Flexible outcalls are always priced with version `2`. + public type FlexibleHttpRequest = { + withMethod : HttpMethod -> FlexibleHttpRequest; + withHeaders : [IC.HttpHeader] -> FlexibleHttpRequest; + withHeader : (Text, Text) -> FlexibleHttpRequest; + withBody : Blob -> FlexibleHttpRequest; + withMaxResponseBytes : Nat64 -> FlexibleHttpRequest; + withTransform : Transform -> FlexibleHttpRequest; + /// Sets how many nodes issue the request and how many responses to require and accept. + withReplication : { min_responses : Nat32; max_responses : Nat32; total_requests : Nat32 } -> FlexibleHttpRequest; + withExpectedRoundtripTimeMs : Nat64 -> FlexibleHttpRequest; + withExpectedRawResponseBytes : Nat64 -> FlexibleHttpRequest; + withExpectedTransformedResponseBytes : Nat64 -> FlexibleHttpRequest; + withExpectedTransformInstructions : Nat64 -> FlexibleHttpRequest; + args : () -> IC.FlexibleHttpRequestArgs; + getCost : () -> Nat; + send : () -> async IC.FlexibleHttpRequestResult; + }; + + /// Starts building a flexible outcall to `url`. + public func flexibleHttpRequest(url : Text) : FlexibleHttpRequest = flexibleHttpRequestFromArgs({ + url; + method = #get; + headers = []; + body = null; + max_response_bytes = null; + transform = null; + replication = null; + }); + + /// Starts building a flexible outcall from existing arguments. + public func flexibleHttpRequestFromArgs(args : IC.FlexibleHttpRequestArgs) : FlexibleHttpRequest = + buildFlexibleHttpRequest(args, Cost.worstCase); + + func buildFlexibleHttpRequest(a : IC.FlexibleHttpRequestArgs, e : Cost.ExpectedUsage) : FlexibleHttpRequest = object { + public func withMethod(m : HttpMethod) : FlexibleHttpRequest = buildFlexibleHttpRequest({ a with method = m }, e); + public func withHeaders(h : [IC.HttpHeader]) : FlexibleHttpRequest = buildFlexibleHttpRequest({ a with headers = h }, e); + public func withHeader(name : Text, value : Text) : FlexibleHttpRequest = + buildFlexibleHttpRequest({ a with headers = a.headers.concat([{ name; value }]) }, e); + public func withBody(b : Blob) : FlexibleHttpRequest = buildFlexibleHttpRequest({ a with body = ?b }, e); + public func withMaxResponseBytes(n : Nat64) : FlexibleHttpRequest = buildFlexibleHttpRequest({ a with max_response_bytes = ?n }, e); + public func withTransform(t : Transform) : FlexibleHttpRequest = buildFlexibleHttpRequest({ a with transform = ?t }, e); + public func withReplication(c : { min_responses : Nat32; max_responses : Nat32; total_requests : Nat32 }) : FlexibleHttpRequest = + buildFlexibleHttpRequest({ a with replication = ?c }, e); + public func withExpectedRoundtripTimeMs(v : Nat64) : FlexibleHttpRequest = buildFlexibleHttpRequest(a, { e with roundtripTimeMs = ?v }); + public func withExpectedRawResponseBytes(v : Nat64) : FlexibleHttpRequest = buildFlexibleHttpRequest(a, { e with rawResponseBytes = ?v }); + public func withExpectedTransformedResponseBytes(v : Nat64) : FlexibleHttpRequest = buildFlexibleHttpRequest(a, { e with transformedResponseBytes = ?v }); + public func withExpectedTransformInstructions(v : Nat64) : FlexibleHttpRequest = buildFlexibleHttpRequest(a, { e with transformInstructions = ?v }); + public func args() : IC.FlexibleHttpRequestArgs = a; + public func getCost() : Nat = Cost.flexibleHttpRequest(a, e); + public func send() : async IC.FlexibleHttpRequestResult = async { await (with cycles = getCost()) ic.flexible_http_request(a) }; }; /// Invokes the `sign_with_ecdsa` method of the IC management canister and automatically adds the necessary cycles to the call. @@ -73,17 +207,6 @@ module { public func createCanister() : Nat = Prim.costCreateCanister(); - public func httpRequest(args : IC.HttpRequestArgs) : Nat { - let requestSize = calculateRequestSize(args); - let maxResponseBytes : Nat64 = switch (args.max_response_bytes) { - // As stated here: https://internetcomputer.org/docs/references/ic-interface-spec#ic-http_request: - // "The upper limit on the maximal size for the response is 2MB (2,000,000B) and this value also applies if no maximal size value is specified." - case null 2_000_000; - case (?bytes) bytes; - }; - Prim.costHttpRequest(requestSize, maxResponseBytes); - }; - public func signWithEcdsa(keyName : Text, curve : IC.EcdsaCurve) : Result { let curveEncoding : Nat32 = switch (curve) { case (#secp256k1) 0; @@ -111,37 +234,160 @@ module { }; }; - func calculateRequestSize(request : IC.HttpRequestArgs) : Nat64 { - var size : Nat64 = 0; + /// The resource consumption a caller expects an outcall to have. + /// + /// Under pricing version `2` the attached cycles are also the budget each node may spend, so + /// narrowing these narrows the reservation. A field left `null` falls back to the most that + /// parameter could reach, which yields a reservation the outcall cannot exhaust but which + /// holds far more cycles for the duration of the call. + public type ExpectedUsage = { + roundtripTimeMs : ?Nat64; + rawResponseBytes : ?Nat64; + transformedResponseBytes : ?Nat64; + transformInstructions : ?Nat64; + }; + + /// Expect the most an outcall could consume. Reserves an amount it cannot run short of. + public let worstCase : ExpectedUsage = { + roundtripTimeMs = null; + rawResponseBytes = null; + transformedResponseBytes = null; + transformInstructions = null; + }; + + // Protocol limits, mirroring the interface specification. + let MAX_RESPONSE_BYTES : Nat64 = 2_000_000; + let MAX_ROUNDTRIP_TIME_MS : Nat64 = 60_000; + let MAX_TRANSFORM_INSTRUCTIONS : Nat64 = 5_000_000_000; + let CANDID_OVERHEAD_RESERVE_BYTES : Nat64 = 1_024; + // The block space one flexible outcall has for its responses. + let MAX_FLEXIBLE_RESULT_BYTES : Nat64 = 2_097_152; + + // `flexible` is a reserved word in Motoko, so the variant tag cannot be written by name. + // `_351978059_` is its Candid field hash, which is what goes on the wire either way. + type CostParams = { + request_bytes : Nat64; + http_roundtrip_time_ms : Nat64; + raw_response_bytes : Nat64; + transformed_response_bytes : Nat64; + transform_instructions : Nat64; + outcall_type : ?{ + #fully_replicated : Any; + #non_replicated : Any; + #_351978059_ : ?{ min_responses : Nat32; max_responses : Nat32; total_requests : Nat32 }; + }; + }; - // Add URL byte length - size += Prim.natToNat64(request.url.size()); + /// Resolves an expectation against `max_response_bytes`, filling unset fields with the most + /// the outcall could consume. `transformedCap` bounds the fallback for the transformed size; + /// `hasTransform` says whether a transform can run at all. + func resolve( + expected : ExpectedUsage, + maxResponseBytes : ?Nat64, + transformedCap : ?Nat64, + hasTransform : Bool, + ) : (Nat64, Nat64, Nat64, Nat64) { + let cap : Nat64 = switch (maxResponseBytes) { case null MAX_RESPONSE_BYTES; case (?b) b }; + let roundtrip = switch (expected.roundtripTimeMs) { case null MAX_ROUNDTRIP_TIME_MS; case (?v) v }; + let raw = switch (expected.rawResponseBytes) { case null cap; case (?v) v }; + let transformed = switch (expected.transformedResponseBytes) { + case (?v) v; + case null { + let worst = cap + CANDID_OVERHEAD_RESERVE_BYTES; + switch (transformedCap) { case null worst; case (?c) if (c < worst) c else worst }; + }; + }; + let instructions = switch (expected.transformInstructions) { + case (?v) v; + // Without a transform the system never runs one, so nothing is reserved for it. + case null if (hasTransform) MAX_TRANSFORM_INSTRUCTIONS else (0 : Nat64); + }; + (roundtrip, raw, transformed, instructions); + }; - // Add headers byte length (sum of all names and values) - for (header in request.headers.vals()) { + /// The cycles an `http_request` priced with pricing version `2` should attach. + public func httpRequestV2(args : IC.HttpRequestArgs, expected : ExpectedUsage) : Nat { + let hasTransform = switch (args.transform) { case null false; case (?_) true }; + let (roundtrip, raw, transformed, instructions) = resolve(expected, args.max_response_bytes, null, hasTransform); + let outcallType = switch (args.is_replicated) { + case (?false) ?(#non_replicated(null)); + // Absent means fully replicated, which is what an absent `outcall_type` prices. + case _ null; + }; + let params : CostParams = { + request_bytes = calculateRequestSize(args.url, args.headers, args.body, args.transform); + http_roundtrip_time_ms = roundtrip; + raw_response_bytes = raw; + transformed_response_bytes = transformed; + transform_instructions = instructions; + outcall_type = outcallType; + }; + Prim.costHttpRequestV2(to_candid (params)); + }; + + /// The cycles a `flexible_http_request` should attach. + /// + /// The expected transformed size defaults to the block budget divided by `min_responses`, + /// rounded up: responses larger than that average cannot be delivered together, so reserving + /// for them only withholds cycles for the duration of the call. + public func flexibleHttpRequest(args : IC.FlexibleHttpRequestArgs, expected : ExpectedUsage) : Nat { + let counts = switch (args.replication) { + case (?c) c; + case null { + // The endpoint's own defaults when `replication` is unset. + let n = Prim.subnetSelfNodeCount(); + { min_responses = 2 * n / 3 + 1; max_responses = n; total_requests = n }; + }; + }; + let transformedCap : ?Nat64 = + if (counts.max_responses == 0 or counts.min_responses == 0) null + else { + let m = Prim.natToNat64(Prim.nat32ToNat(counts.min_responses)); + ?((MAX_FLEXIBLE_RESULT_BYTES + m - 1) / m); + }; + let hasTransform = switch (args.transform) { case null false; case (?_) true }; + let (roundtrip, raw, transformed, instructions) = resolve(expected, args.max_response_bytes, transformedCap, hasTransform); + let params : CostParams = { + request_bytes = calculateRequestSize(args.url, args.headers, args.body, args.transform); + http_roundtrip_time_ms = roundtrip; + raw_response_bytes = raw; + transformed_response_bytes = transformed; + transform_instructions = instructions; + outcall_type = ?(#_351978059_(?counts)); + }; + Prim.costHttpRequestV2(to_candid (params)); + }; + + /// The `request_bytes` an outcall is charged for: the URL, every header name and value, the + /// body, and the transform method name and context. Taken as components so that both + /// `HttpRequestArgs` and `FlexibleHttpRequestArgs` can be measured. + func calculateRequestSize( + url : Text, + headers : [IC.HttpHeader], + body : ?Blob, + transform : ?{ function : shared query { context : Blob; response : IC.HttpRequestResult } -> async IC.HttpRequestResult; context : Blob }, + ) : Nat64 { + var size : Nat64 = 0; + size += Prim.natToNat64(url.size()); + for (header in headers.vals()) { size += Prim.natToNat64(header.name.size()); size += Prim.natToNat64(header.value.size()); }; - - // Add body length if present - switch (request.body) { - case (?body) { size += Prim.natToNat64(body.size()) }; + switch (body) { + case (?b) { size += Prim.natToNat64(b.size()) }; case null {}; }; - - // Add transform context length if present - switch (request.transform) { - case (?transform) { - size += Prim.natToNat64(transform.context.size()); + switch (transform) { + case (?t) { + size += Prim.natToNat64(t.context.size()); // Future work: How to get the method name length otherwise? // This gets us both the method name and the actor. // It results in a few extra cycles (cannot be exact now) but it's a good approximation. - let blob = to_candid (transform.function); + let blob = to_candid (t.function); size += Prim.natToNat64(blob.size()); }; case null {}; }; - size; }; }; diff --git a/src/Types.mo b/src/Types.mo index 9e16be2..1094ab6 100644 --- a/src/Types.mo +++ b/src/Types.mo @@ -24,7 +24,9 @@ module { public type BitcoinGetCurrentFeePercentilesArgs = { network : BitcoinNetwork; }; - public type BitcoinGetCurrentFeePercentilesResult = [MillisatoshiPerByte]; + public type BitcoinGetCurrentFeePercentilesResult = [ + MillisatoshiPerByte + ]; public type BitcoinGetUtxosArgs = { network : BitcoinNetwork; filter : ?{ #page : Blob; #min_confirmations : Nat32 }; @@ -66,7 +68,10 @@ module { timestamp_nanos : Nat64; content : Blob; }; - public type CanisterMetadataArgs = { name : Text; canister_id : CanisterId }; + public type CanisterMetadataArgs = { + name : Text; + canister_id : CanisterId; + }; public type CanisterMetadataResult = { value : Blob }; public type CanisterMetricsArgs = { canister_id : CanisterId }; public type CanisterMetricsResult = { cycles_consumed : CyclesConsumed }; @@ -75,10 +80,13 @@ module { wasm_memory_threshold : ?Nat; environment_variables : ?[EnvironmentVariable]; controllers : ?[Principal]; + minimum_incoming_canister_call_cycles : ?Nat; reserved_cycles_limit : ?Nat; log_visibility : ?LogVisibility; + log_memory_limit : ?Nat; snapshot_visibility : ?SnapshotVisibility; wasm_memory_limit : ?Nat; + status_visibility : ?StatusVisibility; memory_allocation : ?Nat; compute_allocation : ?Nat; }; @@ -86,6 +94,7 @@ module { public type CanisterStatusResult = { memory_metrics : { wasm_binary_size : Nat; + log_memory_store_size : Nat; wasm_chunk_store_size : Nat; canister_history_size : Nat; stable_memory_size : Nat; @@ -176,10 +185,13 @@ module { wasm_memory_threshold : Nat; environment_variables : [EnvironmentVariable]; controllers : [Principal]; + minimum_incoming_canister_call_cycles : Nat; reserved_cycles_limit : Nat; log_visibility : LogVisibility; + log_memory_limit : Nat; snapshot_visibility : SnapshotVisibility; wasm_memory_limit : Nat; + status_visibility : StatusVisibility; memory_allocation : Nat; compute_allocation : Nat; }; @@ -195,12 +207,60 @@ module { canister_id : ?CanisterId; derivation_path : [Blob]; }; - public type EcdsaPublicKeyResult = { public_key : Blob; chain_code : Blob }; + public type EcdsaPublicKeyResult = { + public_key : Blob; + chain_code : Blob; + }; public type EnvironmentVariable = { value : Text; name : Text }; - public type FetchCanisterLogsArgs = { canister_id : CanisterId }; + public type FetchCanisterLogsArgs = { + canister_id : CanisterId; + filter : ?{ + #by_idx : { end : Nat64; start : Nat64 }; + #by_timestamp_nanos : { end : Nat64; start : Nat64 }; + }; + }; public type FetchCanisterLogsResult = { canister_log_records : [CanisterLogRecord]; }; + public type FlexibleHttpRequestArgs = { + url : Text; + method : { #get; #put; #head; #post; #delete; #patch }; + max_response_bytes : ?Nat64; + body : ?Blob; + transform : ?{ + function : shared query { + context : Blob; + response : HttpRequestResult; + } -> async HttpRequestResult; + context : Blob; + }; + headers : [HttpHeader]; + replication : ?{ + total_requests : Nat32; + max_responses : Nat32; + min_responses : Nat32; + }; + }; + public type FlexibleHttpRequestErr = { + node_details : [ + { + report : HttpRequestResourceReport; + node_id : Principal; + error : ?{ code : Text; message : Text }; + } + ]; + global_error : ?{ + #responses_too_large : Any; + #out_of_cycles : Any; + #too_many_rejects : Any; + #timeout : Any; + }; + message : Text; + }; + public type FlexibleHttpRequestResult = { + #ok : [HttpRequestResult]; + #err : FlexibleHttpRequestErr; + }; public type HttpHeader = { value : Text; name : Text }; public type HttpRequestArgs = { url : Text; @@ -216,6 +276,14 @@ module { }; headers : [HttpHeader]; is_replicated : ?Bool; + pricing_version : ?Nat32; + }; + public type HttpRequestResourceReport = { + transformed_response_bytes : ?{ #exceeded : Any; #used : Nat64 }; + transform_instructions : ?{ #exceeded : Any; #used : Nat64 }; + cycles : ?{ #exceeded : Any; #used : Nat }; + raw_response_bytes : ?{ #exceeded : Any; #used : Nat64 }; + http_roundtrip_time_ms : ?{ #exceeded : Any; #used : Nat64 }; }; public type HttpRequestResult = { status : Nat; @@ -321,7 +389,10 @@ module { canister_id : ?CanisterId; derivation_path : [Blob]; }; - public type SchnorrPublicKeyResult = { public_key : Blob; chain_code : Blob }; + public type SchnorrPublicKeyResult = { + public_key : Blob; + chain_code : Blob; + }; public type SignWithEcdsaArgs = { key_id : { name : Text; curve : EcdsaCurve }; derivation_path : [Blob]; @@ -347,6 +418,11 @@ module { #allowed_viewers : [Principal]; }; public type StartCanisterArgs = { canister_id : CanisterId }; + public type StatusVisibility = { + #controllers; + #public_; + #allowed_viewers : [Principal]; + }; public type StopCanisterArgs = { canister_id : CanisterId }; public type StoredChunksArgs = { canister_id : CanisterId }; public type StoredChunksResult = [ChunkHash]; @@ -428,7 +504,7 @@ module { bitcoin_get_utxos : shared BitcoinGetUtxosArgs -> async BitcoinGetUtxosResult; bitcoin_send_transaction : shared BitcoinSendTransactionArgs -> async (); /// Public canister data - canister_info : shared CanisterInfoArgs -> async CanisterInfoResult; + canister_info : shared query CanisterInfoArgs -> async CanisterInfoResult; canister_metadata : shared CanisterMetadataArgs -> async CanisterMetadataResult; /// Returns canister related metrics canister_metrics : shared query CanisterMetricsArgs -> async CanisterMetricsResult; @@ -442,6 +518,7 @@ module { ecdsa_public_key : shared EcdsaPublicKeyArgs -> async EcdsaPublicKeyResult; /// canister logging fetch_canister_logs : shared query FetchCanisterLogsArgs -> async FetchCanisterLogsResult; + flexible_http_request : shared FlexibleHttpRequestArgs -> async FlexibleHttpRequestResult; http_request : shared HttpRequestArgs -> async HttpRequestResult; install_chunked_code : shared InstallChunkedCodeArgs -> async (); install_code : shared InstallCodeArgs -> async (); diff --git a/test/Call.test.mo b/test/Call.test.mo index b0afa7b..a5b3e39 100644 --- a/test/Call.test.mo +++ b/test/Call.test.mo @@ -1,4 +1,5 @@ import Blob "mo:core/Blob"; +import Nat32 "mo:core/Nat32"; import Principal "mo:core/Principal"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; @@ -34,30 +35,31 @@ actor { await test( "httpRequest should succeed", func() : async () { - ignore await Call.httpRequest(request); - ignore await Call.httpRequest({ request with headers }); - ignore await Call.httpRequest({ request with body }); - ignore await Call.httpRequest({ request with max_response_bytes }); - ignore await Call.httpRequest({ - request with headers; - body; - max_response_bytes; - }); - ignore await Call.httpRequest({ request with transform }); + ignore await Call.httpRequestFromArgs(request).send(); + ignore await Call.httpRequestFromArgs({ request with headers }).send(); + ignore await Call.httpRequestFromArgs({ request with body }).send(); + ignore await Call.httpRequestFromArgs({ request with max_response_bytes }).send(); + ignore await Call.httpRequest("https://ic0.app") + .withHeader("x-test", "test") + .withMaxResponseBytes(1_000) + .withExpectedRoundtripTimeMs(300) + .send(); + // A transform is set through the builder too; it raises the reservation, because a + // request with no transform reserves nothing for one. + let withoutTransform = Call.httpRequestFromArgs(request).getCost(); + let withTransform = Call.httpRequestFromArgs({ request with transform }).getCost(); + expect.bool(withTransform > withoutTransform).isTrue(); }, ); await suite( - "http_request cost should be exact", + "http_request version 2 quote is sufficient", func() : async () { - await test("default", httpRequestExactCost(request)); - await test("with headers", httpRequestExactCost({ request with headers })); - await test("with body", httpRequestExactCost({ request with body })); - await test("with max_response_bytes", httpRequestExactCost({ request with max_response_bytes })); - await test("with all above", httpRequestExactCost({ request with headers; body; max_response_bytes })); - - // Future work: transform can't be exact yet - // await test("with transform", httpRequestExactCost({ request with transform })); + await test("default", httpRequestQuoteSuffices(request)); + await test("with headers", httpRequestQuoteSuffices({ request with headers })); + await test("with body", httpRequestQuoteSuffices({ request with body })); + await test("with max_response_bytes", httpRequestQuoteSuffices({ request with max_response_bytes })); + await test("with all above", httpRequestQuoteSuffices({ request with headers; body; max_response_bytes })); }, ); @@ -108,19 +110,51 @@ actor { ).reject(); }, ); + await suite( + "pay-as-you-go pricing", + func() : async () { + await test( + "httpRequestV2 quotes a positive amount", + func() : async () { + let v2 = { request with pricing_version = ?(2 : Nat32) }; + expect.nat(Call.Cost.httpRequestV2(v2, Call.Cost.worstCase)).greater(0); + }, + ); + await test( + "narrowing the expectation lowers the quote", + func() : async () { + let v2 = { request with pricing_version = ?(2 : Nat32); max_response_bytes = ?(4_000 : Nat64) }; + let worst = Call.Cost.httpRequestV2(v2, Call.Cost.worstCase); + let narrowed = Call.Cost.httpRequestV2( + v2, + { Call.Cost.worstCase with roundtripTimeMs = ?(300 : Nat64); transformInstructions = ?(1_000_000 : Nat64) }, + ); + expect.bool(narrowed < worst).isTrue(); + }, + ); + await test( + "flexibleHttpRequest quotes a positive amount", + func() : async () { + expect.nat(Call.Cost.flexibleHttpRequest(flexibleRequest, Call.Cost.worstCase)).greater(0); + }, + ); + }, + ); }; - func httpRequestExactCost(request : IC.HttpRequestArgs) : () -> async () = func() : async () { - let cycles = Call.Cost.httpRequest(request); - ignore await (with cycles) ic.http_request(request); - await expect.call( - func() : async () { - ignore await (with cycles = cycles - 1) ic.http_request(request); - } - ).reject(); + /// Version `2` quotes a reservation, not an exact charge: it covers the most expensive result + /// the call could still produce, and the unspent remainder is refunded. So the quote must be + /// enough to make the call, and the builder must attach it. + func httpRequestQuoteSuffices(request : IC.HttpRequestArgs) : () -> async () = func() : async () { + let builder = Call.httpRequestFromArgs(request); + let cycles = builder.getCost(); + expect.nat(cycles).greater(0); + // The builder always selects version 2, whatever the caller passed. + expect.option(builder.args().pricing_version, Nat32.toText, Nat32.equal).equal(?2); + ignore await (with cycles) ic.http_request(builder.args()); }; - func expectResult(result : Result.Result) : ExpectResult.ExpectResult = expect.result( + func expectResult(result : Result.Result) : ExpectResult.ExpectResult = expect.result( result, func r = switch r { case (#ok _) "ok"; @@ -154,7 +188,18 @@ actor { max_response_bytes = null; transform = null; is_replicated = null; + pricing_version = null; }; + let flexibleRequest : IC.FlexibleHttpRequestArgs = { + url = "https://ic0.app"; + method = #get; + headers = []; + body = null; + max_response_bytes = null; + transform = null; + replication = ?{ min_responses = 2; max_responses = 3; total_requests = 3 }; + }; + let headers = [{ name = "x-test"; value = "test" }]; let body = ?to_candid ([1, 2, 3]); let max_response_bytes : ?Nat64 = ?1_000; @@ -176,4 +221,4 @@ actor { message = fakeMessageHash; aux = null; }; -}; +}