Skip to main content

canton_ledger/
json.rs

1//! JSON Ledger API client (HTTP).
2//!
3//! The JSON transport mirrors the gRPC client over Canton's HTTP JSON Ledger
4//! API v2: read the version/offset, **submit commands**, read the **active
5//! contract set** and **updates** as bounded JSON arrays, and — for a
6//! deployment that exposes nothing else — the **package** and **party**
7//! reads and writes `canton-admin` offers over gRPC. It shares the SDK error
8//! model and the same [`Auth`] as the gRPC client.
9//!
10//! Values use the Daml-LF JSON encoding: a record is a JSON object keyed by
11//! field name, a party is a string, a `TextMap` is a JSON object. Reads return
12//! `serde_json::Value` (the M1 dynamic path); typed bindings land in M2.
13//!
14//! The blocking read endpoints are capped by the node's
15//! `http-list-max-elements-limit` and return `413` past it — pass a `limit` (or
16//! a bounded offset range). WebSocket streaming for unbounded tails is a
17//! separate transport.
18
19use std::sync::Arc;
20
21use canton_auth::TokenProvider;
22use canton_core::telemetry::{self, TRANSPORT_JSON};
23use canton_core::{Auth, Error, Result};
24use canton_proto::com::daml::ledger::api::v2::admin;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28/// A client for the Canton **JSON** Ledger API over HTTP.
29#[derive(Clone)]
30pub struct JsonClient {
31    base_url: String,
32    http: reqwest::Client,
33    auth: Auth,
34    /// Kept for the WebSocket handshake (feature `ws`); the HTTP client bakes
35    /// its TLS settings into `http` at `with_tls` time.
36    tls: Option<canton_core::TlsConfig>,
37    retry: Option<canton_core::RetryConfig>,
38    /// The largest WebSocket frame this client will accept, in bytes. Only the
39    /// WS lane has a ceiling to raise: `reqwest` puts no limit on an HTTP
40    /// response body, so the `POST` lane reads whatever the participant sends.
41    max_decoding_message_size: usize,
42    /// How long one HTTP attempt may take. Applied per request rather than on
43    /// the `reqwest` client, so it holds however the client was built and
44    /// whatever order the builders were called in.
45    timeout: std::time::Duration,
46}
47
48/// How long one JSON request may take before it is abandoned.
49///
50/// The same 30 seconds the gRPC channel uses, and for the same reason: without
51/// it there is no bound at all. `reqwest` applies no timeout unless asked, so a
52/// participant that accepts the connection and then stops answering holds the
53/// caller's task open for as long as the process runs.
54const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
55
56/// Hand-written for the same reason [`Config`]'s is: a base URL can carry
57/// credentials in its userinfo (`https://user:secret@host`), and the derived
58/// `Debug` printed them verbatim — one `tracing` field holding a client, or one
59/// `{:?}` in a log line, was enough.
60///
61/// The `reqwest` client is left out entirely — `finish_non_exhaustive` says so
62/// rather than pretending otherwise — because its internals describe a
63/// connection pool, not this client.
64///
65/// [`Config`]: canton_core::Config
66impl std::fmt::Debug for JsonClient {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("JsonClient")
69            .field("base_url", &canton_core::redact_url(&self.base_url))
70            .field("auth", &self.auth)
71            .field("tls", &self.tls)
72            .field("retry", &self.retry)
73            .field("max_decoding_message_size", &self.max_decoding_message_size)
74            .finish_non_exhaustive()
75    }
76}
77
78#[derive(Deserialize)]
79struct VersionResponse {
80    version: String,
81}
82
83#[derive(Deserialize)]
84struct LedgerEndResponse {
85    offset: i64,
86}
87
88#[derive(Deserialize)]
89struct PackagesResponse {
90    #[serde(rename = "packageIds", default)]
91    package_ids: Vec<String>,
92}
93
94#[derive(Deserialize)]
95struct PackageStatusResponse {
96    #[serde(rename = "packageStatus")]
97    package_status: String,
98}
99
100#[derive(Deserialize)]
101struct ParticipantIdResponse {
102    #[serde(rename = "participantId")]
103    participant_id: String,
104}
105
106/// `PartyDetails` as the JSON Ledger API spells it. Private: what leaves this
107/// module is the gRPC message, so a caller switching transports keeps the
108/// type as well as the vocabulary.
109#[derive(Deserialize, Serialize)]
110struct PartyDetailsWire {
111    party: String,
112    #[serde(
113        rename = "isLocal",
114        default,
115        skip_serializing_if = "std::ops::Not::not"
116    )]
117    is_local: bool,
118    #[serde(
119        rename = "localMetadata",
120        default,
121        skip_serializing_if = "Option::is_none"
122    )]
123    local_metadata: Option<ObjectMetaWire>,
124    #[serde(
125        rename = "identityProviderId",
126        default,
127        skip_serializing_if = "String::is_empty"
128    )]
129    identity_provider_id: String,
130}
131
132#[derive(Deserialize, Serialize, Default)]
133struct ObjectMetaWire {
134    #[serde(
135        rename = "resourceVersion",
136        default,
137        skip_serializing_if = "String::is_empty"
138    )]
139    resource_version: String,
140    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
141    annotations: std::collections::HashMap<String, String>,
142}
143
144impl From<PartyDetailsWire> for admin::PartyDetails {
145    fn from(wire: PartyDetailsWire) -> Self {
146        admin::PartyDetails {
147            party: wire.party,
148            is_local: wire.is_local,
149            local_metadata: wire.local_metadata.map(|meta| admin::ObjectMeta {
150                resource_version: meta.resource_version,
151                annotations: meta.annotations,
152            }),
153            identity_provider_id: wire.identity_provider_id,
154        }
155    }
156}
157
158impl From<&admin::PartyDetails> for PartyDetailsWire {
159    fn from(details: &admin::PartyDetails) -> Self {
160        PartyDetailsWire {
161            party: details.party.clone(),
162            is_local: details.is_local,
163            local_metadata: details.local_metadata.as_ref().map(|meta| ObjectMetaWire {
164                resource_version: meta.resource_version.clone(),
165                annotations: meta.annotations.clone(),
166            }),
167            identity_provider_id: details.identity_provider_id.clone(),
168        }
169    }
170}
171
172#[derive(Deserialize)]
173struct ListKnownPartiesResponse {
174    #[serde(rename = "partyDetails", default)]
175    party_details: Vec<PartyDetailsWire>,
176    #[serde(rename = "nextPageToken", default)]
177    next_page_token: String,
178}
179
180#[derive(Deserialize)]
181struct PartiesResponse {
182    #[serde(rename = "partyDetails", default)]
183    party_details: Vec<PartyDetailsWire>,
184}
185
186#[derive(Deserialize)]
187struct PartyDetailsResponse {
188    #[serde(rename = "partyDetails")]
189    party_details: PartyDetailsWire,
190}
191
192#[derive(Serialize)]
193struct AllocatePartyWire<'a> {
194    #[serde(rename = "partyIdHint", skip_serializing_if = "str::is_empty")]
195    party_id_hint: &'a str,
196    #[serde(rename = "localMetadata", skip_serializing_if = "Option::is_none")]
197    local_metadata: Option<ObjectMetaWire>,
198    #[serde(rename = "identityProviderId", skip_serializing_if = "str::is_empty")]
199    identity_provider_id: &'a str,
200    #[serde(rename = "synchronizerId", skip_serializing_if = "str::is_empty")]
201    synchronizer_id: &'a str,
202    #[serde(rename = "userId", skip_serializing_if = "str::is_empty")]
203    user_id: &'a str,
204}
205
206#[derive(Serialize)]
207struct UpdatePartyDetailsWire {
208    #[serde(rename = "partyDetails")]
209    party_details: PartyDetailsWire,
210    #[serde(rename = "updateMask")]
211    update_mask: FieldMaskWire,
212}
213
214#[derive(Serialize)]
215struct FieldMaskWire {
216    paths: Vec<String>,
217}
218
219/// What to allocate, for [`JsonClient::allocate_party_with`].
220///
221/// [`JsonClient::allocate_party`] covers the common case — a hint and nothing
222/// else. This is the rest of `POST /v2/parties`: the synchronizer to allocate
223/// on (required once the participant is connected to more than one), the user
224/// that should be able to act as the new party, an identity provider, and
225/// participant-local annotations.
226#[derive(Clone, Debug, Default)]
227#[non_exhaustive]
228pub struct AllocateParty {
229    party_id_hint: String,
230    annotations: std::collections::HashMap<String, String>,
231    identity_provider_id: String,
232    synchronizer_id: String,
233    user_id: String,
234}
235
236impl AllocateParty {
237    /// An allocation with nothing chosen: the participant picks the id.
238    #[must_use]
239    pub fn new() -> Self {
240        Self::default()
241    }
242
243    /// Suggest the local part of the party id (`<hint>::<fingerprint>`).
244    #[must_use]
245    pub fn with_hint(mut self, party_id_hint: impl Into<String>) -> Self {
246        self.party_id_hint = party_id_hint.into();
247        self
248    }
249
250    /// Store one participant-local annotation with the party.
251    #[must_use]
252    pub fn with_annotation(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
253        self.annotations.insert(key.into(), value.into());
254        self
255    }
256
257    /// Manage the party under this identity provider.
258    #[must_use]
259    pub fn with_identity_provider_id(mut self, identity_provider_id: impl Into<String>) -> Self {
260        self.identity_provider_id = identity_provider_id.into();
261        self
262    }
263
264    /// Allocate on this synchronizer. Optional while the participant is
265    /// connected to one; required once it is connected to several.
266    #[must_use]
267    pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
268        self.synchronizer_id = synchronizer_id.into();
269        self
270    }
271
272    /// Grant this user the right to act as the new party.
273    #[must_use]
274    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
275        self.user_id = user_id.into();
276        self
277    }
278
279    fn wire(&self) -> AllocatePartyWire<'_> {
280        AllocatePartyWire {
281            party_id_hint: &self.party_id_hint,
282            local_metadata: (!self.annotations.is_empty()).then(|| ObjectMetaWire {
283                resource_version: String::new(),
284                annotations: self.annotations.clone(),
285            }),
286            identity_provider_id: &self.identity_provider_id,
287            synchronizer_id: &self.synchronizer_id,
288            user_id: &self.user_id,
289        }
290    }
291}
292
293/// A set of commands to submit over the JSON transport (dynamic path).
294///
295/// Build with [`JsonCommands::new`] then add commands ([`JsonCommands::add_create`]
296/// or [`JsonCommands::add_command`]) and optional metadata. `command_id`
297/// defaults to a fresh UUID so ledger-side de-duplication behaves correctly.
298#[derive(Debug, Clone, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct JsonCommands {
301    command_id: String,
302    act_as: Vec<String>,
303    commands: Vec<Value>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    user_id: Option<String>,
306    #[serde(skip_serializing_if = "Vec::is_empty")]
307    read_as: Vec<String>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    workflow_id: Option<String>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    synchronizer_id: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    submission_id: Option<String>,
314    #[serde(skip_serializing_if = "Vec::is_empty")]
315    disclosed_contracts: Vec<Value>,
316    #[serde(skip_serializing_if = "Vec::is_empty")]
317    package_id_selection_preference: Vec<String>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    deduplication_period: Option<Value>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    min_ledger_time_abs: Option<Value>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    min_ledger_time_rel: Option<Value>,
324}
325
326impl JsonCommands {
327    /// Start a command set acting as `act_as`, with a generated `command_id`
328    /// and no commands yet.
329    #[must_use]
330    pub fn new(act_as: Vec<String>) -> Self {
331        Self {
332            command_id: format!("sdk-{}", uuid::Uuid::new_v4()),
333            act_as,
334            commands: Vec::new(),
335            user_id: None,
336            read_as: Vec::new(),
337            workflow_id: None,
338            synchronizer_id: None,
339            submission_id: None,
340            disclosed_contracts: Vec::new(),
341            package_id_selection_preference: Vec::new(),
342            deduplication_period: None,
343            min_ledger_time_abs: None,
344            min_ledger_time_rel: None,
345        }
346    }
347
348    /// Set an explicit change-ID `command_id` (for exactly-once / de-duplication).
349    #[must_use]
350    pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
351        self.command_id = command_id.into();
352        self
353    }
354
355    /// This command set's complete identity — available before it is sent, and
356    /// the only way back to the outcome of a submission whose result was lost.
357    #[must_use]
358    pub fn change_id(&self) -> crate::ChangeId {
359        crate::ChangeId::new(
360            self.user_id.clone().unwrap_or_default(),
361            self.act_as.clone(),
362            self.command_id.clone(),
363        )
364    }
365
366    /// Set the acting user id (defaults to the one derived from the token).
367    #[must_use]
368    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
369        self.user_id = Some(user_id.into());
370        self
371    }
372
373    /// Add read-as parties.
374    #[must_use]
375    pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
376        self.read_as = read_as;
377        self
378    }
379
380    /// Set the workflow id.
381    #[must_use]
382    pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
383        self.workflow_id = Some(workflow_id.into());
384        self
385    }
386
387    /// Pin the submission to a specific synchronizer.
388    #[must_use]
389    pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
390        self.synchronizer_id = Some(synchronizer_id.into());
391        self
392    }
393
394    /// Add a `CreateCommand` for `template_id` (`"<pkg>:<Module>:<Entity>"`) with
395    /// `create_arguments` in Daml-LF JSON (a record is an object keyed by field).
396    #[must_use]
397    pub fn add_create(mut self, template_id: impl Into<String>, create_arguments: Value) -> Self {
398        // Build the object directly (rather than `json!`) so `create_arguments`
399        // is moved in, not cloned.
400        let mut create = serde_json::Map::new();
401        create.insert("templateId".to_string(), Value::String(template_id.into()));
402        create.insert("createArguments".to_string(), create_arguments);
403        let mut command = serde_json::Map::new();
404        command.insert("CreateCommand".to_string(), Value::Object(create));
405        self.commands.push(Value::Object(command));
406        self
407    }
408
409    /// Add a raw command value (e.g. an `ExerciseCommand`), for shapes the
410    /// convenience builders don't cover.
411    #[must_use]
412    pub fn add_command(mut self, command: Value) -> Self {
413        self.commands.push(command);
414        self
415    }
416
417    /// Set an explicit submission id, to correlate this particular submission
418    /// attempt in completions. Defaults to participant-generated.
419    #[must_use]
420    pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
421        self.submission_id = Some(submission_id.into());
422        self
423    }
424
425    /// Attach a disclosed contract (raw JSON: `{"templateId": …,
426    /// "contractId": …, "createdEventBlob": …, "synchronizerId": …}`, with the
427    /// blob obtained from a read with created-event blobs enabled). May be
428    /// called repeatedly.
429    #[must_use]
430    pub fn add_disclosed_contract(mut self, contract: Value) -> Self {
431        self.disclosed_contracts.push(contract);
432        self
433    }
434
435    /// Restrict package selection for interpretation to these package ids
436    /// (at most one preference per package name) — the SCU upgrade pin.
437    #[must_use]
438    pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
439        self.package_id_selection_preference = package_ids;
440        self
441    }
442
443    /// Set the de-duplication period (raw JSON, e.g.
444    /// `{"DeduplicationDuration": {"value": {"duration": "5s"}}}`), matching
445    /// the JSON API's `deduplicationPeriod` encoding.
446    #[must_use]
447    pub fn with_deduplication_period(mut self, period: Value) -> Self {
448        self.deduplication_period = Some(period);
449        self
450    }
451
452    /// Set the absolute lower bound for the ledger-effective time (raw JSON,
453    /// an ISO-8601 timestamp string). Mutually exclusive with
454    /// [`Self::with_min_ledger_time_rel`].
455    #[must_use]
456    pub fn with_min_ledger_time_abs(mut self, time: Value) -> Self {
457        self.min_ledger_time_abs = Some(time);
458        self
459    }
460
461    /// Set the relative lower bound for the ledger-effective time (raw JSON,
462    /// a proto duration like `"5s"`). Mutually exclusive with
463    /// [`Self::with_min_ledger_time_abs`].
464    #[must_use]
465    pub fn with_min_ledger_time_rel(mut self, duration: Value) -> Self {
466        self.min_ledger_time_rel = Some(duration);
467        self
468    }
469}
470
471/// The response to a successful `submit-and-wait` — the lighter of the two
472/// waiting submissions, which reports where the command landed without
473/// returning the transaction itself.
474#[derive(Debug, Clone, Deserialize)]
475#[serde(rename_all = "camelCase")]
476#[non_exhaustive]
477pub struct JsonSubmitAndWaitResponse {
478    /// The id of the transaction the command produced.
479    pub update_id: String,
480    /// The offset of the completion.
481    pub completion_offset: i64,
482}
483
484/// The response to a successful `submit-and-wait-for-transaction`.
485#[derive(Debug, Clone, Deserialize)]
486#[non_exhaustive]
487pub struct JsonSubmitResponse {
488    /// The committed transaction.
489    pub transaction: JsonTransaction,
490}
491
492/// A committed transaction from the JSON transport. Top-level fields are typed;
493/// `events` stay as raw JSON (the M1 dynamic path).
494#[derive(Debug, Clone, Deserialize)]
495#[serde(rename_all = "camelCase")]
496#[non_exhaustive]
497pub struct JsonTransaction {
498    /// The update id (globally unique).
499    pub update_id: String,
500    /// The submitter-provided command id (empty if not echoed).
501    #[serde(default)]
502    pub command_id: String,
503    /// The workflow id (empty if unset).
504    #[serde(default)]
505    pub workflow_id: String,
506    /// The ledger offset at which this transaction was committed.
507    pub offset: i64,
508    /// The synchronizer that sequenced the transaction.
509    #[serde(default)]
510    pub synchronizer_id: String,
511    /// Ledger-effective time (ISO-8601).
512    #[serde(default)]
513    pub effective_at: String,
514    /// Record time (ISO-8601).
515    #[serde(default)]
516    pub record_time: String,
517    /// The events, each a tagged object (`{"CreatedEvent": …}` / `{"ArchivedEvent": …}`).
518    #[serde(default)]
519    pub events: Vec<Value>,
520}
521
522/// The request body for an ACS snapshot at `active_at_offset` (POST and WS).
523/// Built through [`crate::request::ActiveContractsRequest`], so the plain and
524/// builder-driven methods share one body producer.
525fn active_contracts_request(parties: &[String], active_at_offset: i64) -> Value {
526    crate::request::ActiveContractsRequest::new(parties.to_vec(), active_at_offset).json_body()
527}
528
529/// The request body for updates over `(begin_exclusive, end_inclusive]` (POST
530/// and WS); omit `end_inclusive` for an unbounded tail. Built through
531/// [`crate::request::UpdatesRequest`] — `LEDGER_EFFECTS`, wildcard filters,
532/// reassignments included — the same defaults as the gRPC lane, so both
533/// transports yield the same event set for the same query.
534fn updates_request(parties: &[String], begin_exclusive: i64, end_inclusive: Option<i64>) -> Value {
535    let mut request = crate::request::UpdatesRequest::new(parties.to_vec(), begin_exclusive);
536    if let Some(end) = end_inclusive {
537        request = request.until(end);
538    }
539    request.json_body()
540}
541
542/// The request body for command completions from `begin_exclusive` (WS).
543#[cfg(feature = "ws")]
544fn completions_request(parties: &[String], begin_exclusive: i64) -> Value {
545    crate::request::CompletionsRequest::new(parties.to_vec(), begin_exclusive).json_body()
546}
547
548/// Whether a failed submission is the participant refusing a command it already
549/// has: the error id `DUPLICATE_COMMAND`, or the gRPC code `ALREADY_EXISTS`
550/// that Canton assigns it (`grpcCodeValue` in a JSON body).
551///
552/// The HTTP status alone does not say: Canton's JSON lane maps `ALREADY_EXISTS`
553/// *and* `ABORTED` to 409, and `PARTICIPANT_BACKPRESSURE` is the latter, so a
554/// bare 409 on a retry could be a node that never accepted anything. Nor does
555/// the text: a body that merely mentions `DUPLICATE_COMMAND` in a cause string
556/// is not the error itself. Anything that is not the duplicate goes back to
557/// the retry loop to be classified.
558fn is_duplicate_submission(error: &Error) -> bool {
559    error
560        .error_info()
561        .is_some_and(|info| info.reason == "DUPLICATE_COMMAND")
562        || error.code() == Some(tonic::Code::AlreadyExists)
563}
564
565/// The HTTP client every JSON lane starts from. Redirects are not followed:
566/// the requests carry a bearer token and, on a submission, a command body,
567/// and a 3xx from the participant's address would have `reqwest` replay both
568/// wherever the redirect points. A participant does not redirect; anything
569/// that does is answered as the status it sent, which is not retried.
570fn http_client_builder() -> reqwest::ClientBuilder {
571    reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
572}
573
574/// Add W3C trace-context headers to an outgoing request (a no-op without the
575/// `otel` feature, or when no OpenTelemetry context is active).
576/// Percent-encode `value` as one path segment or one query value: everything
577/// outside RFC 3986's unreserved set is encoded, so a page token carrying `&`
578/// or a party id carrying `::` arrives as one value rather than as syntax.
579fn encode(value: &str) -> String {
580    use std::fmt::Write as _;
581    let mut out = String::with_capacity(value.len());
582    for byte in value.bytes() {
583        match byte {
584            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
585                out.push(char::from(byte));
586            }
587            _ => {
588                // Writing to a String cannot fail.
589                let _ = write!(out, "%{byte:02X}");
590            }
591        }
592    }
593    out
594}
595
596/// A caller-supplied identifier as a path segment. Empty, or carrying
597/// whitespace, is a caller mistake rather than something to encode and send:
598/// the participant would answer about a party that does not exist, and the
599/// caller would learn only that.
600fn segment(kind: &str, value: &str) -> Result<String> {
601    if value.is_empty() || value.chars().any(char::is_whitespace) {
602        return Err(Error::InvalidRequest(format!(
603            "{kind} {value:?} cannot form a path segment"
604        )));
605    }
606    Ok(encode(value))
607}
608
609fn with_trace_context(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
610    #[cfg(feature = "otel")]
611    {
612        let mut headers = reqwest::header::HeaderMap::new();
613        canton_core::telemetry::otel::inject_trace_context(&mut headers);
614        if !headers.is_empty() {
615            return request.headers(headers);
616        }
617    }
618    request
619}
620
621/// How much of an error body is kept. The status is the verdict; the body is
622/// context for a log line, and a participant (or whatever answers in its
623/// place) does not get to size the error the caller stores and prints.
624const ERROR_BODY_CAP: usize = 64 * 1024;
625
626/// Validate an HTTP response and deserialize its JSON body, reading at most
627/// `cap` bytes of it: the JSON lane's counterpart to the gRPC decoding limit,
628/// so a hostile or broken node cannot make the client allocate without bound.
629async fn read_json<T: for<'de> Deserialize<'de>>(
630    response: reqwest::Response,
631    path: &str,
632    cap: usize,
633) -> Result<T> {
634    // Non-2xx carries its status (e.g. `413` past the node's list cap, `401`
635    // for a bad token), so callers can branch and retry 5xx/429.
636    if !response.status().is_success() {
637        let status = response.status().as_u16();
638        let body = read_body(response, ERROR_BODY_CAP, path)
639            .await
640            .unwrap_or_else(|e| format!("<body not read: {e}>"));
641        return Err(Error::http_at(status, body, path));
642    }
643    let body = read_body(response, cap, path).await?;
644    // A malformed body is a deserialization error (Error::Json), not a bad request.
645    serde_json::from_str::<T>(&body).map_err(Error::from)
646}
647
648/// Read a response body, refusing one that declares or grows past `cap`.
649/// `reqwest`'s own `text()` reads to the end whatever the size; this reads
650/// chunk by chunk and stops at the cap, so the bound holds for a chunked
651/// stream with no `Content-Length` as much as for a declared one.
652async fn read_body(mut response: reqwest::Response, cap: usize, path: &str) -> Result<String> {
653    if let Some(declared) = response
654        .content_length()
655        .filter(|declared| usize::try_from(*declared).map_or(true, |n| n > cap))
656    {
657        {
658            return Err(Error::UnexpectedResponse(format!(
659                "{path} answered with a {declared}-byte body, above the {cap}-byte limit \
660                 (raise it with `with_max_decoding_message_size` if it is expected)"
661            )));
662        }
663    }
664    let mut buffer: Vec<u8> = Vec::new();
665    while let Some(chunk) = response
666        .chunk()
667        .await
668        .map_err(|e| Error::Connection(format!("reading json body from {path} failed: {e}")))?
669    {
670        if buffer.len() + chunk.len() > cap {
671            return Err(Error::UnexpectedResponse(format!(
672                "{path} answered with a body above the {cap}-byte limit \
673                 (raise it with `with_max_decoding_message_size` if it is expected)"
674            )));
675        }
676        buffer.extend_from_slice(&chunk);
677    }
678    String::from_utf8(buffer).map_err(|e| {
679        Error::UnexpectedResponse(format!(
680            "{path} answered with a body that is not UTF-8: {e}"
681        ))
682    })
683}
684
685/// Upgrade an `http://` base URL to `https://` when TLS is configured.
686///
687/// Both JSON lanes select TLS by URL scheme: `reqwest` for HTTP, and
688/// [`ws_url`](crate::ws) (`http`→`ws`, `https`→`wss`) for WebSocket. So a
689/// `JsonClient` given an `http://` base URL together with `with_tls` would send
690/// plaintext HTTP with the certificates unused and open a `ws://` socket. This
691/// normalises the scheme so TLS is actually applied, matching the gRPC channel
692/// builder. Detection is case-insensitive; anything not `http://` (already
693/// `https://`, or another scheme) is left unchanged.
694fn upgrade_base_url_for_tls(base_url: &str) -> String {
695    if base_url
696        .get(..7)
697        .is_some_and(|s| s.eq_ignore_ascii_case("http://"))
698    {
699        format!("https://{}", &base_url[7..])
700    } else {
701        base_url.to_string()
702    }
703}
704
705impl JsonClient {
706    /// Create a JSON client for `base_url` (e.g. `http://localhost:3975`), with
707    /// no authentication. A trailing slash on `base_url` is tolerated.
708    #[must_use]
709    pub fn new(base_url: impl Into<String>) -> Self {
710        let mut base_url = base_url.into();
711        while base_url.ends_with('/') {
712            base_url.pop();
713        }
714        Self {
715            base_url,
716            http: http_client_builder()
717                .build()
718                .unwrap_or_else(|_| reqwest::Client::new()),
719            auth: Auth::None,
720            tls: None,
721            retry: None,
722            max_decoding_message_size: canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
723            timeout: DEFAULT_TIMEOUT,
724        }
725    }
726
727    /// Build a client from a local development network exported into the
728    /// environment — the JSON counterpart to [`Config::from_env`].
729    ///
730    /// ```text
731    /// eval "$(canton-devkit localnet env demo)"
732    /// ```
733    ///
734    /// reads `CANTON_JSON_LEDGER_API_URL` and the default participant's JWT.
735    /// See [`canton_core::localnet`] for the full contract.
736    ///
737    /// # Errors
738    /// Returns [`Error::InvalidRequest`] when no JSON endpoint is exported,
739    /// naming the variable and the command that produces it. A missing token is
740    /// not an error: an unauthenticated LocalNet is a normal target.
741    ///
742    /// [`Config::from_env`]: canton_core::Config::from_env
743    pub fn from_env() -> Result<Self> {
744        Self::for_role(None)
745    }
746
747    /// The same, for a participant other than the default: `"app-user"`,
748    /// `"sv"`, or any role the exporter knows.
749    ///
750    /// # Errors
751    /// As [`Self::from_env`], naming that role's variable.
752    pub fn from_env_for(role: &str) -> Result<Self> {
753        Self::for_role(Some(role))
754    }
755
756    fn for_role(role: Option<&str>) -> Result<Self> {
757        use canton_core::localnet;
758
759        let base_url = localnet::json_endpoint(role).ok_or_else(|| {
760            let variable = match role {
761                None => "CANTON_JSON_LEDGER_API_URL".to_string(),
762                Some(role) => format!(
763                    "CANTON_{}_JSON_LEDGER_API_URL",
764                    role.to_uppercase().replace('-', "_")
765                ),
766            };
767            Error::InvalidRequest(format!(
768                "no JSON ledger endpoint in the environment: set {variable}. \
769                 A local network exports it with `canton-devkit localnet env <instance>`; \
770                 run that through `eval` first."
771            ))
772        })?;
773        let client = Self::new(base_url);
774        Ok(match localnet::token(role) {
775            Some(token) => client.with_token(token),
776            None => client,
777        })
778    }
779
780    /// How long one HTTP attempt may take (default 30s), the JSON lane's
781    /// counterpart to [`Config::with_timeout`].
782    ///
783    /// `reqwest` imposes no timeout of its own, so without this a request to a
784    /// participant that accepts the connection and then goes quiet never
785    /// returns. Applied per attempt: under [`Self::with_retry`] each try gets
786    /// the full budget, matching how the gRPC channel's timeout composes with
787    /// its retries.
788    ///
789    /// The clock covers the whole exchange — connecting, sending, and reading
790    /// the response body — so it is a real bound rather than a bound on the
791    /// first byte.
792    ///
793    /// [`Config::with_timeout`]: canton_core::Config::with_timeout
794    #[must_use]
795    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
796        self.timeout = timeout;
797        self
798    }
799
800    /// The largest WebSocket message this client will accept, in bytes —
801    /// the JSON lane's counterpart to [`Config::with_max_decoding_message_size`],
802    /// and the same default.
803    ///
804    /// A WS stream carries the same payloads the gRPC one does, so it needs the
805    /// same ceiling. Left alone, `tungstenite` applies its own: 64 MiB per
806    /// message and **16 MiB per frame**, the second of which is the one a large
807    /// update meets first. Both are set from this value, so a frame is capped
808    /// only by what the caller asked for.
809    ///
810    /// Does not affect the HTTP (`POST`) lane, which has no ceiling to raise.
811    ///
812    /// [`Config::with_max_decoding_message_size`]: canton_core::Config::with_max_decoding_message_size
813    #[must_use]
814    pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
815        self.max_decoding_message_size = bytes;
816        self
817    }
818
819    /// Retry requests on retriable errors (category-first classification of
820    /// the participant's error body, transient HTTP statuses, connection
821    /// failures) with exponential backoff, honouring a server-recommended
822    /// retry delay — the same policy as the gRPC client's unary retries.
823    /// Off by default. Safe for command submission too: the command id in
824    /// the body stays fixed across attempts, so the participant de-duplicates.
825    /// Streaming (the WS lane) resumes via its own reconnect policy instead.
826    #[must_use]
827    pub fn with_retry(mut self, retry: canton_core::RetryConfig) -> Self {
828        self.retry = Some(retry);
829        self
830    }
831
832    /// Use TLS for the HTTP connection: a custom CA (server-side TLS against a
833    /// private/self-signed server) and/or a client identity (mutual TLS). This
834    /// is a terminal builder step — call it last, after [`Self::with_token`] /
835    /// [`Self::with_oidc`].
836    ///
837    /// An `http://` base URL is normalised to `https://` so TLS is never
838    /// silently downgraded: `reqwest` selects TLS from the URL scheme (not from
839    /// the configured certificates), and the WebSocket lane maps `http`→`ws` /
840    /// `https`→`wss` the same way, so an `http://` base URL with `with_tls`
841    /// would otherwise send plaintext HTTP and open a `ws://` socket with the
842    /// certificates unused. Detection is case-insensitive, mirroring the gRPC
843    /// channel builder (`canton-core`'s `resolve_endpoint`).
844    ///
845    /// `TlsConfig::domain_name` is not applied here: `reqwest` derives SNI
846    /// from the request URL (it is a gRPC/`tonic` knob).
847    ///
848    /// # Errors
849    /// Returns [`Error::InvalidRequest`] if a certificate/identity PEM is
850    /// invalid or the HTTPS client cannot be built.
851    pub fn with_tls(mut self, tls: &canton_core::TlsConfig) -> Result<Self> {
852        let mut builder = http_client_builder();
853        if let Some(ca) = &tls.ca_certificate_pem {
854            let cert = reqwest::Certificate::from_pem(ca)
855                .map_err(|e| Error::InvalidRequest(format!("invalid CA certificate: {e}")))?;
856            builder = builder.add_root_certificate(cert);
857        }
858        if let Some((cert, key)) = &tls.client_identity_pem {
859            // reqwest/rustls expects one PEM blob: certificate chain then key.
860            let mut pem = cert.clone();
861            pem.push(b'\n');
862            pem.extend_from_slice(key);
863            let identity = reqwest::Identity::from_pem(&pem)
864                .map_err(|e| Error::InvalidRequest(format!("invalid client identity: {e}")))?;
865            builder = builder.identity(identity);
866        }
867        self.http = builder
868            .build()
869            .map_err(|e| Error::InvalidRequest(format!("building the HTTPS client failed: {e}")))?;
870        self.base_url = upgrade_base_url_for_tls(&self.base_url);
871        self.tls = Some(tls.clone());
872        Ok(self)
873    }
874
875    /// Authenticate with a fixed bearer token.
876    #[must_use]
877    pub fn with_token(mut self, token: impl Into<String>) -> Self {
878        self.auth = Auth::Static(token.into());
879        self
880    }
881
882    /// Authenticate with an OIDC token provider (client-credentials, auto-refresh).
883    #[must_use]
884    pub fn with_oidc(mut self, provider: TokenProvider) -> Self {
885        self.auth = Auth::Dynamic(Arc::new(provider));
886        self
887    }
888
889    /// The WS lane's view of this client. One place, so a setting added to the
890    /// transport reaches every subscription rather than the ones someone
891    /// remembered.
892    #[cfg(feature = "ws")]
893    fn ws_transport(&self) -> crate::ws::WsTransport<'_> {
894        crate::ws::WsTransport {
895            base_url: &self.base_url,
896            auth: &self.auth,
897            tls: self.tls.as_ref(),
898            max_decoding_message_size: self.max_decoding_message_size,
899            timeout: self.timeout,
900        }
901    }
902
903    async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T> {
904        canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
905            let mut request = self
906                .http
907                .get(format!("{}{path}", self.base_url))
908                .timeout(self.timeout);
909            if let Some(token) = self.auth.bearer().await? {
910                request = request.bearer_auth(token);
911            }
912            request = with_trace_context(request);
913            let response = request
914                .send()
915                .await
916                .map_err(|e| transport_error(path, &e))?;
917            read_json(response, path, self.max_decoding_message_size).await
918        })
919        .await
920    }
921
922    async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
923        &self,
924        path: &str,
925        body: &B,
926    ) -> Result<T> {
927        canton_core::retry::run_with_retry(self.retry.as_ref(), || self.post_once(path, body)).await
928    }
929
930    /// One POST, no retry. The retrying wrappers are built on this so that a
931    /// submission can tell its own retries apart from its first attempt.
932    async fn post_once<B: Serialize, T: for<'de> Deserialize<'de>>(
933        &self,
934        path: &str,
935        body: &B,
936    ) -> Result<T> {
937        let mut request = self
938            .http
939            .post(format!("{}{path}", self.base_url))
940            .timeout(self.timeout)
941            .json(body);
942        if let Some(token) = self.auth.bearer().await? {
943            request = request.bearer_auth(token);
944        }
945        request = with_trace_context(request);
946        // main's structure (one-shot core under the retrying wrapper) with this
947        // branch's transport classification instead of a blanket Connection.
948        let response = request
949            .send()
950            .await
951            .map_err(|e| transport_error(path, &e))?;
952        read_json(response, path, self.max_decoding_message_size).await
953    }
954
955    /// One PATCH, no retry: the callers are mutations whose lost response the
956    /// caller resolves by reading back, not by sending again.
957    async fn patch_once<B: Serialize, T: for<'de> Deserialize<'de>>(
958        &self,
959        path: &str,
960        body: &B,
961    ) -> Result<T> {
962        let mut request = self
963            .http
964            .patch(format!("{}{path}", self.base_url))
965            .timeout(self.timeout)
966            .json(body);
967        if let Some(token) = self.auth.bearer().await? {
968            request = request.bearer_auth(token);
969        }
970        request = with_trace_context(request);
971        let response = request
972            .send()
973            .await
974            .map_err(|e| transport_error(path, &e))?;
975        read_json(response, path, self.max_decoding_message_size).await
976    }
977
978    /// The participant's Ledger API version (`GET /v2/version`, unauthenticated).
979    ///
980    /// # Errors
981    /// Returns an [`Error`] if the request fails or the response is malformed.
982    pub async fn version(&self) -> Result<String> {
983        telemetry::instrument("version", TRANSPORT_JSON, async {
984            Ok(self.get::<VersionResponse>("/v2/version").await?.version)
985        })
986        .await
987    }
988
989    /// The current ledger end offset (`GET /v2/state/ledger-end`, authenticated).
990    ///
991    /// # Errors
992    /// Returns an [`Error`] if authentication or the request fails.
993    pub async fn ledger_end(&self) -> Result<i64> {
994        telemetry::instrument("ledger_end", TRANSPORT_JSON, async {
995            Ok(self
996                .get::<LedgerEndResponse>("/v2/state/ledger-end")
997                .await?
998                .offset)
999        })
1000        .await
1001    }
1002
1003    /// The ids of every package the participant knows (`GET /v2/packages`).
1004    ///
1005    /// The JSON twin of `canton-admin`'s `AdminClient::list_packages`,
1006    /// for a deployment that exposes only the JSON Ledger API. What a package
1007    /// *is* to the ledger — uploaded, vetted, both — is answered by
1008    /// [`package_status`](Self::package_status), not by presence in this list.
1009    ///
1010    /// # Errors
1011    /// Returns an [`Error`] if authentication or the request fails.
1012    pub async fn list_packages(&self) -> Result<Vec<String>> {
1013        telemetry::instrument("list_packages", TRANSPORT_JSON, async {
1014            Ok(self
1015                .get::<PackagesResponse>("/v2/packages")
1016                .await?
1017                .package_ids)
1018        })
1019        .await
1020    }
1021
1022    /// Whether the participant has a package registered
1023    /// (`GET /v2/packages/{package-id}/status`).
1024    ///
1025    /// Answers with the same [`PackageStatus`](canton_proto::com::daml::ledger::api::v2::PackageStatus)
1026    /// the gRPC path returns, so a caller can switch transports without
1027    /// re-learning the vocabulary. A status this build does not know is an
1028    /// [`Error::UnexpectedResponse`] rather than a silent
1029    /// `PACKAGE_STATUS_UNSPECIFIED`: the participant said something definite,
1030    /// and guessing would hide it.
1031    ///
1032    /// # Errors
1033    /// Returns [`Error::InvalidRequest`] if `package_id` is empty or could not
1034    /// form a path segment, and an [`Error`] if authentication or the request
1035    /// fails or the status is not one this build knows.
1036    pub async fn package_status(
1037        &self,
1038        package_id: &str,
1039    ) -> Result<canton_proto::com::daml::ledger::api::v2::PackageStatus> {
1040        use canton_proto::com::daml::ledger::api::v2::PackageStatus;
1041
1042        // The id becomes a path segment. A package id is a hex hash, so
1043        // anything that would change the path's shape is a caller mistake,
1044        // not something to escape and send.
1045        if package_id.is_empty()
1046            || package_id
1047                .chars()
1048                .any(|c| matches!(c, '/' | '?' | '#' | '%') || c.is_whitespace())
1049        {
1050            return Err(Error::InvalidRequest(format!(
1051                "package id {package_id:?} cannot form a path segment"
1052            )));
1053        }
1054        let path = format!("/v2/packages/{package_id}/status");
1055        telemetry::instrument("package_status", TRANSPORT_JSON, async {
1056            let status = self
1057                .get::<PackageStatusResponse>(&path)
1058                .await?
1059                .package_status;
1060            PackageStatus::from_str_name(&status).ok_or_else(|| {
1061                Error::UnexpectedResponse(format!(
1062                    "package status {status:?} is not one this build knows"
1063                ))
1064            })
1065        })
1066        .await
1067    }
1068
1069    /// The participant's own identifier (`GET /v2/parties/participant-id`).
1070    ///
1071    /// # Errors
1072    /// Returns an [`Error`] if authentication or the request fails.
1073    pub async fn participant_id(&self) -> Result<String> {
1074        telemetry::instrument("participant_id", TRANSPORT_JSON, async {
1075            Ok(self
1076                .get::<ParticipantIdResponse>("/v2/parties/participant-id")
1077                .await?
1078                .participant_id)
1079        })
1080        .await
1081    }
1082
1083    /// One page of the parties the participant knows (`GET /v2/parties`).
1084    /// Pass the returned token to fetch the next page; `page_size` `0` uses
1085    /// the server default.
1086    ///
1087    /// Answers with the same [`PartyDetails`](admin::PartyDetails) the gRPC
1088    /// path returns. Requires `ParticipantAdmin` or an identity-provider
1089    /// admin, as the gRPC call does; a plain user is refused.
1090    ///
1091    /// # Errors
1092    /// Returns an [`Error`] if authentication or the request fails.
1093    pub async fn list_known_parties_page(
1094        &self,
1095        page_size: i32,
1096        page_token: Option<String>,
1097    ) -> Result<(Vec<admin::PartyDetails>, Option<String>)> {
1098        use std::fmt::Write as _;
1099        let mut path = String::from("/v2/parties");
1100        let mut separator = '?';
1101        if page_size > 0 {
1102            // Writing to a String cannot fail.
1103            let _ = write!(path, "{separator}pageSize={page_size}");
1104            separator = '&';
1105        }
1106        if let Some(token) = page_token.as_deref().filter(|t| !t.is_empty()) {
1107            let _ = write!(path, "{separator}pageToken={}", encode(token));
1108        }
1109        telemetry::instrument("list_known_parties_page", TRANSPORT_JSON, async {
1110            let response = self.get::<ListKnownPartiesResponse>(&path).await?;
1111            let next = (!response.next_page_token.is_empty()).then_some(response.next_page_token);
1112            Ok((
1113                response.party_details.into_iter().map(Into::into).collect(),
1114                next,
1115            ))
1116        })
1117        .await
1118    }
1119
1120    /// Every party the participant knows, following pagination internally
1121    /// (`GET /v2/parties`).
1122    ///
1123    /// # Errors
1124    /// Returns an [`Error`] if authentication or any page request fails, and
1125    /// [`Error::UnexpectedResponse`] if the participant repeats a page token —
1126    /// the list would be a prefix that looks complete, so it fails instead.
1127    pub async fn list_known_parties(&self) -> Result<Vec<admin::PartyDetails>> {
1128        telemetry::instrument("list_known_parties", TRANSPORT_JSON, async {
1129            let mut all = Vec::new();
1130            let mut page_token: Option<String> = None;
1131            let mut seen = std::collections::HashSet::new();
1132            for page in 1.. {
1133                let (parties, next) = self.list_known_parties_page(0, page_token.clone()).await?;
1134                all.extend(parties);
1135                let Some(next) = next else {
1136                    break;
1137                };
1138                // A server that never advances the token, cycles between a
1139                // few, or mints a fresh one forever would have this loop run
1140                // until memory ran out. Stopping quietly is no better: the
1141                // caller would receive a prefix of the party list with nothing
1142                // to say it is a prefix, and "which parties exist" is a
1143                // question whose wrong answer looks exactly like a right one.
1144                // So a token seen before, or more pages than any participant
1145                // has, fails.
1146                if !seen.insert(next.clone()) {
1147                    return Err(Error::UnexpectedResponse(format!(
1148                        "the participant repeated a page token after {} parties; \
1149                         the list is incomplete and cannot be continued",
1150                        all.len()
1151                    )));
1152                }
1153                if page >= canton_core::MAX_LIST_PAGES {
1154                    return Err(Error::UnexpectedResponse(format!(
1155                        "the party listing did not end after {} pages and {} parties; \
1156                         the list is incomplete and cannot be continued",
1157                        canton_core::MAX_LIST_PAGES,
1158                        all.len()
1159                    )));
1160                }
1161                page_token = Some(next);
1162            }
1163            Ok(all)
1164        })
1165        .await
1166    }
1167
1168    /// Details for specific parties (`GET /v2/parties/{party}`, the rest as
1169    /// `?parties=`). Unknown parties are omitted from the result rather than
1170    /// erroring, as over gRPC. A plain user may only ask about parties it can
1171    /// act or read as; anything else is refused by the participant.
1172    ///
1173    /// # Errors
1174    /// Returns [`Error::InvalidRequest`] if `parties` is empty or one of them
1175    /// cannot form a path segment, and an [`Error`] if authentication or the
1176    /// request fails.
1177    pub async fn get_parties(&self, parties: Vec<String>) -> Result<Vec<admin::PartyDetails>> {
1178        use std::fmt::Write as _;
1179        let Some((first, rest)) = parties.split_first() else {
1180            return Err(Error::InvalidRequest(
1181                "get_parties needs at least one party".to_string(),
1182            ));
1183        };
1184        let mut path = format!("/v2/parties/{}", segment("party", first)?);
1185        for (i, party) in rest.iter().enumerate() {
1186            let separator = if i == 0 { '?' } else { '&' };
1187            // Writing to a String cannot fail.
1188            let _ = write!(path, "{separator}parties={}", segment("party", party)?);
1189        }
1190        telemetry::instrument("get_parties", TRANSPORT_JSON, async {
1191            Ok(self
1192                .get::<PartiesResponse>(&path)
1193                .await?
1194                .party_details
1195                .into_iter()
1196                .map(Into::into)
1197                .collect())
1198        })
1199        .await
1200    }
1201
1202    /// Allocate a party (`POST /v2/parties`). `party_id_hint` suggests the
1203    /// local part of the id; the participant returns the actual
1204    /// [`PartyDetails`](admin::PartyDetails) (`party = "<hint>::<fingerprint>"`).
1205    ///
1206    /// The same call as `canton-admin`'s `allocate_party`, over JSON. For the
1207    /// rest of the request — synchronizer, user, annotations — see
1208    /// [`allocate_party_with`](Self::allocate_party_with).
1209    ///
1210    /// Requires the `ParticipantAdmin` right. Not retried: allocation is a
1211    /// non-idempotent topology mutation, so a transient failure surfaces to
1212    /// the caller rather than risking two parties.
1213    ///
1214    /// # Errors
1215    /// Returns an [`Error`] if authentication or authorization fails, or the
1216    /// request fails.
1217    pub async fn allocate_party(&self, party_id_hint: Option<&str>) -> Result<admin::PartyDetails> {
1218        let mut request = AllocateParty::new();
1219        if let Some(hint) = party_id_hint {
1220            request = request.with_hint(hint);
1221        }
1222        self.allocate_party_with(&request).await
1223    }
1224
1225    /// Allocate a party with the full request (`POST /v2/parties`).
1226    ///
1227    /// # Errors
1228    /// As [`allocate_party`](Self::allocate_party).
1229    pub async fn allocate_party_with(
1230        &self,
1231        request: &AllocateParty,
1232    ) -> Result<admin::PartyDetails> {
1233        telemetry::instrument("allocate_party", TRANSPORT_JSON, async {
1234            Ok(self
1235                .post_once::<_, PartyDetailsResponse>("/v2/parties", &request.wire())
1236                .await?
1237                .party_details
1238                .into())
1239        })
1240        .await
1241    }
1242
1243    /// Update the participant-local details of a party
1244    /// (`PATCH /v2/parties/{party}`).
1245    ///
1246    /// `update_paths` is the field mask — which of `details` to apply, in the
1247    /// gRPC message's spelling: `local_metadata.annotations`, or
1248    /// `local_metadata` for all of it. To remove an annotation, send its key
1249    /// with an empty value. Carrying `resource_version` from a read makes the
1250    /// update conditional on nobody having changed the party since: a stale
1251    /// version is refused with a 409 (`CONCURRENT_PARTY_DETAILS_UPDATE_DETECTED`)
1252    /// the participant classifies as contention, so [`Error::is_retriable`]
1253    /// is true and the right move is to read again and update again.
1254    ///
1255    /// Not retried, for the reason [`allocate_party`](Self::allocate_party)
1256    /// gives: a lost response is resolved by reading the party back.
1257    ///
1258    /// # Errors
1259    /// Returns [`Error::InvalidRequest`] if `update_paths` is empty — the
1260    /// participant refuses an empty mask, and this says so before the trip —
1261    /// or the party cannot form a path segment; an [`Error`] if authentication
1262    /// or authorization fails, the request fails, or the participant reports a
1263    /// concurrent change.
1264    pub async fn update_party_details(
1265        &self,
1266        details: &admin::PartyDetails,
1267        update_paths: &[&str],
1268    ) -> Result<admin::PartyDetails> {
1269        if update_paths.is_empty() {
1270            return Err(Error::InvalidRequest(
1271                "update_party_details needs at least one update path".to_string(),
1272            ));
1273        }
1274        let path = format!("/v2/parties/{}", segment("party", &details.party)?);
1275        let body = UpdatePartyDetailsWire {
1276            party_details: details.into(),
1277            update_mask: FieldMaskWire {
1278                paths: update_paths.iter().map(|p| (*p).to_string()).collect(),
1279            },
1280        };
1281        telemetry::instrument("update_party_details", TRANSPORT_JSON, async {
1282            Ok(self
1283                .patch_once::<_, PartyDetailsResponse>(&path, &body)
1284                .await?
1285                .party_details
1286                .into())
1287        })
1288        .await
1289    }
1290
1291    /// Submit commands and wait for the resulting transaction
1292    /// (`POST /v2/commands/submit-and-wait-for-transaction`).
1293    ///
1294    /// # Errors
1295    /// Returns an [`Error`] if authentication fails, the command is rejected
1296    /// (surfaced as [`Error::Http`] carrying the participant's error body), or
1297    /// the response is malformed.
1298    pub async fn submit_and_wait_for_transaction(
1299        &self,
1300        commands: &JsonCommands,
1301    ) -> Result<JsonSubmitResponse> {
1302        telemetry::instrument("submit_and_wait_for_transaction", TRANSPORT_JSON, async {
1303            let body = json!({ "commands": commands });
1304            self.post("/v2/commands/submit-and-wait-for-transaction", &body)
1305                .await
1306        })
1307        .await
1308    }
1309
1310    /// Submit **without waiting** (`POST /v2/commands/async/submit`): the
1311    /// participant accepts the command and the outcome arrives on the
1312    /// completion stream.
1313    ///
1314    /// The gRPC lane has had this since M1. Note what a failure here means: the
1315    /// command may still have committed, so reach for
1316    /// [`Self::submission`] rather than this method when losing the outcome is
1317    /// not acceptable.
1318    ///
1319    /// # Errors
1320    /// Returns an [`Error`] if authentication fails or the participant rejects
1321    /// the submission ([`Error::Http`] with its error body).
1322    pub async fn submit(&self, commands: &JsonCommands) -> Result<()> {
1323        telemetry::instrument("submit", TRANSPORT_JSON, async {
1324            // These two endpoints take the command set *itself* as the body,
1325            // where `submit-and-wait-for-transaction` takes a request object
1326            // wrapping it. The participant rejects the wrong one with a 400
1327            // naming the fields it could not find.
1328            // The response body is an empty object; accepting it is the answer.
1329            self.post_submission("/v2/commands/async/submit", commands)
1330                .await
1331        })
1332        .await
1333    }
1334
1335    /// `post`, with the one rule a submission needs that a read does not: a
1336    /// retry the participant refuses as a duplicate means our *own* earlier
1337    /// attempt was accepted and its response was lost, so the command
1338    /// succeeded. Reporting the rejection would tell the caller their command
1339    /// failed when it did the opposite — which is the failure this change ID
1340    /// exists to prevent, and it is not the JSON lane's to have differently
1341    /// from gRPC.
1342    async fn post_submission<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
1343        let attempt = std::sync::atomic::AtomicU32::new(0);
1344        canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
1345            let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
1346            match self.post_once::<B, serde_json::Value>(path, body).await {
1347                Ok(_) => Ok(()),
1348                Err(error) if retry && is_duplicate_submission(&error) => {
1349                    tracing::debug!(
1350                        "submission retry was de-duplicated; the earlier attempt is the one that landed"
1351                    );
1352                    Ok(())
1353                }
1354                Err(error) => Err(error),
1355            }
1356        })
1357        .await
1358    }
1359
1360    /// Submit and wait for the completion (`POST /v2/commands/submit-and-wait`)
1361    /// without fetching the transaction — the update id and completion offset.
1362    ///
1363    /// # Errors
1364    /// Returns an [`Error`] if authentication fails or the command is rejected.
1365    pub async fn submit_and_wait(
1366        &self,
1367        commands: &JsonCommands,
1368    ) -> Result<JsonSubmitAndWaitResponse> {
1369        telemetry::instrument("submit_and_wait", TRANSPORT_JSON, async {
1370            self.post("/v2/commands/submit-and-wait", commands).await
1371        })
1372        .await
1373    }
1374
1375    /// The create and consuming-exercise events of one contract
1376    /// (`POST /v2/events/events-by-contract-id`), as seen by `parties`.
1377    ///
1378    /// Returns the raw response object; a contract that has been pruned or is
1379    /// invisible to `parties` comes back as a `CONTRACT_EVENTS_NOT_FOUND`
1380    /// error rather than an empty result.
1381    ///
1382    /// # Errors
1383    /// Returns an [`Error`] if authentication or the request fails, or the
1384    /// contract has no events visible to `parties`.
1385    pub async fn events_by_contract_id(
1386        &self,
1387        contract_id: impl Into<String>,
1388        parties: Vec<String>,
1389    ) -> Result<Value> {
1390        telemetry::instrument("events_by_contract_id", TRANSPORT_JSON, async {
1391            let request = crate::request::ActiveContractsRequest::new(parties, 0);
1392            let body = json!({
1393                "contractId": contract_id.into(),
1394                "eventFormat": request.json_body()["eventFormat"],
1395            });
1396            self.post("/v2/events/events-by-contract-id", &body).await
1397        })
1398        .await
1399    }
1400
1401    /// How far the participant has pruned (`GET /v2/state/latest-pruned-offsets`),
1402    /// the JSON twin of [`CantonClient::latest_pruned_offsets`](crate::CantonClient::latest_pruned_offsets).
1403    ///
1404    /// # Errors
1405    /// Returns an [`Error`] if authentication or the request fails.
1406    pub async fn latest_pruned_offsets(&self) -> Result<crate::PrunedOffsets> {
1407        #[derive(Deserialize)]
1408        #[serde(rename_all = "camelCase")]
1409        struct Body {
1410            participant_pruned_up_to_inclusive: i64,
1411            all_divulged_contracts_pruned_up_to_inclusive: i64,
1412        }
1413        telemetry::instrument("latest_pruned_offsets", TRANSPORT_JSON, async {
1414            let body: Body = self.get("/v2/state/latest-pruned-offsets").await?;
1415            Ok(crate::PrunedOffsets {
1416                participant_pruned_up_to_inclusive: body.participant_pruned_up_to_inclusive,
1417                all_divulged_contracts_pruned_up_to_inclusive: body
1418                    .all_divulged_contracts_pruned_up_to_inclusive,
1419            })
1420        })
1421        .await
1422    }
1423
1424    /// The update at `offset`, as `parties` see it
1425    /// (`POST /v2/updates/update-by-offset`), in the default format of
1426    /// [`UpdatesRequest::new`](crate::request::UpdatesRequest::new): the JSON
1427    /// twin of [`CantonClient::update_by_offset`](crate::CantonClient::update_by_offset).
1428    /// The element is raw JSON (`{"update": …}`), as [`Self::updates`] returns
1429    /// them. `Ok(None)` when no update at that offset is visible to `parties`.
1430    ///
1431    /// # Errors
1432    /// Returns an [`Error`] if authentication or the request fails.
1433    pub async fn update_by_offset(
1434        &self,
1435        offset: i64,
1436        parties: Vec<String>,
1437    ) -> Result<Option<Value>> {
1438        self.update_by_offset_with(offset, &crate::request::UpdatesRequest::new(parties, 0))
1439            .await
1440    }
1441
1442    /// Like [`Self::update_by_offset`], in the format an
1443    /// [`UpdatesRequest`](crate::request::UpdatesRequest) describes. Its
1444    /// offsets are not used.
1445    ///
1446    /// # Errors
1447    /// Returns an [`Error`] if the request names no party, or authentication or
1448    /// the request fails.
1449    pub async fn update_by_offset_with(
1450        &self,
1451        offset: i64,
1452        format: &crate::request::UpdatesRequest,
1453    ) -> Result<Option<Value>> {
1454        format.validate()?;
1455        telemetry::instrument("update_by_offset", TRANSPORT_JSON, async {
1456            let body = json!({
1457                "offset": offset,
1458                "updateFormat": format.json_body()["updateFormat"],
1459            });
1460            crate::client::found(self.post("/v2/updates/update-by-offset", &body).await)
1461        })
1462        .await
1463    }
1464
1465    /// The update with id `update_id`, as `parties` see it
1466    /// (`POST /v2/updates/update-by-id`): the JSON twin of
1467    /// [`CantonClient::update_by_id`](crate::CantonClient::update_by_id).
1468    /// `Ok(None)` when no update with that id is visible to `parties`.
1469    ///
1470    /// # Errors
1471    /// Returns an [`Error`] if authentication or the request fails.
1472    pub async fn update_by_id(
1473        &self,
1474        update_id: impl Into<String>,
1475        parties: Vec<String>,
1476    ) -> Result<Option<Value>> {
1477        self.update_by_id_with(update_id, &crate::request::UpdatesRequest::new(parties, 0))
1478            .await
1479    }
1480
1481    /// Like [`Self::update_by_id`], in the format an
1482    /// [`UpdatesRequest`](crate::request::UpdatesRequest) describes. Its
1483    /// offsets are not used.
1484    ///
1485    /// # Errors
1486    /// Returns an [`Error`] if the request names no party, or authentication or
1487    /// the request fails.
1488    pub async fn update_by_id_with(
1489        &self,
1490        update_id: impl Into<String>,
1491        format: &crate::request::UpdatesRequest,
1492    ) -> Result<Option<Value>> {
1493        format.validate()?;
1494        let update_id = update_id.into();
1495        telemetry::instrument("update_by_id", TRANSPORT_JSON, async {
1496            let body = json!({
1497                "updateId": update_id,
1498                "updateFormat": format.json_body()["updateFormat"],
1499            });
1500            crate::client::found(self.post("/v2/updates/update-by-id", &body).await)
1501        })
1502        .await
1503    }
1504
1505    /// Fix a submission's identity **before** sending it, returning a
1506    /// [`JsonSubmission`](crate::JsonSubmission) that carries its
1507    /// [`ChangeId`](crate::ChangeId) — the JSON lane's
1508    /// [`CantonClient::submission`](crate::CantonClient::submission).
1509    #[must_use]
1510    pub fn submission(&self, commands: JsonCommands) -> crate::submission::JsonSubmission {
1511        crate::submission::JsonSubmission::new(self.clone(), commands)
1512    }
1513
1514    /// The active contract set snapshot at `active_at_offset`, wildcard-filtered
1515    /// to `parties` (`POST /v2/state/active-contracts`).
1516    ///
1517    /// This is a **bounded** read: the node caps results at
1518    /// `http-list-max-elements-limit` and returns [`Error::Http`] `413` past it,
1519    /// so pass a `limit` for large sets (or use the streaming transport).
1520    /// Each element is raw JSON (`{"workflowId": …, "contractEntry": …}`).
1521    ///
1522    /// # Errors
1523    /// Returns an [`Error`] if authentication or the request fails, or the
1524    /// result set exceeds the node limit (`413`).
1525    pub async fn active_contracts(
1526        &self,
1527        parties: Vec<String>,
1528        active_at_offset: i64,
1529        limit: Option<i64>,
1530    ) -> Result<Vec<Value>> {
1531        telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
1532            let body = active_contracts_request(&parties, active_at_offset);
1533            let path = with_limit("/v2/state/active-contracts", limit);
1534            self.post(&path, &body).await
1535        })
1536        .await
1537    }
1538
1539    /// Like [`Self::active_contracts`], with the full request surface of an
1540    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
1541    /// (template/interface filters, created-event blobs, non-verbose records)
1542    /// — the same builder the gRPC lane takes.
1543    ///
1544    /// # Errors
1545    /// Returns an [`Error`] if authentication or the request fails, or the
1546    /// result set exceeds the node limit (`413`).
1547    pub async fn active_contracts_with(
1548        &self,
1549        request: &crate::request::ActiveContractsRequest,
1550        limit: Option<i64>,
1551    ) -> Result<Vec<Value>> {
1552        telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
1553            let path = with_limit("/v2/state/active-contracts", limit);
1554            self.post(&path, &request.json_body()).await
1555        })
1556        .await
1557    }
1558
1559    /// Updates (transactions/reassignments) for `parties` in the offset range
1560    /// `(begin_exclusive, end_inclusive]` (`POST /v2/updates`).
1561    ///
1562    /// A **bounded** read like [`Self::active_contracts`]: bound it with
1563    /// `end_inclusive` and/or `limit`, or the node returns [`Error::Http`]
1564    /// `413`. Each element is raw JSON (`{"update": …}`), including
1565    /// `OffsetCheckpoint` heartbeats.
1566    ///
1567    /// # Errors
1568    /// Returns an [`Error`] if authentication or the request fails, or the
1569    /// result set exceeds the node limit (`413`).
1570    pub async fn updates(
1571        &self,
1572        parties: Vec<String>,
1573        begin_exclusive: i64,
1574        end_inclusive: Option<i64>,
1575        limit: Option<i64>,
1576    ) -> Result<Vec<Value>> {
1577        telemetry::instrument("updates", TRANSPORT_JSON, async {
1578            let body = updates_request(&parties, begin_exclusive, end_inclusive);
1579            let path = with_limit("/v2/updates", limit);
1580            self.post(&path, &body).await
1581        })
1582        .await
1583    }
1584
1585    /// Like [`Self::updates`], with the full request surface of an
1586    /// [`UpdatesRequest`](crate::request::UpdatesRequest) (bounds, template/
1587    /// interface filters, transaction shape, created-event blobs, topology
1588    /// events, non-verbose records) — the same builder the gRPC lane takes.
1589    ///
1590    /// # Errors
1591    /// Returns an [`Error`] if authentication or the request fails, or the
1592    /// result set exceeds the node limit (`413`).
1593    pub async fn updates_with(
1594        &self,
1595        request: &crate::request::UpdatesRequest,
1596        limit: Option<i64>,
1597    ) -> Result<Vec<Value>> {
1598        telemetry::instrument("updates", TRANSPORT_JSON, async {
1599            let path = with_limit("/v2/updates", limit);
1600            self.post(&path, &request.json_body()).await
1601        })
1602        .await
1603    }
1604
1605    /// Like [`Self::updates_with`], as the SDK's own
1606    /// [`Update`](crate::update::Update): the type
1607    /// [`CantonClient::updates_typed_with`](crate::CantonClient::updates_typed_with)
1608    /// yields too. `OffsetCheckpoint` heartbeats are dropped, as the gRPC lane
1609    /// drops them.
1610    ///
1611    /// # Errors
1612    /// Returns an [`Error`] if authentication or the request fails, the result
1613    /// set exceeds the node limit (`413`), or an element is malformed.
1614    pub async fn updates_typed_with(
1615        &self,
1616        request: &crate::request::UpdatesRequest,
1617        limit: Option<i64>,
1618    ) -> Result<Vec<crate::update::Update>> {
1619        let raw = self.updates_with(request, limit).await?;
1620        let mut updates = Vec::with_capacity(raw.len());
1621        for element in &raw {
1622            let update = crate::update::Update::from_json(element)?;
1623            if !matches!(update, crate::update::Update::OffsetCheckpoint(_)) {
1624                updates.push(update);
1625            }
1626        }
1627        Ok(updates)
1628    }
1629}
1630
1631/// Append a `?limit=<n>` query when a limit is set.
1632fn with_limit(path: &str, limit: Option<i64>) -> String {
1633    match limit {
1634        Some(limit) => format!("{path}?limit={limit}"),
1635        None => path.to_string(),
1636    }
1637}
1638
1639#[cfg(feature = "ws")]
1640use futures_util::StreamExt as _;
1641
1642#[cfg(feature = "ws")]
1643impl JsonClient {
1644    /// Stream updates over WebSocket (feature `ws`) for `parties`, starting after
1645    /// `begin_exclusive`. With `end_inclusive` the stream is bounded and closes
1646    /// once the range is exhausted; without it the stream tails live. Each item
1647    /// is a raw JSON update (`{"update": …}`); `OffsetCheckpoint` heartbeats are
1648    /// filtered out (as in the gRPC [`CantonClient::updates`]).
1649    ///
1650    /// Unlike [`Self::updates`], this is not capped by the node's list limit. For
1651    /// automatic reconnection use [`Self::ws_updates_resumable`].
1652    ///
1653    /// [`CantonClient::updates`]: crate::CantonClient::updates
1654    ///
1655    /// # Errors
1656    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
1657    /// participant error frame or a transport failure.
1658    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1659    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1660    pub async fn ws_updates(
1661        &self,
1662        parties: Vec<String>,
1663        begin_exclusive: i64,
1664        end_inclusive: Option<i64>,
1665    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1666        telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
1667            let request = updates_request(&parties, begin_exclusive, end_inclusive);
1668            let inner = crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request).await?;
1669            Ok(telemetry::instrument_stream(
1670                "ws_updates",
1671                TRANSPORT_JSON,
1672                crate::ws::filter_checkpoints(inner),
1673            ))
1674        })
1675        .await
1676    }
1677
1678    /// Like [`Self::ws_updates`], with the full request surface of an
1679    /// [`UpdatesRequest`](crate::request::UpdatesRequest) — the same builder
1680    /// the gRPC lane takes (bounds, filters, shape, blobs, topology events,
1681    /// non-verbose records).
1682    ///
1683    /// # Errors
1684    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
1685    /// a participant error frame or a transport failure.
1686    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1687    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1688    pub async fn ws_updates_with(
1689        &self,
1690        request: &crate::request::UpdatesRequest,
1691    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1692        telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
1693            let inner =
1694                crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request.json_body())
1695                    .await?;
1696            Ok(telemetry::instrument_stream(
1697                "ws_updates",
1698                TRANSPORT_JSON,
1699                crate::ws::filter_checkpoints(inner),
1700            ))
1701        })
1702        .await
1703    }
1704
1705    /// Stream the active contract set snapshot at `active_at_offset` over
1706    /// WebSocket (feature `ws`), wildcard-filtered to `parties`. The stream
1707    /// closes when the snapshot is fully delivered. Each item is raw JSON
1708    /// (`{"workflowId": …, "contractEntry": …}`).
1709    ///
1710    /// Unlike [`Self::active_contracts`], this is not capped by the node's list
1711    /// limit.
1712    ///
1713    /// # Errors
1714    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
1715    /// participant error frame or a transport failure.
1716    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1717    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1718    pub async fn ws_active_contracts(
1719        &self,
1720        parties: Vec<String>,
1721        active_at_offset: i64,
1722    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1723        telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
1724            let request = active_contracts_request(&parties, active_at_offset);
1725            let inner =
1726                crate::ws::subscribe(&self.ws_transport(), "/v2/state/active-contracts", request)
1727                    .await?;
1728            Ok(telemetry::instrument_stream(
1729                "ws_active_contracts",
1730                TRANSPORT_JSON,
1731                inner,
1732            ))
1733        })
1734        .await
1735    }
1736
1737    /// Like [`Self::ws_active_contracts`], with the full request surface of an
1738    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest) —
1739    /// the same builder the gRPC lane takes.
1740    ///
1741    /// # Errors
1742    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
1743    /// a participant error frame or a transport failure.
1744    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1745    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1746    pub async fn ws_active_contracts_with(
1747        &self,
1748        request: &crate::request::ActiveContractsRequest,
1749    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1750        telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
1751            let inner = crate::ws::subscribe(
1752                &self.ws_transport(),
1753                "/v2/state/active-contracts",
1754                request.json_body(),
1755            )
1756            .await?;
1757            Ok(telemetry::instrument_stream(
1758                "ws_active_contracts",
1759                TRANSPORT_JSON,
1760                inner,
1761            ))
1762        })
1763        .await
1764    }
1765
1766    /// Stream command completions over WebSocket (feature `ws`) for `parties`,
1767    /// starting after `begin_exclusive`. Each item is a raw JSON completion;
1768    /// `OffsetCheckpoint` heartbeats are filtered out.
1769    ///
1770    /// # Errors
1771    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
1772    /// participant error frame or a transport failure.
1773    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1774    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1775    pub async fn ws_completions(
1776        &self,
1777        parties: Vec<String>,
1778        begin_exclusive: i64,
1779    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1780        telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
1781            let request = completions_request(&parties, begin_exclusive);
1782            let inner = crate::ws::subscribe(
1783                &self.ws_transport(),
1784                "/v2/commands/command-completions",
1785                request,
1786            )
1787            .await?;
1788            Ok(telemetry::instrument_stream(
1789                "ws_completions",
1790                TRANSPORT_JSON,
1791                crate::ws::filter_checkpoints(inner),
1792            ))
1793        })
1794        .await
1795    }
1796
1797    /// Like [`Self::ws_completions`], with the full request surface of a
1798    /// [`CompletionsRequest`](crate::request::CompletionsRequest) — including
1799    /// the submitting `user_id` to scope the stream to (the same builder the
1800    /// gRPC lane takes).
1801    ///
1802    /// # Errors
1803    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
1804    /// a participant error frame or a transport failure.
1805    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1806    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1807    pub async fn ws_completions_with(
1808        &self,
1809        request: &crate::request::CompletionsRequest,
1810    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1811        telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
1812            let inner = crate::ws::subscribe(
1813                &self.ws_transport(),
1814                "/v2/commands/command-completions",
1815                request.json_body(),
1816            )
1817            .await?;
1818            Ok(telemetry::instrument_stream(
1819                "ws_completions",
1820                TRANSPORT_JSON,
1821                crate::ws::filter_checkpoints(inner),
1822            ))
1823        })
1824        .await
1825    }
1826
1827    /// The reconnect policy for the WebSocket streams: `(max_reconnects,
1828    /// backoff_unit)`, taken from this client's
1829    /// [`RetryConfig`](canton_core::RetryConfig) when one is configured — the
1830    /// same derivation the gRPC client makes, so configuring retries once
1831    /// governs both lanes rather than only the unary one.
1832    #[cfg(feature = "ws")]
1833    fn reconnect_policy(&self) -> (u32, std::time::Duration) {
1834        match &self.retry {
1835            Some(retry) => (retry.max_attempts, retry.initial_backoff),
1836            None => (5, std::time::Duration::from_millis(250)),
1837        }
1838    }
1839
1840    /// Like [`Self::ws_active_contracts`], but **resumable**: on a retriable
1841    /// disconnect it resubscribes from the last continuation token the
1842    /// participant sent, rather than starting the snapshot again.
1843    ///
1844    /// An ACS snapshot has no offsets to resume from — it is a position in a
1845    /// stream of entries, which is what `streamContinuationToken` names. The
1846    /// gRPC lane has had a resumable ACS read since M1; this is its counterpart,
1847    /// and without it a WebSocket consumer of a large snapshot had to start over
1848    /// on any blip.
1849    ///
1850    /// The token is only valid against the same participant, the same
1851    /// `active_at_offset` and the same filters — all of which are fixed for the
1852    /// life of this stream — and while the snapshot's offset has not been
1853    /// pruned.
1854    #[cfg(feature = "ws")]
1855    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1856    pub fn ws_active_contracts_resumable(
1857        &self,
1858        parties: Vec<String>,
1859        active_at_offset: i64,
1860    ) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
1861        let (max_reconnects, backoff_unit) = self.reconnect_policy();
1862        let base_url = self.base_url.clone();
1863        let auth = self.auth.clone();
1864        let tls = self.tls.clone();
1865        let max_decoding_message_size = self.max_decoding_message_size;
1866        let timeout = self.timeout;
1867        async_stream::stream! {
1868            let mut token: Option<String> = None;
1869            let mut reconnects = 0u32;
1870            loop {
1871                let mut request = active_contracts_request(&parties, active_at_offset);
1872                if let Some(token) = &token {
1873                    request["streamContinuationToken"] = Value::String(token.clone());
1874                }
1875                let transport = crate::ws::WsTransport {
1876                    base_url: &base_url,
1877                    auth: &auth,
1878                    tls: tls.as_ref(),
1879                    max_decoding_message_size,
1880                    timeout,
1881                };
1882                // What made this reconnect necessary, carried out of the inner
1883                // loop for the reason the gRPC streams carry it: giving up must
1884                // report the participant's own failure, not an error of ours.
1885                let cause = match crate::ws::subscribe(&transport, "/v2/state/active-contracts", request).await {
1886                    Ok(inner) => {
1887                        // Each connection is instrumented for its own life, so a
1888                        // subscription that dies mid-snapshot is counted rather
1889                        // than disappearing into the reconnect loop.
1890                        let inner = telemetry::instrument_stream(
1891                            "ws_active_contracts",
1892                            TRANSPORT_JSON,
1893                            inner,
1894                        );
1895                        tokio::pin!(inner);
1896                        loop {
1897                            match inner.next().await {
1898                                Some(Ok(frame)) => {
1899                                    if let Some(next) = frame
1900                                        .get("streamContinuationToken")
1901                                        .and_then(Value::as_str)
1902                                        .filter(|next| !next.is_empty())
1903                                    {
1904                                        token = Some(next.to_string());
1905                                    }
1906                                    reconnects = 0;
1907                                    yield Ok(frame);
1908                                }
1909                                Some(Err(err)) if err.is_retriable() => break err,
1910                                Some(Err(err)) => {
1911                                    yield Err(err);
1912                                    return;
1913                                }
1914                                // A bounded read: the participant closes the
1915                                // socket when the snapshot is delivered, and
1916                                // there is no token left to resume from.
1917                                None => return,
1918                            }
1919                        }
1920                    }
1921                    Err(err) if err.is_retriable() => err,
1922                    Err(err) => {
1923                        yield Err(err);
1924                        return;
1925                    }
1926                };
1927
1928                reconnects += 1;
1929                if reconnects > max_reconnects {
1930                    tracing::warn!(
1931                        max_reconnects,
1932                        "ws acs stream gave up resuming; reporting the failure that caused it"
1933                    );
1934                    yield Err(cause);
1935                    return;
1936                }
1937                tokio::time::sleep(backoff_unit * reconnects).await;
1938            }
1939        }
1940    }
1941
1942    /// Like [`Self::ws_updates`] (unbounded tail), but **resumable**: on a
1943    /// retriable disconnect it reconnects from the last offset it observed
1944    /// (tracked via `OffsetCheckpoint` heartbeats and update offsets), with a
1945    /// short backoff and a bounded number of consecutive reconnects. Mirrors the
1946    /// gRPC [`CantonClient::updates_resumable`]. Checkpoints are consumed for
1947    /// position tracking and not yielded.
1948    ///
1949    /// [`CantonClient::updates_resumable`]: crate::CantonClient::updates_resumable
1950    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1951    pub fn ws_updates_resumable(
1952        &self,
1953        parties: Vec<String>,
1954        begin_exclusive: i64,
1955    ) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
1956        let (max_reconnects, backoff_unit) = self.reconnect_policy();
1957        let base_url = self.base_url.clone();
1958        let auth = self.auth.clone();
1959        let tls = self.tls.clone();
1960        let max_decoding_message_size = self.max_decoding_message_size;
1961        let timeout = self.timeout;
1962        async_stream::stream! {
1963            let mut offset = begin_exclusive;
1964            let mut reconnects = 0u32;
1965            loop {
1966                // Unbounded tail (no end): a close means the connection dropped.
1967                let request = updates_request(&parties, offset, None);
1968                let transport = crate::ws::WsTransport {
1969                    base_url: &base_url,
1970                    auth: &auth,
1971                    tls: tls.as_ref(),
1972                    max_decoding_message_size,
1973                    timeout,
1974                };
1975                // `Option`, unlike the other resumable streams: here a clean
1976                // WS close is also a reason to reconnect, and it carries no
1977                // failure to report. Everything else does.
1978                let cause = match crate::ws::subscribe(&transport, "/v2/updates", request).await {
1979                    Ok(inner) => {
1980                        let inner =
1981                            telemetry::instrument_stream("ws_updates", TRANSPORT_JSON, inner);
1982                        tokio::pin!(inner);
1983                        loop {
1984                            match inner.next().await {
1985                                Some(Ok(frame)) => {
1986                                    if let Some(o) = crate::ws::update_offset(&frame) {
1987                                        offset = o;
1988                                    }
1989                                    reconnects = 0;
1990                                    if !crate::ws::is_offset_checkpoint(&frame) {
1991                                        yield Ok(frame);
1992                                    }
1993                                }
1994                                Some(Err(err)) if err.is_retriable() => break Some(err),
1995                                Some(Err(err)) => {
1996                                    yield Err(err);
1997                                    return;
1998                                }
1999                                None => break None, // WS closed → reconnect from `offset`
2000                            }
2001                        }
2002                    }
2003                    Err(err) if err.is_retriable() => Some(err),
2004                    Err(err) => {
2005                        yield Err(err);
2006                        return;
2007                    }
2008                };
2009
2010                reconnects += 1;
2011                if reconnects > max_reconnects {
2012                    tracing::warn!(
2013                        max_reconnects,
2014                        offset,
2015                        "ws update stream gave up resuming; reporting the failure that caused it"
2016                    );
2017                    // No cause means the participant kept closing the socket
2018                    // cleanly and the stream never got anywhere — which is not
2019                    // a participant error, so it is described as what it is.
2020                    yield Err(cause.unwrap_or_else(|| Error::UnexpectedResponse(format!(
2021                        "ws update stream was closed and reopened {max_reconnects} times \
2022                         without delivering an update"
2023                    ))));
2024                    return;
2025                }
2026                tokio::time::sleep(backoff_unit * reconnects).await;
2027            }
2028        }
2029    }
2030}
2031
2032/// Classify a failure to *send* a JSON request.
2033///
2034/// This sits inside [`canton_core::retry::run_with_retry`], which loops while
2035/// `is_retriable()` — and `Error::Connection` always is. Reporting every
2036/// transport failure that way meant an unverifiable certificate, a malformed
2037/// URL and a redirect loop each ran the full retry schedule and then reported
2038/// `error sending request for url (…)`, with the word *certificate* left in the
2039/// source chain nobody read.
2040fn transport_error(path: &str, e: &reqwest::Error) -> Error {
2041    let detail = canton_core::chain(e);
2042    if e.is_timeout() {
2043        return Error::Timeout;
2044    }
2045    if e.is_builder() || e.is_redirect() {
2046        return Error::InvalidRequest(format!("the request to {path} could not be made: {detail}"));
2047    }
2048    if e.is_decode() {
2049        return Error::UnexpectedResponse(format!(
2050            "{path} returned a body this cannot read: {detail}"
2051        ));
2052    }
2053    // A TLS failure arrives as a connect error, indistinguishable by type from
2054    // a refused connection — reqwest exposes no typed TLS error — so the chain
2055    // is the only place it shows. Permanent, so not retriable.
2056    if detail.to_ascii_lowercase().contains("certificate") {
2057        return Error::InvalidRequest(format!(
2058            "the certificate presented for {path} could not be verified: {detail}"
2059        ));
2060    }
2061    Error::Connection(format!("json request to {path} failed: {detail}"))
2062}
2063
2064#[cfg(test)]
2065#[allow(clippy::unwrap_used)]
2066mod tests {
2067    use super::*;
2068
2069    #[test]
2070    fn updates_request_matches_grpc_and_includes_reassignments() {
2071        let parties = vec!["alice::1".to_string()];
2072        let body = updates_request(&parties, 10, Some(20));
2073        assert_eq!(body["beginExclusive"], 10);
2074        assert_eq!(body["endInclusive"], 20);
2075        let fmt = &body["updateFormat"];
2076        // Both sub-formats present — same event set as the gRPC lane, which sets
2077        // include_transactions AND include_reassignments.
2078        assert!(fmt["includeTransactions"].is_object(), "{body}");
2079        assert!(
2080            fmt["includeReassignments"].is_object(),
2081            "reassignments must be requested, or the JSON lane drops them: {body}"
2082        );
2083        assert_eq!(
2084            fmt["includeTransactions"]["transactionShape"],
2085            "TRANSACTION_SHAPE_LEDGER_EFFECTS"
2086        );
2087    }
2088
2089    #[test]
2090    fn commands_serialize_to_the_json_api_shape() {
2091        let commands = JsonCommands::new(vec!["alice::1".to_string()])
2092            .with_command_id("cmd-1")
2093            .add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }));
2094        let value = serde_json::to_value(&commands).unwrap();
2095
2096        assert_eq!(value["commandId"], "cmd-1");
2097        assert_eq!(value["actAs"][0], "alice::1");
2098        // Tagged CreateCommand with a Daml-LF-JSON record argument.
2099        assert_eq!(
2100            value["commands"][0]["CreateCommand"]["templateId"],
2101            "pkg:Mod:Ent"
2102        );
2103        assert_eq!(
2104            value["commands"][0]["CreateCommand"]["createArguments"]["owner"],
2105            "alice::1"
2106        );
2107        // Optional fields are omitted, not null.
2108        assert!(value.get("userId").is_none());
2109        assert!(value.get("readAs").is_none());
2110    }
2111
2112    #[test]
2113    fn all_command_options_serialize_to_camel_case() {
2114        let commands = JsonCommands::new(vec!["alice::1".to_string()])
2115            .with_command_id("cmd-1")
2116            .with_user_id("user-1")
2117            .with_read_as(vec!["bob::2".to_string()])
2118            .with_workflow_id("wf-1")
2119            .with_synchronizer_id("sync-1")
2120            .with_submission_id("sub-1")
2121            .add_disclosed_contract(json!({ "contractId": "c9", "createdEventBlob": "AQI=" }))
2122            .with_package_id_selection_preference(vec!["pkg-9".to_string()])
2123            .with_deduplication_period(
2124                json!({ "DeduplicationDuration": { "value": { "duration": "30s" } } }),
2125            )
2126            .with_min_ledger_time_rel(json!("5s"))
2127            .add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }))
2128            .add_command(json!({ "ExerciseCommand": { "contractId": "c1" } }));
2129        let value = serde_json::to_value(&commands).unwrap();
2130
2131        assert_eq!(value["userId"], "user-1");
2132        assert_eq!(value["readAs"][0], "bob::2");
2133        assert_eq!(value["workflowId"], "wf-1");
2134        assert_eq!(value["synchronizerId"], "sync-1");
2135        assert_eq!(value["submissionId"], "sub-1");
2136        assert_eq!(value["disclosedContracts"][0]["contractId"], "c9");
2137        assert_eq!(value["packageIdSelectionPreference"][0], "pkg-9");
2138        assert_eq!(
2139            value["deduplicationPeriod"]["DeduplicationDuration"]["value"]["duration"],
2140            "30s"
2141        );
2142        assert_eq!(value["minLedgerTimeRel"], "5s");
2143        assert!(value.get("minLedgerTimeAbs").is_none());
2144        // Both the convenience create and the raw command are present, in order.
2145        assert!(value["commands"][0]["CreateCommand"].is_object());
2146        assert_eq!(value["commands"][1]["ExerciseCommand"]["contractId"], "c1");
2147    }
2148
2149    #[test]
2150    fn wildcard_event_format_filters_each_party() {
2151        let format = &active_contracts_request(&["alice::1".to_string(), "bob::2".to_string()], 0)
2152            ["eventFormat"];
2153        assert_eq!(format["verbose"], true);
2154        assert!(format["filtersByParty"]["alice::1"]["cumulative"][0]["identifierFilter"]
2155            ["WildcardFilter"]
2156            .is_object());
2157        assert!(format["filtersByParty"]["bob::2"].is_object());
2158    }
2159
2160    #[test]
2161    fn with_limit_appends_only_when_set() {
2162        assert_eq!(with_limit("/v2/updates", None), "/v2/updates");
2163        assert_eq!(with_limit("/v2/updates", Some(5)), "/v2/updates?limit=5");
2164    }
2165
2166    /// A participant that accepts the connection and then says nothing is the
2167    /// case no status code covers, and `reqwest` waits for it forever unless
2168    /// told not to. The gRPC lane has bounded this at 30s since M1; this one
2169    /// had no bound and no knob.
2170    ///
2171    /// The listener here accepts and never answers, which is the only way to
2172    /// tell a timeout that works from a request that happened to be fast.
2173    #[tokio::test]
2174    async fn a_request_to_a_silent_participant_gives_up_instead_of_hanging() {
2175        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2176        let addr = listener.local_addr().unwrap();
2177        // Hold every accepted connection open, answering nothing.
2178        tokio::spawn(async move {
2179            let mut held = Vec::new();
2180            while let Ok((socket, _)) = listener.accept().await {
2181                held.push(socket);
2182            }
2183        });
2184
2185        let client = JsonClient::new(format!("http://{addr}"))
2186            .with_timeout(std::time::Duration::from_millis(250));
2187
2188        // The outer bound is what makes this a test rather than a hang: with no
2189        // per-request timeout the call never returns at all, and a CI job that
2190        // stops responding says less than one that fails.
2191        let outcome =
2192            tokio::time::timeout(std::time::Duration::from_secs(5), client.version()).await;
2193        let Ok(result) = outcome else {
2194            panic!("the request never returned — the per-request timeout is not being applied");
2195        };
2196
2197        let error = result.unwrap_err();
2198        assert!(
2199            error.is_retriable(),
2200            "a timeout is transient and should be retriable: {error}"
2201        );
2202
2203        // And the default is a real bound, not `None` dressed up as one.
2204        assert_eq!(
2205            JsonClient::new("http://localhost:3975").timeout,
2206            DEFAULT_TIMEOUT
2207        );
2208    }
2209
2210    /// A client is the thing a caller is most likely to put in a `tracing`
2211    /// field or a `{:?}`, and its base URL is the one place a credential can
2212    /// hide in plain sight. `Config` has redacted its endpoint since the
2213    /// mutual-TLS fix; this type held the same secret behind a derived `Debug`.
2214    #[test]
2215    fn debug_does_not_print_credentials_carried_in_the_base_url() {
2216        let secret = "s3cr3t-p@ssw0rd";
2217        let client = JsonClient::new(format!("https://svc-account:{secret}@ledger.example:3975"))
2218            .with_token("eyJhbGciOiJSUzI1NiJ9.PAYLOAD.SIG");
2219        let rendered = format!("{client:?}");
2220
2221        assert!(
2222            !rendered.contains(secret),
2223            "leaked the password: {rendered}"
2224        );
2225        assert!(
2226            !rendered.contains("svc-account"),
2227            "leaked the user: {rendered}"
2228        );
2229        assert!(
2230            !rendered.contains("PAYLOAD"),
2231            "leaked the token: {rendered}"
2232        );
2233        // Still useful: the host has to survive, or the output tells a reader
2234        // nothing about which participant this client talks to.
2235        assert!(
2236            rendered.contains("ledger.example:3975"),
2237            "should keep the host: {rendered}"
2238        );
2239    }
2240
2241    /// The WS lane must not silently inherit `tungstenite`'s ceiling: 64 MiB
2242    /// per message and 16 MiB per *frame*, neither of them anything the caller
2243    /// asked for. A JSON client carries the same limit the gRPC one does, and
2244    /// raising it is the caller's to do.
2245    #[test]
2246    fn the_ws_lane_starts_at_the_sdk_size_limit_not_tungstenites() {
2247        let client = JsonClient::new("http://localhost:3975");
2248        assert_eq!(
2249            client.max_decoding_message_size,
2250            canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
2251        );
2252        // Well clear of both tungstenite defaults, which is the whole point.
2253        assert!(client.max_decoding_message_size > 64 << 20);
2254
2255        let raised = client.with_max_decoding_message_size(256 << 20);
2256        assert_eq!(raised.max_decoding_message_size, 256 << 20);
2257    }
2258
2259    #[test]
2260    fn command_id_defaults_to_a_generated_uuid() {
2261        let commands = JsonCommands::new(vec!["alice::1".to_string()]);
2262        let value = serde_json::to_value(&commands).unwrap();
2263        let id = value["commandId"].as_str().unwrap();
2264        assert!(id.starts_with("sdk-"), "got {id}");
2265        assert!(id.len() > 10, "expected a uuid suffix, got {id}");
2266    }
2267
2268    #[test]
2269    fn with_tls_threads_a_ca_and_client_identity() {
2270        let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
2271        let cert_pem = ck.cert.pem().into_bytes();
2272        let key_pem = ck.key_pair.serialize_pem().into_bytes();
2273
2274        // A valid CA + client identity (mTLS) builds an HTTPS client.
2275        let tls = canton_core::TlsConfig::new()
2276            .with_ca_certificate(cert_pem.clone())
2277            .with_client_identity(cert_pem, key_pem);
2278        assert!(
2279            JsonClient::new("https://localhost:3975")
2280                .with_token("t")
2281                .with_tls(&tls)
2282                .is_ok()
2283        );
2284
2285        // A malformed client-identity PEM is rejected as an InvalidRequest.
2286        let bad = canton_core::TlsConfig::new()
2287            .with_client_identity(b"not a pem".to_vec(), b"nor this".to_vec());
2288        assert!(matches!(
2289            JsonClient::new("https://localhost:3975").with_tls(&bad),
2290            Err(Error::InvalidRequest(_))
2291        ));
2292    }
2293
2294    #[test]
2295    fn with_tls_upgrades_an_http_base_url_to_https() {
2296        // The security bug: `with_tls` on an http:// base URL would otherwise
2297        // send plaintext HTTP (certs unused) and open a ws:// socket. The scheme
2298        // must become https so both the reqwest and WebSocket lanes use TLS.
2299        let client = JsonClient::new("http://localhost:3975")
2300            .with_tls(&canton_core::TlsConfig::new())
2301            .unwrap();
2302        assert_eq!(client.base_url, "https://localhost:3975");
2303
2304        // Already-https is left untouched, and the check is case-insensitive.
2305        assert_eq!(
2306            upgrade_base_url_for_tls("https://host:443"),
2307            "https://host:443"
2308        );
2309        assert_eq!(
2310            upgrade_base_url_for_tls("HTTP://host:80"),
2311            "https://host:80"
2312        );
2313        // Without TLS, plain http is preserved (no normalisation on `new`).
2314        assert_eq!(
2315            JsonClient::new("http://localhost:3975").base_url,
2316            "http://localhost:3975"
2317        );
2318    }
2319}