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