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**, and read the **active
5//! contract set** and **updates** as bounded JSON arrays. It shares the SDK
6//! error model and the same [`Auth`] as the gRPC client.
7//!
8//! Values use the Daml-LF JSON encoding: a record is a JSON object keyed by
9//! field name, a party is a string, a `TextMap` is a JSON object. Reads return
10//! `serde_json::Value` (the M1 dynamic path); typed bindings land in M2.
11//!
12//! The blocking read endpoints are capped by the node's
13//! `http-list-max-elements-limit` and return `413` past it — pass a `limit` (or
14//! a bounded offset range). WebSocket streaming for unbounded tails is a
15//! separate transport.
16
17use std::sync::Arc;
18
19use canton_auth::TokenProvider;
20use canton_core::telemetry::{self, TRANSPORT_JSON};
21use canton_core::{Auth, Error, Result};
22use serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24
25/// A client for the Canton **JSON** Ledger API over HTTP.
26#[derive(Clone)]
27pub struct JsonClient {
28    base_url: String,
29    http: reqwest::Client,
30    auth: Auth,
31    /// Kept for the WebSocket handshake (feature `ws`); the HTTP client bakes
32    /// its TLS settings into `http` at `with_tls` time.
33    tls: Option<canton_core::TlsConfig>,
34    retry: Option<canton_core::RetryConfig>,
35    /// The largest WebSocket frame this client will accept, in bytes. Only the
36    /// WS lane has a ceiling to raise: `reqwest` puts no limit on an HTTP
37    /// response body, so the `POST` lane reads whatever the participant sends.
38    max_decoding_message_size: usize,
39    /// How long one HTTP attempt may take. Applied per request rather than on
40    /// the `reqwest` client, so it holds however the client was built and
41    /// whatever order the builders were called in.
42    timeout: std::time::Duration,
43}
44
45/// How long one JSON request may take before it is abandoned.
46///
47/// The same 30 seconds the gRPC channel uses, and for the same reason: without
48/// it there is no bound at all. `reqwest` applies no timeout unless asked, so a
49/// participant that accepts the connection and then stops answering holds the
50/// caller's task open for as long as the process runs.
51const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
52
53/// Hand-written for the same reason [`Config`]'s is: a base URL can carry
54/// credentials in its userinfo (`https://user:secret@host`), and the derived
55/// `Debug` printed them verbatim — one `tracing` field holding a client, or one
56/// `{:?}` in a log line, was enough.
57///
58/// The `reqwest` client is left out entirely — `finish_non_exhaustive` says so
59/// rather than pretending otherwise — because its internals describe a
60/// connection pool, not this client.
61///
62/// [`Config`]: canton_core::Config
63impl std::fmt::Debug for JsonClient {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("JsonClient")
66            .field("base_url", &canton_core::redact_url(&self.base_url))
67            .field("auth", &self.auth)
68            .field("tls", &self.tls)
69            .field("retry", &self.retry)
70            .field("max_decoding_message_size", &self.max_decoding_message_size)
71            .finish_non_exhaustive()
72    }
73}
74
75#[derive(Deserialize)]
76struct VersionResponse {
77    version: String,
78}
79
80#[derive(Deserialize)]
81struct LedgerEndResponse {
82    offset: i64,
83}
84
85/// A set of commands to submit over the JSON transport (dynamic path).
86///
87/// Build with [`JsonCommands::new`] then add commands ([`JsonCommands::add_create`]
88/// or [`JsonCommands::add_command`]) and optional metadata. `command_id`
89/// defaults to a fresh UUID so ledger-side de-duplication behaves correctly.
90#[derive(Debug, Clone, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct JsonCommands {
93    command_id: String,
94    act_as: Vec<String>,
95    commands: Vec<Value>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    user_id: Option<String>,
98    #[serde(skip_serializing_if = "Vec::is_empty")]
99    read_as: Vec<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    workflow_id: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    synchronizer_id: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    submission_id: Option<String>,
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    disclosed_contracts: Vec<Value>,
108    #[serde(skip_serializing_if = "Vec::is_empty")]
109    package_id_selection_preference: Vec<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    deduplication_period: Option<Value>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    min_ledger_time_abs: Option<Value>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    min_ledger_time_rel: Option<Value>,
116}
117
118impl JsonCommands {
119    /// Start a command set acting as `act_as`, with a generated `command_id`
120    /// and no commands yet.
121    #[must_use]
122    pub fn new(act_as: Vec<String>) -> Self {
123        Self {
124            command_id: format!("sdk-{}", uuid::Uuid::new_v4()),
125            act_as,
126            commands: Vec::new(),
127            user_id: None,
128            read_as: Vec::new(),
129            workflow_id: None,
130            synchronizer_id: None,
131            submission_id: None,
132            disclosed_contracts: Vec::new(),
133            package_id_selection_preference: Vec::new(),
134            deduplication_period: None,
135            min_ledger_time_abs: None,
136            min_ledger_time_rel: None,
137        }
138    }
139
140    /// Set an explicit change-ID `command_id` (for exactly-once / de-duplication).
141    #[must_use]
142    pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
143        self.command_id = command_id.into();
144        self
145    }
146
147    /// This command set's complete identity — available before it is sent, and
148    /// the only way back to the outcome of a submission whose result was lost.
149    #[must_use]
150    pub fn change_id(&self) -> crate::ChangeId {
151        crate::ChangeId::new(
152            self.user_id.clone().unwrap_or_default(),
153            self.act_as.clone(),
154            self.command_id.clone(),
155        )
156    }
157
158    /// Set the acting user id (defaults to the one derived from the token).
159    #[must_use]
160    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
161        self.user_id = Some(user_id.into());
162        self
163    }
164
165    /// Add read-as parties.
166    #[must_use]
167    pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
168        self.read_as = read_as;
169        self
170    }
171
172    /// Set the workflow id.
173    #[must_use]
174    pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
175        self.workflow_id = Some(workflow_id.into());
176        self
177    }
178
179    /// Pin the submission to a specific synchronizer.
180    #[must_use]
181    pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
182        self.synchronizer_id = Some(synchronizer_id.into());
183        self
184    }
185
186    /// Add a `CreateCommand` for `template_id` (`"<pkg>:<Module>:<Entity>"`) with
187    /// `create_arguments` in Daml-LF JSON (a record is an object keyed by field).
188    #[must_use]
189    pub fn add_create(mut self, template_id: impl Into<String>, create_arguments: Value) -> Self {
190        // Build the object directly (rather than `json!`) so `create_arguments`
191        // is moved in, not cloned.
192        let mut create = serde_json::Map::new();
193        create.insert("templateId".to_string(), Value::String(template_id.into()));
194        create.insert("createArguments".to_string(), create_arguments);
195        let mut command = serde_json::Map::new();
196        command.insert("CreateCommand".to_string(), Value::Object(create));
197        self.commands.push(Value::Object(command));
198        self
199    }
200
201    /// Add a raw command value (e.g. an `ExerciseCommand`), for shapes the
202    /// convenience builders don't cover.
203    #[must_use]
204    pub fn add_command(mut self, command: Value) -> Self {
205        self.commands.push(command);
206        self
207    }
208
209    /// Set an explicit submission id, to correlate this particular submission
210    /// attempt in completions. Defaults to participant-generated.
211    #[must_use]
212    pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
213        self.submission_id = Some(submission_id.into());
214        self
215    }
216
217    /// Attach a disclosed contract (raw JSON: `{"templateId": …,
218    /// "contractId": …, "createdEventBlob": …, "synchronizerId": …}`, with the
219    /// blob obtained from a read with created-event blobs enabled). May be
220    /// called repeatedly.
221    #[must_use]
222    pub fn add_disclosed_contract(mut self, contract: Value) -> Self {
223        self.disclosed_contracts.push(contract);
224        self
225    }
226
227    /// Restrict package selection for interpretation to these package ids
228    /// (at most one preference per package name) — the SCU upgrade pin.
229    #[must_use]
230    pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
231        self.package_id_selection_preference = package_ids;
232        self
233    }
234
235    /// Set the de-duplication period (raw JSON, e.g.
236    /// `{"DeduplicationDuration": {"value": {"duration": "5s"}}}`), matching
237    /// the JSON API's `deduplicationPeriod` encoding.
238    #[must_use]
239    pub fn with_deduplication_period(mut self, period: Value) -> Self {
240        self.deduplication_period = Some(period);
241        self
242    }
243
244    /// Set the absolute lower bound for the ledger-effective time (raw JSON,
245    /// an ISO-8601 timestamp string). Mutually exclusive with
246    /// [`Self::with_min_ledger_time_rel`].
247    #[must_use]
248    pub fn with_min_ledger_time_abs(mut self, time: Value) -> Self {
249        self.min_ledger_time_abs = Some(time);
250        self
251    }
252
253    /// Set the relative lower bound for the ledger-effective time (raw JSON,
254    /// a proto duration like `"5s"`). Mutually exclusive with
255    /// [`Self::with_min_ledger_time_abs`].
256    #[must_use]
257    pub fn with_min_ledger_time_rel(mut self, duration: Value) -> Self {
258        self.min_ledger_time_rel = Some(duration);
259        self
260    }
261}
262
263/// The response to a successful `submit-and-wait` — the lighter of the two
264/// waiting submissions, which reports where the command landed without
265/// returning the transaction itself.
266#[derive(Debug, Clone, Deserialize)]
267#[serde(rename_all = "camelCase")]
268#[non_exhaustive]
269pub struct JsonSubmitAndWaitResponse {
270    /// The id of the transaction the command produced.
271    pub update_id: String,
272    /// The offset of the completion.
273    pub completion_offset: i64,
274}
275
276/// The response to a successful `submit-and-wait-for-transaction`.
277#[derive(Debug, Clone, Deserialize)]
278#[non_exhaustive]
279pub struct JsonSubmitResponse {
280    /// The committed transaction.
281    pub transaction: JsonTransaction,
282}
283
284/// A committed transaction from the JSON transport. Top-level fields are typed;
285/// `events` stay as raw JSON (the M1 dynamic path).
286#[derive(Debug, Clone, Deserialize)]
287#[serde(rename_all = "camelCase")]
288#[non_exhaustive]
289pub struct JsonTransaction {
290    /// The update id (globally unique).
291    pub update_id: String,
292    /// The submitter-provided command id (empty if not echoed).
293    #[serde(default)]
294    pub command_id: String,
295    /// The workflow id (empty if unset).
296    #[serde(default)]
297    pub workflow_id: String,
298    /// The ledger offset at which this transaction was committed.
299    pub offset: i64,
300    /// The synchronizer that sequenced the transaction.
301    #[serde(default)]
302    pub synchronizer_id: String,
303    /// Ledger-effective time (ISO-8601).
304    #[serde(default)]
305    pub effective_at: String,
306    /// Record time (ISO-8601).
307    #[serde(default)]
308    pub record_time: String,
309    /// The events, each a tagged object (`{"CreatedEvent": …}` / `{"ArchivedEvent": …}`).
310    #[serde(default)]
311    pub events: Vec<Value>,
312}
313
314/// The request body for an ACS snapshot at `active_at_offset` (POST and WS).
315/// Built through [`crate::request::ActiveContractsRequest`], so the plain and
316/// builder-driven methods share one body producer.
317fn active_contracts_request(parties: &[String], active_at_offset: i64) -> Value {
318    crate::request::ActiveContractsRequest::new(parties.to_vec(), active_at_offset).json_body()
319}
320
321/// The request body for updates over `(begin_exclusive, end_inclusive]` (POST
322/// and WS); omit `end_inclusive` for an unbounded tail. Built through
323/// [`crate::request::UpdatesRequest`] — `LEDGER_EFFECTS`, wildcard filters,
324/// reassignments included — the same defaults as the gRPC lane, so both
325/// transports yield the same event set for the same query.
326fn updates_request(parties: &[String], begin_exclusive: i64, end_inclusive: Option<i64>) -> Value {
327    let mut request = crate::request::UpdatesRequest::new(parties.to_vec(), begin_exclusive);
328    if let Some(end) = end_inclusive {
329        request = request.until(end);
330    }
331    request.json_body()
332}
333
334/// The request body for command completions from `begin_exclusive` (WS).
335#[cfg(feature = "ws")]
336fn completions_request(parties: &[String], begin_exclusive: i64) -> Value {
337    crate::request::CompletionsRequest::new(parties.to_vec(), begin_exclusive).json_body()
338}
339
340/// Whether a failed submission is the participant refusing a command it already
341/// has. Canton's JSON lane maps `ALREADY_EXISTS` to HTTP 409, and names the
342/// error in the body — either signal is enough, and a body that names
343/// `DUPLICATE_COMMAND` under some other status still means the same thing.
344fn is_duplicate_submission(error: &Error) -> bool {
345    match error {
346        Error::Http { status, body } => *status == 409 || body.contains("DUPLICATE_COMMAND"),
347        _ => false,
348    }
349}
350
351/// Add W3C trace-context headers to an outgoing request (a no-op without the
352/// `otel` feature, or when no OpenTelemetry context is active).
353fn with_trace_context(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
354    #[cfg(feature = "otel")]
355    {
356        let mut headers = reqwest::header::HeaderMap::new();
357        canton_core::telemetry::otel::inject_trace_context(&mut headers);
358        if !headers.is_empty() {
359            return request.headers(headers);
360        }
361    }
362    request
363}
364
365/// Validate an HTTP response and deserialize its JSON body.
366async fn read_json<T: for<'de> Deserialize<'de>>(
367    response: reqwest::Response,
368    path: &str,
369) -> Result<T> {
370    // Non-2xx carries its status (e.g. `413` past the node's list cap, `401`
371    // for a bad token), so callers can branch and retry 5xx/429.
372    if !response.status().is_success() {
373        let status = response.status().as_u16();
374        let body = response.text().await.unwrap_or_default();
375        return Err(Error::Http { status, body });
376    }
377    let body = response
378        .text()
379        .await
380        .map_err(|e| Error::Connection(format!("reading json body from {path} failed: {e}")))?;
381    // A malformed body is a deserialization error (Error::Json), not a bad request.
382    serde_json::from_str::<T>(&body).map_err(Error::from)
383}
384
385/// Upgrade an `http://` base URL to `https://` when TLS is configured.
386///
387/// Both JSON lanes select TLS by URL scheme: `reqwest` for HTTP, and
388/// [`ws_url`](crate::ws) (`http`→`ws`, `https`→`wss`) for WebSocket. So a
389/// `JsonClient` given an `http://` base URL together with `with_tls` would send
390/// plaintext HTTP with the certificates unused and open a `ws://` socket. This
391/// normalises the scheme so TLS is actually applied, matching the gRPC channel
392/// builder. Detection is case-insensitive; anything not `http://` (already
393/// `https://`, or another scheme) is left unchanged.
394fn upgrade_base_url_for_tls(base_url: &str) -> String {
395    if base_url
396        .get(..7)
397        .is_some_and(|s| s.eq_ignore_ascii_case("http://"))
398    {
399        format!("https://{}", &base_url[7..])
400    } else {
401        base_url.to_string()
402    }
403}
404
405impl JsonClient {
406    /// Create a JSON client for `base_url` (e.g. `http://localhost:3975`), with
407    /// no authentication. A trailing slash on `base_url` is tolerated.
408    #[must_use]
409    pub fn new(base_url: impl Into<String>) -> Self {
410        let mut base_url = base_url.into();
411        while base_url.ends_with('/') {
412            base_url.pop();
413        }
414        Self {
415            base_url,
416            http: reqwest::Client::new(),
417            auth: Auth::None,
418            tls: None,
419            retry: None,
420            max_decoding_message_size: canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
421            timeout: DEFAULT_TIMEOUT,
422        }
423    }
424
425    /// Build a client from a local development network exported into the
426    /// environment — the JSON counterpart to [`Config::from_env`].
427    ///
428    /// ```text
429    /// eval "$(canton-devkit localnet env demo)"
430    /// ```
431    ///
432    /// reads `CANTON_JSON_LEDGER_API_URL` and the default participant's JWT.
433    /// See [`canton_core::localnet`] for the full contract.
434    ///
435    /// # Errors
436    /// Returns [`Error::InvalidRequest`] when no JSON endpoint is exported,
437    /// naming the variable and the command that produces it. A missing token is
438    /// not an error: an unauthenticated LocalNet is a normal target.
439    ///
440    /// [`Config::from_env`]: canton_core::Config::from_env
441    pub fn from_env() -> Result<Self> {
442        Self::for_role(None)
443    }
444
445    /// The same, for a participant other than the default: `"app-user"`,
446    /// `"sv"`, or any role the exporter knows.
447    ///
448    /// # Errors
449    /// As [`Self::from_env`], naming that role's variable.
450    pub fn from_env_for(role: &str) -> Result<Self> {
451        Self::for_role(Some(role))
452    }
453
454    fn for_role(role: Option<&str>) -> Result<Self> {
455        use canton_core::localnet;
456
457        let base_url = localnet::json_endpoint(role).ok_or_else(|| {
458            let variable = match role {
459                None => "CANTON_JSON_LEDGER_API_URL".to_string(),
460                Some(role) => format!(
461                    "CANTON_{}_JSON_LEDGER_API_URL",
462                    role.to_uppercase().replace('-', "_")
463                ),
464            };
465            Error::InvalidRequest(format!(
466                "no JSON ledger endpoint in the environment: set {variable}. \
467                 A local network exports it with `canton-devkit localnet env <instance>`; \
468                 run that through `eval` first."
469            ))
470        })?;
471        let client = Self::new(base_url);
472        Ok(match localnet::token(role) {
473            Some(token) => client.with_token(token),
474            None => client,
475        })
476    }
477
478    /// How long one HTTP attempt may take (default 30s), the JSON lane's
479    /// counterpart to [`Config::with_timeout`].
480    ///
481    /// `reqwest` imposes no timeout of its own, so without this a request to a
482    /// participant that accepts the connection and then goes quiet never
483    /// returns. Applied per attempt: under [`Self::with_retry`] each try gets
484    /// the full budget, matching how the gRPC channel's timeout composes with
485    /// its retries.
486    ///
487    /// The clock covers the whole exchange — connecting, sending, and reading
488    /// the response body — so it is a real bound rather than a bound on the
489    /// first byte.
490    ///
491    /// [`Config::with_timeout`]: canton_core::Config::with_timeout
492    #[must_use]
493    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
494        self.timeout = timeout;
495        self
496    }
497
498    /// The largest WebSocket message this client will accept, in bytes —
499    /// the JSON lane's counterpart to [`Config::with_max_decoding_message_size`],
500    /// and the same default.
501    ///
502    /// A WS stream carries the same payloads the gRPC one does, so it needs the
503    /// same ceiling. Left alone, `tungstenite` applies its own: 64 MiB per
504    /// message and **16 MiB per frame**, the second of which is the one a large
505    /// update meets first. Both are set from this value, so a frame is capped
506    /// only by what the caller asked for.
507    ///
508    /// Does not affect the HTTP (`POST`) lane, which has no ceiling to raise.
509    ///
510    /// [`Config::with_max_decoding_message_size`]: canton_core::Config::with_max_decoding_message_size
511    #[must_use]
512    pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
513        self.max_decoding_message_size = bytes;
514        self
515    }
516
517    /// Retry requests on retriable errors (category-first classification of
518    /// the participant's error body, transient HTTP statuses, connection
519    /// failures) with exponential backoff, honouring a server-recommended
520    /// retry delay — the same policy as the gRPC client's unary retries.
521    /// Off by default. Safe for command submission too: the command id in
522    /// the body stays fixed across attempts, so the participant de-duplicates.
523    /// Streaming (the WS lane) resumes via its own reconnect policy instead.
524    #[must_use]
525    pub fn with_retry(mut self, retry: canton_core::RetryConfig) -> Self {
526        self.retry = Some(retry);
527        self
528    }
529
530    /// Use TLS for the HTTP connection: a custom CA (server-side TLS against a
531    /// private/self-signed server) and/or a client identity (mutual TLS). This
532    /// is a terminal builder step — call it last, after [`Self::with_token`] /
533    /// [`Self::with_oidc`].
534    ///
535    /// An `http://` base URL is normalised to `https://` so TLS is never
536    /// silently downgraded: `reqwest` selects TLS from the URL scheme (not from
537    /// the configured certificates), and the WebSocket lane maps `http`→`ws` /
538    /// `https`→`wss` the same way, so an `http://` base URL with `with_tls`
539    /// would otherwise send plaintext HTTP and open a `ws://` socket with the
540    /// certificates unused. Detection is case-insensitive, mirroring the gRPC
541    /// channel builder (`canton-core`'s `resolve_endpoint`).
542    ///
543    /// `TlsConfig::domain_name` is not applied here: `reqwest` derives SNI
544    /// from the request URL (it is a gRPC/`tonic` knob).
545    ///
546    /// # Errors
547    /// Returns [`Error::InvalidRequest`] if a certificate/identity PEM is
548    /// invalid or the HTTPS client cannot be built.
549    pub fn with_tls(mut self, tls: &canton_core::TlsConfig) -> Result<Self> {
550        let mut builder = reqwest::Client::builder();
551        if let Some(ca) = &tls.ca_certificate_pem {
552            let cert = reqwest::Certificate::from_pem(ca)
553                .map_err(|e| Error::InvalidRequest(format!("invalid CA certificate: {e}")))?;
554            builder = builder.add_root_certificate(cert);
555        }
556        if let Some((cert, key)) = &tls.client_identity_pem {
557            // reqwest/rustls expects one PEM blob: certificate chain then key.
558            let mut pem = cert.clone();
559            pem.push(b'\n');
560            pem.extend_from_slice(key);
561            let identity = reqwest::Identity::from_pem(&pem)
562                .map_err(|e| Error::InvalidRequest(format!("invalid client identity: {e}")))?;
563            builder = builder.identity(identity);
564        }
565        self.http = builder
566            .build()
567            .map_err(|e| Error::InvalidRequest(format!("building the HTTPS client failed: {e}")))?;
568        self.base_url = upgrade_base_url_for_tls(&self.base_url);
569        self.tls = Some(tls.clone());
570        Ok(self)
571    }
572
573    /// Authenticate with a fixed bearer token.
574    #[must_use]
575    pub fn with_token(mut self, token: impl Into<String>) -> Self {
576        self.auth = Auth::Static(token.into());
577        self
578    }
579
580    /// Authenticate with an OIDC token provider (client-credentials, auto-refresh).
581    #[must_use]
582    pub fn with_oidc(mut self, provider: TokenProvider) -> Self {
583        self.auth = Auth::Dynamic(Arc::new(provider));
584        self
585    }
586
587    /// The WS lane's view of this client. One place, so a setting added to the
588    /// transport reaches every subscription rather than the ones someone
589    /// remembered.
590    #[cfg(feature = "ws")]
591    fn ws_transport(&self) -> crate::ws::WsTransport<'_> {
592        crate::ws::WsTransport {
593            base_url: &self.base_url,
594            auth: &self.auth,
595            tls: self.tls.as_ref(),
596            max_decoding_message_size: self.max_decoding_message_size,
597            timeout: self.timeout,
598        }
599    }
600
601    async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T> {
602        canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
603            let mut request = self
604                .http
605                .get(format!("{}{path}", self.base_url))
606                .timeout(self.timeout);
607            if let Some(token) = self.auth.bearer().await? {
608                request = request.bearer_auth(token);
609            }
610            request = with_trace_context(request);
611            let response = request
612                .send()
613                .await
614                .map_err(|e| Error::Connection(format!("json request to {path} failed: {e}")))?;
615            read_json(response, path).await
616        })
617        .await
618    }
619
620    async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
621        &self,
622        path: &str,
623        body: &B,
624    ) -> Result<T> {
625        canton_core::retry::run_with_retry(self.retry.as_ref(), || self.post_once(path, body)).await
626    }
627
628    /// One POST, no retry. The retrying wrappers are built on this so that a
629    /// submission can tell its own retries apart from its first attempt.
630    async fn post_once<B: Serialize, T: for<'de> Deserialize<'de>>(
631        &self,
632        path: &str,
633        body: &B,
634    ) -> Result<T> {
635        let mut request = self
636            .http
637            .post(format!("{}{path}", self.base_url))
638            .timeout(self.timeout)
639            .json(body);
640        if let Some(token) = self.auth.bearer().await? {
641            request = request.bearer_auth(token);
642        }
643        request = with_trace_context(request);
644        let response = request
645            .send()
646            .await
647            .map_err(|e| Error::Connection(format!("json request to {path} failed: {e}")))?;
648        read_json(response, path).await
649    }
650
651    /// The participant's Ledger API version (`GET /v2/version`, unauthenticated).
652    ///
653    /// # Errors
654    /// Returns an [`Error`] if the request fails or the response is malformed.
655    pub async fn version(&self) -> Result<String> {
656        telemetry::instrument("version", TRANSPORT_JSON, async {
657            Ok(self.get::<VersionResponse>("/v2/version").await?.version)
658        })
659        .await
660    }
661
662    /// The current ledger end offset (`GET /v2/state/ledger-end`, authenticated).
663    ///
664    /// # Errors
665    /// Returns an [`Error`] if authentication or the request fails.
666    pub async fn ledger_end(&self) -> Result<i64> {
667        telemetry::instrument("ledger_end", TRANSPORT_JSON, async {
668            Ok(self
669                .get::<LedgerEndResponse>("/v2/state/ledger-end")
670                .await?
671                .offset)
672        })
673        .await
674    }
675
676    /// Submit commands and wait for the resulting transaction
677    /// (`POST /v2/commands/submit-and-wait-for-transaction`).
678    ///
679    /// # Errors
680    /// Returns an [`Error`] if authentication fails, the command is rejected
681    /// (surfaced as [`Error::Http`] carrying the participant's error body), or
682    /// the response is malformed.
683    pub async fn submit_and_wait_for_transaction(
684        &self,
685        commands: &JsonCommands,
686    ) -> Result<JsonSubmitResponse> {
687        telemetry::instrument("submit_and_wait_for_transaction", TRANSPORT_JSON, async {
688            let body = json!({ "commands": commands });
689            self.post("/v2/commands/submit-and-wait-for-transaction", &body)
690                .await
691        })
692        .await
693    }
694
695    /// Submit **without waiting** (`POST /v2/commands/async/submit`): the
696    /// participant accepts the command and the outcome arrives on the
697    /// completion stream.
698    ///
699    /// The gRPC lane has had this since M1. Note what a failure here means: the
700    /// command may still have committed, so reach for
701    /// [`Self::submission`] rather than this method when losing the outcome is
702    /// not acceptable.
703    ///
704    /// # Errors
705    /// Returns an [`Error`] if authentication fails or the participant rejects
706    /// the submission ([`Error::Http`] with its error body).
707    pub async fn submit(&self, commands: &JsonCommands) -> Result<()> {
708        telemetry::instrument("submit", TRANSPORT_JSON, async {
709            // These two endpoints take the command set *itself* as the body,
710            // where `submit-and-wait-for-transaction` takes a request object
711            // wrapping it. The participant rejects the wrong one with a 400
712            // naming the fields it could not find.
713            // The response body is an empty object; accepting it is the answer.
714            self.post_submission("/v2/commands/async/submit", commands)
715                .await
716        })
717        .await
718    }
719
720    /// `post`, with the one rule a submission needs that a read does not: a
721    /// retry the participant refuses as a duplicate means our *own* earlier
722    /// attempt was accepted and its response was lost, so the command
723    /// succeeded. Reporting the rejection would tell the caller their command
724    /// failed when it did the opposite — which is the failure this change ID
725    /// exists to prevent, and it is not the JSON lane's to have differently
726    /// from gRPC.
727    async fn post_submission<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
728        let attempt = std::sync::atomic::AtomicU32::new(0);
729        canton_core::retry::run_with_retry(self.retry.as_ref(), || async {
730            let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
731            match self.post_once::<B, serde_json::Value>(path, body).await {
732                Ok(_) => Ok(()),
733                Err(error) if retry && is_duplicate_submission(&error) => {
734                    tracing::debug!(
735                        "submission retry was de-duplicated; the earlier attempt is the one that landed"
736                    );
737                    Ok(())
738                }
739                Err(error) => Err(error),
740            }
741        })
742        .await
743    }
744
745    /// Submit and wait for the completion (`POST /v2/commands/submit-and-wait`)
746    /// without fetching the transaction — the update id and completion offset.
747    ///
748    /// # Errors
749    /// Returns an [`Error`] if authentication fails or the command is rejected.
750    pub async fn submit_and_wait(
751        &self,
752        commands: &JsonCommands,
753    ) -> Result<JsonSubmitAndWaitResponse> {
754        telemetry::instrument("submit_and_wait", TRANSPORT_JSON, async {
755            self.post("/v2/commands/submit-and-wait", commands).await
756        })
757        .await
758    }
759
760    /// The create and consuming-exercise events of one contract
761    /// (`POST /v2/events/events-by-contract-id`), as seen by `parties`.
762    ///
763    /// Returns the raw response object; a contract that has been pruned or is
764    /// invisible to `parties` comes back as a `CONTRACT_EVENTS_NOT_FOUND`
765    /// error rather than an empty result.
766    ///
767    /// # Errors
768    /// Returns an [`Error`] if authentication or the request fails, or the
769    /// contract has no events visible to `parties`.
770    pub async fn events_by_contract_id(
771        &self,
772        contract_id: impl Into<String>,
773        parties: Vec<String>,
774    ) -> Result<Value> {
775        telemetry::instrument("events_by_contract_id", TRANSPORT_JSON, async {
776            let request = crate::request::ActiveContractsRequest::new(parties, 0);
777            let body = json!({
778                "contractId": contract_id.into(),
779                "eventFormat": request.json_body()["eventFormat"],
780            });
781            self.post("/v2/events/events-by-contract-id", &body).await
782        })
783        .await
784    }
785
786    /// Fix a submission's identity **before** sending it, returning a
787    /// [`JsonSubmission`](crate::JsonSubmission) that carries its
788    /// [`ChangeId`](crate::ChangeId) — the JSON lane's
789    /// [`CantonClient::submission`](crate::CantonClient::submission).
790    #[must_use]
791    pub fn submission(&self, commands: JsonCommands) -> crate::submission::JsonSubmission {
792        crate::submission::JsonSubmission::new(self.clone(), commands)
793    }
794
795    /// The active contract set snapshot at `active_at_offset`, wildcard-filtered
796    /// to `parties` (`POST /v2/state/active-contracts`).
797    ///
798    /// This is a **bounded** read: the node caps results at
799    /// `http-list-max-elements-limit` and returns [`Error::Http`] `413` past it,
800    /// so pass a `limit` for large sets (or use the streaming transport).
801    /// Each element is raw JSON (`{"workflowId": …, "contractEntry": …}`).
802    ///
803    /// # Errors
804    /// Returns an [`Error`] if authentication or the request fails, or the
805    /// result set exceeds the node limit (`413`).
806    pub async fn active_contracts(
807        &self,
808        parties: Vec<String>,
809        active_at_offset: i64,
810        limit: Option<i64>,
811    ) -> Result<Vec<Value>> {
812        telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
813            let body = active_contracts_request(&parties, active_at_offset);
814            let path = with_limit("/v2/state/active-contracts", limit);
815            self.post(&path, &body).await
816        })
817        .await
818    }
819
820    /// Like [`Self::active_contracts`], with the full request surface of an
821    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
822    /// (template/interface filters, created-event blobs, non-verbose records)
823    /// — the same builder the gRPC lane takes.
824    ///
825    /// # Errors
826    /// Returns an [`Error`] if authentication or the request fails, or the
827    /// result set exceeds the node limit (`413`).
828    pub async fn active_contracts_with(
829        &self,
830        request: &crate::request::ActiveContractsRequest,
831        limit: Option<i64>,
832    ) -> Result<Vec<Value>> {
833        telemetry::instrument("active_contracts", TRANSPORT_JSON, async {
834            let path = with_limit("/v2/state/active-contracts", limit);
835            self.post(&path, &request.json_body()).await
836        })
837        .await
838    }
839
840    /// Updates (transactions/reassignments) for `parties` in the offset range
841    /// `(begin_exclusive, end_inclusive]` (`POST /v2/updates`).
842    ///
843    /// A **bounded** read like [`Self::active_contracts`]: bound it with
844    /// `end_inclusive` and/or `limit`, or the node returns [`Error::Http`]
845    /// `413`. Each element is raw JSON (`{"update": …}`), including
846    /// `OffsetCheckpoint` heartbeats.
847    ///
848    /// # Errors
849    /// Returns an [`Error`] if authentication or the request fails, or the
850    /// result set exceeds the node limit (`413`).
851    pub async fn updates(
852        &self,
853        parties: Vec<String>,
854        begin_exclusive: i64,
855        end_inclusive: Option<i64>,
856        limit: Option<i64>,
857    ) -> Result<Vec<Value>> {
858        telemetry::instrument("updates", TRANSPORT_JSON, async {
859            let body = updates_request(&parties, begin_exclusive, end_inclusive);
860            let path = with_limit("/v2/updates", limit);
861            self.post(&path, &body).await
862        })
863        .await
864    }
865
866    /// Like [`Self::updates`], with the full request surface of an
867    /// [`UpdatesRequest`](crate::request::UpdatesRequest) (bounds, template/
868    /// interface filters, transaction shape, created-event blobs, topology
869    /// events, non-verbose records) — the same builder the gRPC lane takes.
870    ///
871    /// # Errors
872    /// Returns an [`Error`] if authentication or the request fails, or the
873    /// result set exceeds the node limit (`413`).
874    pub async fn updates_with(
875        &self,
876        request: &crate::request::UpdatesRequest,
877        limit: Option<i64>,
878    ) -> Result<Vec<Value>> {
879        telemetry::instrument("updates", TRANSPORT_JSON, async {
880            let path = with_limit("/v2/updates", limit);
881            self.post(&path, &request.json_body()).await
882        })
883        .await
884    }
885}
886
887/// Append a `?limit=<n>` query when a limit is set.
888fn with_limit(path: &str, limit: Option<i64>) -> String {
889    match limit {
890        Some(limit) => format!("{path}?limit={limit}"),
891        None => path.to_string(),
892    }
893}
894
895#[cfg(feature = "ws")]
896use futures_util::StreamExt as _;
897
898#[cfg(feature = "ws")]
899impl JsonClient {
900    /// Stream updates over WebSocket (feature `ws`) for `parties`, starting after
901    /// `begin_exclusive`. With `end_inclusive` the stream is bounded and closes
902    /// once the range is exhausted; without it the stream tails live. Each item
903    /// is a raw JSON update (`{"update": …}`); `OffsetCheckpoint` heartbeats are
904    /// filtered out (as in the gRPC [`CantonClient::updates`]).
905    ///
906    /// Unlike [`Self::updates`], this is not capped by the node's list limit. For
907    /// automatic reconnection use [`Self::ws_updates_resumable`].
908    ///
909    /// [`CantonClient::updates`]: crate::CantonClient::updates
910    ///
911    /// # Errors
912    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
913    /// participant error frame or a transport failure.
914    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
915    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
916    pub async fn ws_updates(
917        &self,
918        parties: Vec<String>,
919        begin_exclusive: i64,
920        end_inclusive: Option<i64>,
921    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
922        telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
923            let request = updates_request(&parties, begin_exclusive, end_inclusive);
924            let inner = crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request).await?;
925            Ok(telemetry::instrument_stream(
926                "ws_updates",
927                TRANSPORT_JSON,
928                crate::ws::filter_checkpoints(inner),
929            ))
930        })
931        .await
932    }
933
934    /// Like [`Self::ws_updates`], with the full request surface of an
935    /// [`UpdatesRequest`](crate::request::UpdatesRequest) — the same builder
936    /// the gRPC lane takes (bounds, filters, shape, blobs, topology events,
937    /// non-verbose records).
938    ///
939    /// # Errors
940    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
941    /// a participant error frame or a transport failure.
942    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
943    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
944    pub async fn ws_updates_with(
945        &self,
946        request: &crate::request::UpdatesRequest,
947    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
948        telemetry::instrument("ws_updates", TRANSPORT_JSON, async move {
949            let inner =
950                crate::ws::subscribe(&self.ws_transport(), "/v2/updates", request.json_body())
951                    .await?;
952            Ok(telemetry::instrument_stream(
953                "ws_updates",
954                TRANSPORT_JSON,
955                crate::ws::filter_checkpoints(inner),
956            ))
957        })
958        .await
959    }
960
961    /// Stream the active contract set snapshot at `active_at_offset` over
962    /// WebSocket (feature `ws`), wildcard-filtered to `parties`. The stream
963    /// closes when the snapshot is fully delivered. Each item is raw JSON
964    /// (`{"workflowId": …, "contractEntry": …}`).
965    ///
966    /// Unlike [`Self::active_contracts`], this is not capped by the node's list
967    /// limit.
968    ///
969    /// # Errors
970    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
971    /// participant error frame or a transport failure.
972    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
973    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
974    pub async fn ws_active_contracts(
975        &self,
976        parties: Vec<String>,
977        active_at_offset: i64,
978    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
979        telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
980            let request = active_contracts_request(&parties, active_at_offset);
981            let inner =
982                crate::ws::subscribe(&self.ws_transport(), "/v2/state/active-contracts", request)
983                    .await?;
984            Ok(telemetry::instrument_stream(
985                "ws_active_contracts",
986                TRANSPORT_JSON,
987                inner,
988            ))
989        })
990        .await
991    }
992
993    /// Like [`Self::ws_active_contracts`], with the full request surface of an
994    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest) —
995    /// the same builder the gRPC lane takes.
996    ///
997    /// # Errors
998    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
999    /// a participant error frame or a transport failure.
1000    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1001    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1002    pub async fn ws_active_contracts_with(
1003        &self,
1004        request: &crate::request::ActiveContractsRequest,
1005    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1006        telemetry::instrument("ws_active_contracts", TRANSPORT_JSON, async move {
1007            let inner = crate::ws::subscribe(
1008                &self.ws_transport(),
1009                "/v2/state/active-contracts",
1010                request.json_body(),
1011            )
1012            .await?;
1013            Ok(telemetry::instrument_stream(
1014                "ws_active_contracts",
1015                TRANSPORT_JSON,
1016                inner,
1017            ))
1018        })
1019        .await
1020    }
1021
1022    /// Stream command completions over WebSocket (feature `ws`) for `parties`,
1023    /// starting after `begin_exclusive`. Each item is a raw JSON completion;
1024    /// `OffsetCheckpoint` heartbeats are filtered out.
1025    ///
1026    /// # Errors
1027    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on a
1028    /// participant error frame or a transport failure.
1029    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1030    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1031    pub async fn ws_completions(
1032        &self,
1033        parties: Vec<String>,
1034        begin_exclusive: i64,
1035    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1036        telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
1037            let request = completions_request(&parties, begin_exclusive);
1038            let inner = crate::ws::subscribe(
1039                &self.ws_transport(),
1040                "/v2/commands/command-completions",
1041                request,
1042            )
1043            .await?;
1044            Ok(telemetry::instrument_stream(
1045                "ws_completions",
1046                TRANSPORT_JSON,
1047                crate::ws::filter_checkpoints(inner),
1048            ))
1049        })
1050        .await
1051    }
1052
1053    /// Like [`Self::ws_completions`], with the full request surface of a
1054    /// [`CompletionsRequest`](crate::request::CompletionsRequest) — including
1055    /// the submitting `user_id` to scope the stream to (the same builder the
1056    /// gRPC lane takes).
1057    ///
1058    /// # Errors
1059    /// Returns an [`Error`] if the handshake fails; the stream yields `Err` on
1060    /// a participant error frame or a transport failure.
1061    #[allow(clippy::large_futures)] // the WS handshake state is inherently large; awaited once.
1062    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1063    pub async fn ws_completions_with(
1064        &self,
1065        request: &crate::request::CompletionsRequest,
1066    ) -> Result<impl futures_core::Stream<Item = Result<Value>> + Send + use<>> {
1067        telemetry::instrument("ws_completions", TRANSPORT_JSON, async move {
1068            let inner = crate::ws::subscribe(
1069                &self.ws_transport(),
1070                "/v2/commands/command-completions",
1071                request.json_body(),
1072            )
1073            .await?;
1074            Ok(telemetry::instrument_stream(
1075                "ws_completions",
1076                TRANSPORT_JSON,
1077                crate::ws::filter_checkpoints(inner),
1078            ))
1079        })
1080        .await
1081    }
1082
1083    /// The reconnect policy for the WebSocket streams: `(max_reconnects,
1084    /// backoff_unit)`, taken from this client's
1085    /// [`RetryConfig`](canton_core::RetryConfig) when one is configured — the
1086    /// same derivation the gRPC client makes, so configuring retries once
1087    /// governs both lanes rather than only the unary one.
1088    #[cfg(feature = "ws")]
1089    fn reconnect_policy(&self) -> (u32, std::time::Duration) {
1090        match &self.retry {
1091            Some(retry) => (retry.max_attempts, retry.initial_backoff),
1092            None => (5, std::time::Duration::from_millis(250)),
1093        }
1094    }
1095
1096    /// Like [`Self::ws_active_contracts`], but **resumable**: on a retriable
1097    /// disconnect it resubscribes from the last continuation token the
1098    /// participant sent, rather than starting the snapshot again.
1099    ///
1100    /// An ACS snapshot has no offsets to resume from — it is a position in a
1101    /// stream of entries, which is what `streamContinuationToken` names. The
1102    /// gRPC lane has had a resumable ACS read since M1; this is its counterpart,
1103    /// and without it a WebSocket consumer of a large snapshot had to start over
1104    /// on any blip.
1105    ///
1106    /// The token is only valid against the same participant, the same
1107    /// `active_at_offset` and the same filters — all of which are fixed for the
1108    /// life of this stream — and while the snapshot's offset has not been
1109    /// pruned.
1110    #[cfg(feature = "ws")]
1111    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1112    pub fn ws_active_contracts_resumable(
1113        &self,
1114        parties: Vec<String>,
1115        active_at_offset: i64,
1116    ) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
1117        let (max_reconnects, backoff_unit) = self.reconnect_policy();
1118        let base_url = self.base_url.clone();
1119        let auth = self.auth.clone();
1120        let tls = self.tls.clone();
1121        let max_decoding_message_size = self.max_decoding_message_size;
1122        let timeout = self.timeout;
1123        async_stream::stream! {
1124            let mut token: Option<String> = None;
1125            let mut reconnects = 0u32;
1126            loop {
1127                let mut request = active_contracts_request(&parties, active_at_offset);
1128                if let Some(token) = &token {
1129                    request["streamContinuationToken"] = Value::String(token.clone());
1130                }
1131                let transport = crate::ws::WsTransport {
1132                    base_url: &base_url,
1133                    auth: &auth,
1134                    tls: tls.as_ref(),
1135                    max_decoding_message_size,
1136                    timeout,
1137                };
1138                // What made this reconnect necessary, carried out of the inner
1139                // loop for the reason the gRPC streams carry it: giving up must
1140                // report the participant's own failure, not an error of ours.
1141                let cause = match crate::ws::subscribe(&transport, "/v2/state/active-contracts", request).await {
1142                    Ok(inner) => {
1143                        // Each connection is instrumented for its own life, so a
1144                        // subscription that dies mid-snapshot is counted rather
1145                        // than disappearing into the reconnect loop.
1146                        let inner = telemetry::instrument_stream(
1147                            "ws_active_contracts",
1148                            TRANSPORT_JSON,
1149                            inner,
1150                        );
1151                        tokio::pin!(inner);
1152                        loop {
1153                            match inner.next().await {
1154                                Some(Ok(frame)) => {
1155                                    if let Some(next) = frame
1156                                        .get("streamContinuationToken")
1157                                        .and_then(Value::as_str)
1158                                        .filter(|next| !next.is_empty())
1159                                    {
1160                                        token = Some(next.to_string());
1161                                    }
1162                                    reconnects = 0;
1163                                    yield Ok(frame);
1164                                }
1165                                Some(Err(err)) if err.is_retriable() => break err,
1166                                Some(Err(err)) => {
1167                                    yield Err(err);
1168                                    return;
1169                                }
1170                                // A bounded read: the participant closes the
1171                                // socket when the snapshot is delivered, and
1172                                // there is no token left to resume from.
1173                                None => return,
1174                            }
1175                        }
1176                    }
1177                    Err(err) if err.is_retriable() => err,
1178                    Err(err) => {
1179                        yield Err(err);
1180                        return;
1181                    }
1182                };
1183
1184                reconnects += 1;
1185                if reconnects > max_reconnects {
1186                    tracing::warn!(
1187                        max_reconnects,
1188                        "ws acs stream gave up resuming; reporting the failure that caused it"
1189                    );
1190                    yield Err(cause);
1191                    return;
1192                }
1193                tokio::time::sleep(backoff_unit * reconnects).await;
1194            }
1195        }
1196    }
1197
1198    /// Like [`Self::ws_updates`] (unbounded tail), but **resumable**: on a
1199    /// retriable disconnect it reconnects from the last offset it observed
1200    /// (tracked via `OffsetCheckpoint` heartbeats and update offsets), with a
1201    /// short backoff and a bounded number of consecutive reconnects. Mirrors the
1202    /// gRPC [`CantonClient::updates_resumable`]. Checkpoints are consumed for
1203    /// position tracking and not yielded.
1204    ///
1205    /// [`CantonClient::updates_resumable`]: crate::CantonClient::updates_resumable
1206    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
1207    pub fn ws_updates_resumable(
1208        &self,
1209        parties: Vec<String>,
1210        begin_exclusive: i64,
1211    ) -> impl futures_core::Stream<Item = Result<Value>> + Send + use<> {
1212        let (max_reconnects, backoff_unit) = self.reconnect_policy();
1213        let base_url = self.base_url.clone();
1214        let auth = self.auth.clone();
1215        let tls = self.tls.clone();
1216        let max_decoding_message_size = self.max_decoding_message_size;
1217        let timeout = self.timeout;
1218        async_stream::stream! {
1219            let mut offset = begin_exclusive;
1220            let mut reconnects = 0u32;
1221            loop {
1222                // Unbounded tail (no end): a close means the connection dropped.
1223                let request = updates_request(&parties, offset, None);
1224                let transport = crate::ws::WsTransport {
1225                    base_url: &base_url,
1226                    auth: &auth,
1227                    tls: tls.as_ref(),
1228                    max_decoding_message_size,
1229                    timeout,
1230                };
1231                // `Option`, unlike the other resumable streams: here a clean
1232                // WS close is also a reason to reconnect, and it carries no
1233                // failure to report. Everything else does.
1234                let cause = match crate::ws::subscribe(&transport, "/v2/updates", request).await {
1235                    Ok(inner) => {
1236                        let inner =
1237                            telemetry::instrument_stream("ws_updates", TRANSPORT_JSON, inner);
1238                        tokio::pin!(inner);
1239                        loop {
1240                            match inner.next().await {
1241                                Some(Ok(frame)) => {
1242                                    if let Some(o) = crate::ws::update_offset(&frame) {
1243                                        offset = o;
1244                                    }
1245                                    reconnects = 0;
1246                                    if !crate::ws::is_offset_checkpoint(&frame) {
1247                                        yield Ok(frame);
1248                                    }
1249                                }
1250                                Some(Err(err)) if err.is_retriable() => break Some(err),
1251                                Some(Err(err)) => {
1252                                    yield Err(err);
1253                                    return;
1254                                }
1255                                None => break None, // WS closed → reconnect from `offset`
1256                            }
1257                        }
1258                    }
1259                    Err(err) if err.is_retriable() => Some(err),
1260                    Err(err) => {
1261                        yield Err(err);
1262                        return;
1263                    }
1264                };
1265
1266                reconnects += 1;
1267                if reconnects > max_reconnects {
1268                    tracing::warn!(
1269                        max_reconnects,
1270                        offset,
1271                        "ws update stream gave up resuming; reporting the failure that caused it"
1272                    );
1273                    // No cause means the participant kept closing the socket
1274                    // cleanly and the stream never got anywhere — which is not
1275                    // a participant error, so it is described as what it is.
1276                    yield Err(cause.unwrap_or_else(|| Error::UnexpectedResponse(format!(
1277                        "ws update stream was closed and reopened {max_reconnects} times \
1278                         without delivering an update"
1279                    ))));
1280                    return;
1281                }
1282                tokio::time::sleep(backoff_unit * reconnects).await;
1283            }
1284        }
1285    }
1286}
1287
1288#[cfg(test)]
1289#[allow(clippy::unwrap_used)]
1290mod tests {
1291    use super::*;
1292
1293    #[test]
1294    fn updates_request_matches_grpc_and_includes_reassignments() {
1295        let parties = vec!["alice::1".to_string()];
1296        let body = updates_request(&parties, 10, Some(20));
1297        assert_eq!(body["beginExclusive"], 10);
1298        assert_eq!(body["endInclusive"], 20);
1299        let fmt = &body["updateFormat"];
1300        // Both sub-formats present — same event set as the gRPC lane, which sets
1301        // include_transactions AND include_reassignments.
1302        assert!(fmt["includeTransactions"].is_object(), "{body}");
1303        assert!(
1304            fmt["includeReassignments"].is_object(),
1305            "reassignments must be requested, or the JSON lane drops them: {body}"
1306        );
1307        assert_eq!(
1308            fmt["includeTransactions"]["transactionShape"],
1309            "TRANSACTION_SHAPE_LEDGER_EFFECTS"
1310        );
1311    }
1312
1313    #[test]
1314    fn commands_serialize_to_the_json_api_shape() {
1315        let commands = JsonCommands::new(vec!["alice::1".to_string()])
1316            .with_command_id("cmd-1")
1317            .add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }));
1318        let value = serde_json::to_value(&commands).unwrap();
1319
1320        assert_eq!(value["commandId"], "cmd-1");
1321        assert_eq!(value["actAs"][0], "alice::1");
1322        // Tagged CreateCommand with a Daml-LF-JSON record argument.
1323        assert_eq!(
1324            value["commands"][0]["CreateCommand"]["templateId"],
1325            "pkg:Mod:Ent"
1326        );
1327        assert_eq!(
1328            value["commands"][0]["CreateCommand"]["createArguments"]["owner"],
1329            "alice::1"
1330        );
1331        // Optional fields are omitted, not null.
1332        assert!(value.get("userId").is_none());
1333        assert!(value.get("readAs").is_none());
1334    }
1335
1336    #[test]
1337    fn all_command_options_serialize_to_camel_case() {
1338        let commands = JsonCommands::new(vec!["alice::1".to_string()])
1339            .with_command_id("cmd-1")
1340            .with_user_id("user-1")
1341            .with_read_as(vec!["bob::2".to_string()])
1342            .with_workflow_id("wf-1")
1343            .with_synchronizer_id("sync-1")
1344            .with_submission_id("sub-1")
1345            .add_disclosed_contract(json!({ "contractId": "c9", "createdEventBlob": "AQI=" }))
1346            .with_package_id_selection_preference(vec!["pkg-9".to_string()])
1347            .with_deduplication_period(
1348                json!({ "DeduplicationDuration": { "value": { "duration": "30s" } } }),
1349            )
1350            .with_min_ledger_time_rel(json!("5s"))
1351            .add_create("pkg:Mod:Ent", json!({ "owner": "alice::1" }))
1352            .add_command(json!({ "ExerciseCommand": { "contractId": "c1" } }));
1353        let value = serde_json::to_value(&commands).unwrap();
1354
1355        assert_eq!(value["userId"], "user-1");
1356        assert_eq!(value["readAs"][0], "bob::2");
1357        assert_eq!(value["workflowId"], "wf-1");
1358        assert_eq!(value["synchronizerId"], "sync-1");
1359        assert_eq!(value["submissionId"], "sub-1");
1360        assert_eq!(value["disclosedContracts"][0]["contractId"], "c9");
1361        assert_eq!(value["packageIdSelectionPreference"][0], "pkg-9");
1362        assert_eq!(
1363            value["deduplicationPeriod"]["DeduplicationDuration"]["value"]["duration"],
1364            "30s"
1365        );
1366        assert_eq!(value["minLedgerTimeRel"], "5s");
1367        assert!(value.get("minLedgerTimeAbs").is_none());
1368        // Both the convenience create and the raw command are present, in order.
1369        assert!(value["commands"][0]["CreateCommand"].is_object());
1370        assert_eq!(value["commands"][1]["ExerciseCommand"]["contractId"], "c1");
1371    }
1372
1373    #[test]
1374    fn wildcard_event_format_filters_each_party() {
1375        let format = &active_contracts_request(&["alice::1".to_string(), "bob::2".to_string()], 0)
1376            ["eventFormat"];
1377        assert_eq!(format["verbose"], true);
1378        assert!(format["filtersByParty"]["alice::1"]["cumulative"][0]["identifierFilter"]
1379            ["WildcardFilter"]
1380            .is_object());
1381        assert!(format["filtersByParty"]["bob::2"].is_object());
1382    }
1383
1384    #[test]
1385    fn with_limit_appends_only_when_set() {
1386        assert_eq!(with_limit("/v2/updates", None), "/v2/updates");
1387        assert_eq!(with_limit("/v2/updates", Some(5)), "/v2/updates?limit=5");
1388    }
1389
1390    /// A participant that accepts the connection and then says nothing is the
1391    /// case no status code covers, and `reqwest` waits for it forever unless
1392    /// told not to. The gRPC lane has bounded this at 30s since M1; this one
1393    /// had no bound and no knob.
1394    ///
1395    /// The listener here accepts and never answers, which is the only way to
1396    /// tell a timeout that works from a request that happened to be fast.
1397    #[tokio::test]
1398    async fn a_request_to_a_silent_participant_gives_up_instead_of_hanging() {
1399        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1400        let addr = listener.local_addr().unwrap();
1401        // Hold every accepted connection open, answering nothing.
1402        tokio::spawn(async move {
1403            let mut held = Vec::new();
1404            while let Ok((socket, _)) = listener.accept().await {
1405                held.push(socket);
1406            }
1407        });
1408
1409        let client = JsonClient::new(format!("http://{addr}"))
1410            .with_timeout(std::time::Duration::from_millis(250));
1411
1412        // The outer bound is what makes this a test rather than a hang: with no
1413        // per-request timeout the call never returns at all, and a CI job that
1414        // stops responding says less than one that fails.
1415        let outcome =
1416            tokio::time::timeout(std::time::Duration::from_secs(5), client.version()).await;
1417        let Ok(result) = outcome else {
1418            panic!("the request never returned — the per-request timeout is not being applied");
1419        };
1420
1421        let error = result.unwrap_err();
1422        assert!(
1423            error.is_retriable(),
1424            "a timeout is transient and should be retriable: {error}"
1425        );
1426
1427        // And the default is a real bound, not `None` dressed up as one.
1428        assert_eq!(
1429            JsonClient::new("http://localhost:3975").timeout,
1430            DEFAULT_TIMEOUT
1431        );
1432    }
1433
1434    /// A client is the thing a caller is most likely to put in a `tracing`
1435    /// field or a `{:?}`, and its base URL is the one place a credential can
1436    /// hide in plain sight. `Config` has redacted its endpoint since the
1437    /// mutual-TLS fix; this type held the same secret behind a derived `Debug`.
1438    #[test]
1439    fn debug_does_not_print_credentials_carried_in_the_base_url() {
1440        let secret = "s3cr3t-p@ssw0rd";
1441        let client = JsonClient::new(format!("https://svc-account:{secret}@ledger.example:3975"))
1442            .with_token("eyJhbGciOiJSUzI1NiJ9.PAYLOAD.SIG");
1443        let rendered = format!("{client:?}");
1444
1445        assert!(
1446            !rendered.contains(secret),
1447            "leaked the password: {rendered}"
1448        );
1449        assert!(
1450            !rendered.contains("svc-account"),
1451            "leaked the user: {rendered}"
1452        );
1453        assert!(
1454            !rendered.contains("PAYLOAD"),
1455            "leaked the token: {rendered}"
1456        );
1457        // Still useful: the host has to survive, or the output tells a reader
1458        // nothing about which participant this client talks to.
1459        assert!(
1460            rendered.contains("ledger.example:3975"),
1461            "should keep the host: {rendered}"
1462        );
1463    }
1464
1465    /// The WS lane must not silently inherit `tungstenite`'s ceiling: 64 MiB
1466    /// per message and 16 MiB per *frame*, neither of them anything the caller
1467    /// asked for. A JSON client carries the same limit the gRPC one does, and
1468    /// raising it is the caller's to do.
1469    #[test]
1470    fn the_ws_lane_starts_at_the_sdk_size_limit_not_tungstenites() {
1471        let client = JsonClient::new("http://localhost:3975");
1472        assert_eq!(
1473            client.max_decoding_message_size,
1474            canton_core::DEFAULT_MAX_DECODING_MESSAGE_SIZE,
1475        );
1476        // Well clear of both tungstenite defaults, which is the whole point.
1477        assert!(client.max_decoding_message_size > 64 << 20);
1478
1479        let raised = client.with_max_decoding_message_size(256 << 20);
1480        assert_eq!(raised.max_decoding_message_size, 256 << 20);
1481    }
1482
1483    #[test]
1484    fn command_id_defaults_to_a_generated_uuid() {
1485        let commands = JsonCommands::new(vec!["alice::1".to_string()]);
1486        let value = serde_json::to_value(&commands).unwrap();
1487        let id = value["commandId"].as_str().unwrap();
1488        assert!(id.starts_with("sdk-"), "got {id}");
1489        assert!(id.len() > 10, "expected a uuid suffix, got {id}");
1490    }
1491
1492    #[test]
1493    fn with_tls_threads_a_ca_and_client_identity() {
1494        let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
1495        let cert_pem = ck.cert.pem().into_bytes();
1496        let key_pem = ck.key_pair.serialize_pem().into_bytes();
1497
1498        // A valid CA + client identity (mTLS) builds an HTTPS client.
1499        let tls = canton_core::TlsConfig::new()
1500            .with_ca_certificate(cert_pem.clone())
1501            .with_client_identity(cert_pem, key_pem);
1502        assert!(
1503            JsonClient::new("https://localhost:3975")
1504                .with_token("t")
1505                .with_tls(&tls)
1506                .is_ok()
1507        );
1508
1509        // A malformed client-identity PEM is rejected as an InvalidRequest.
1510        let bad = canton_core::TlsConfig::new()
1511            .with_client_identity(b"not a pem".to_vec(), b"nor this".to_vec());
1512        assert!(matches!(
1513            JsonClient::new("https://localhost:3975").with_tls(&bad),
1514            Err(Error::InvalidRequest(_))
1515        ));
1516    }
1517
1518    #[test]
1519    fn with_tls_upgrades_an_http_base_url_to_https() {
1520        // The security bug: `with_tls` on an http:// base URL would otherwise
1521        // send plaintext HTTP (certs unused) and open a ws:// socket. The scheme
1522        // must become https so both the reqwest and WebSocket lanes use TLS.
1523        let client = JsonClient::new("http://localhost:3975")
1524            .with_tls(&canton_core::TlsConfig::new())
1525            .unwrap();
1526        assert_eq!(client.base_url, "https://localhost:3975");
1527
1528        // Already-https is left untouched, and the check is case-insensitive.
1529        assert_eq!(
1530            upgrade_base_url_for_tls("https://host:443"),
1531            "https://host:443"
1532        );
1533        assert_eq!(
1534            upgrade_base_url_for_tls("HTTP://host:80"),
1535            "https://host:80"
1536        );
1537        // Without TLS, plain http is preserved (no normalisation on `new`).
1538        assert_eq!(
1539            JsonClient::new("http://localhost:3975").base_url,
1540            "http://localhost:3975"
1541        );
1542    }
1543}