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}