canton_ledger/client.rs
1//! The async Ledger API client.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use canton_core::auth::{self, Intercepted};
7use canton_core::telemetry::{self, TRANSPORT_GRPC};
8use canton_core::{Config, Error, Result};
9use canton_proto::com::daml::ledger::api::v2 as pb;
10use canton_proto::grpc::health::v1 as health_pb;
11use canton_proto::grpc::health::v1::health_check_response::ServingStatus;
12use futures_core::Stream;
13use tokio_stream::StreamExt as _;
14use tonic::transport::Channel;
15
16/// An `EventFormat` with a wildcard (all-templates) filter for each party.
17fn wildcard_event_format(parties: &[String]) -> pb::EventFormat {
18 let filters_by_party = parties
19 .iter()
20 .map(|party| {
21 (
22 party.clone(),
23 pb::Filters {
24 cumulative: vec![pb::CumulativeFilter {
25 identifier_filter: Some(
26 pb::cumulative_filter::IdentifierFilter::WildcardFilter(
27 pb::WildcardFilter {
28 include_created_event_blob: false,
29 },
30 ),
31 ),
32 }],
33 },
34 )
35 })
36 .collect();
37
38 pb::EventFormat {
39 filters_by_party,
40 filters_for_any_party: None,
41 verbose: true,
42 }
43}
44
45/// Refuse a command set the participant is certain to refuse.
46///
47/// Every one of these costs a round trip and comes back as a server-side error
48/// that reads like the ledger's fault. They are the caller's, and they are
49/// knowable here.
50/// Whether a failed submission is the participant refusing a command it already
51/// has — and, on a retry, one this client is responsible for having sent.
52///
53/// `ALREADY_EXISTS` is what Canton's `DUPLICATE_COMMAND` arrives as. On a first
54/// attempt it means the *caller* reused a change ID from an earlier submission,
55/// which is a genuine rejection they must see. On a retry it means our own
56/// previous attempt was accepted, which is the opposite of a failure — so the
57/// caller of this predicate checks that first.
58fn is_duplicate_of_our_own(status: &tonic::Status) -> bool {
59 status.code() == tonic::Code::AlreadyExists
60}
61
62fn validate_commands(commands: &pb::Commands) -> Result<()> {
63 if commands.act_as.is_empty() {
64 return Err(Error::InvalidRequest(
65 "a submission needs at least one acting party".to_string(),
66 ));
67 }
68 if commands.commands.is_empty() {
69 return Err(Error::InvalidRequest(
70 "a submission needs at least one command".to_string(),
71 ));
72 }
73 if commands.min_ledger_time_abs.is_some() && commands.min_ledger_time_rel.is_some() {
74 return Err(Error::InvalidRequest(
75 "min_ledger_time_abs and min_ledger_time_rel are mutually exclusive: set one"
76 .to_string(),
77 ));
78 }
79 Ok(())
80}
81
82/// One entry of an Active Contract Set snapshot.
83///
84/// A snapshot is not only active contracts. A reassignment that is half-done at
85/// the snapshot offset appears as an *incomplete* entry — unassigned from one
86/// synchronizer with no matching assignment yet, or assigned to one with the
87/// unassignment out of view — and an application that reads only
88/// [`Active`](Self::Active) sees a contract that has left one synchronizer and
89/// not arrived at the other simply vanish. The Ledger API sends these precisely
90/// so a multi-synchronizer application can bootstrap without that hole, which is
91/// why there is a lossless read.
92///
93/// `#[non_exhaustive]`: the Ledger API may add an entry kind.
94#[derive(Clone, Debug, PartialEq)]
95#[non_exhaustive]
96pub enum AcsEntry {
97 /// A contract active on the synchronizer named in the entry.
98 Active(pb::ActiveContract),
99 /// Unassigned before the snapshot offset with no assignment before it —
100 /// the contract is in flight *out of* this synchronizer.
101 IncompleteUnassigned(pb::IncompleteUnassigned),
102 /// Assigned before the snapshot offset with no unassignment before it.
103 /// Note this does **not** say the contract is active on the target
104 /// synchronizer; the proto is explicit about that.
105 IncompleteAssigned(pb::IncompleteAssigned),
106}
107
108impl AcsEntry {
109 /// The active contract, if this entry is one.
110 #[must_use]
111 pub fn active(&self) -> Option<&pb::ActiveContract> {
112 match self {
113 AcsEntry::Active(active) => Some(active),
114 _ => None,
115 }
116 }
117
118 /// Consume the entry, yielding the active contract if it is one.
119 #[must_use]
120 pub fn into_active(self) -> Option<pb::ActiveContract> {
121 match self {
122 AcsEntry::Active(active) => Some(active),
123 _ => None,
124 }
125 }
126}
127
128/// Convert one wire entry, dropping only the case the participant left empty
129/// (the `oneof` is required, so an empty entry is a participant that sent
130/// nothing to report).
131fn acs_entry(entry: Option<pb::get_active_contracts_response::ContractEntry>) -> Option<AcsEntry> {
132 use pb::get_active_contracts_response::ContractEntry;
133 match entry {
134 Some(ContractEntry::ActiveContract(active)) => Some(AcsEntry::Active(active)),
135 Some(ContractEntry::IncompleteUnassigned(unassigned)) => {
136 Some(AcsEntry::IncompleteUnassigned(unassigned))
137 }
138 Some(ContractEntry::IncompleteAssigned(assigned)) => {
139 Some(AcsEntry::IncompleteAssigned(assigned))
140 }
141 None => None,
142 }
143}
144
145/// The offset of an update, for resumable-stream position tracking.
146fn update_offset(update: &pb::get_updates_response::Update) -> i64 {
147 use pb::get_updates_response::Update;
148 match update {
149 Update::Transaction(t) => t.offset,
150 Update::Reassignment(r) => r.offset,
151 Update::TopologyTransaction(t) => t.offset,
152 Update::OffsetCheckpoint(c) => c.offset,
153 }
154}
155
156/// Build a gRPC service client on the authenticated channel, with this
157/// client's decode limit applied.
158///
159/// A macro rather than a function because `max_decoding_message_size` is an
160/// inherent method on each generated client — tonic exposes no trait for it —
161/// so there is nothing to be generic over. Keeping every construction site
162/// behind one expansion is the point: `tonic`'s 4 MiB default is small enough
163/// that a real ACS page trips it, and a new RPC added later would otherwise
164/// pick the default up silently.
165macro_rules! service {
166 ($self:ident, $ctor:expr) => {
167 $ctor($self.intercepted().await?)
168 .max_decoding_message_size($self.config.max_decoding_message_size())
169 };
170}
171
172/// An async client for the Canton Ledger API over gRPC.
173///
174/// The client owns a lazily-connected [`Channel`]; cloning it is cheap and
175/// clones share the underlying connection pool, so it is safe to hand a clone
176/// to each task.
177#[derive(Clone, Debug)]
178pub struct CantonClient {
179 channel: Channel,
180 config: Arc<Config>,
181}
182
183impl CantonClient {
184 /// Build a lazily-connected client. Returns immediately; the TCP/TLS
185 /// handshake happens on the first RPC.
186 ///
187 /// # Errors
188 /// Returns [`Error::InvalidRequest`] if the endpoint is not a valid URI.
189 pub fn connect_lazy(config: Config) -> Result<Self> {
190 Ok(Self {
191 channel: config.connect_channel()?,
192 config: Arc::new(config),
193 })
194 }
195
196 /// A channel wrapped with a fresh bearer-token interceptor for this call.
197 async fn intercepted(&self) -> Result<Intercepted> {
198 auth::intercepted(&self.channel, self.config.auth()).await
199 }
200
201 /// Run a unary operation under the configured retry policy (no-op when
202 /// retrying is not enabled).
203 async fn with_retry<T, F, Fut>(&self, op: F) -> Result<T>
204 where
205 F: FnMut() -> Fut,
206 Fut: std::future::Future<Output = Result<T>>,
207 {
208 canton_core::retry::run_with_retry(self.config.retry(), op).await
209 }
210
211 /// Return the participant's Ledger API version string (e.g. `"3.5.7"`).
212 ///
213 /// # Errors
214 /// Returns an [`Error`] if the RPC fails.
215 pub async fn version(&self) -> Result<String> {
216 telemetry::instrument("version", TRANSPORT_GRPC, async {
217 self.with_retry(|| async {
218 let mut client =
219 service!(self, pb::version_service_client::VersionServiceClient::new);
220 let response = client
221 .get_ledger_api_version(pb::GetLedgerApiVersionRequest {})
222 .await?
223 .into_inner();
224 Ok(response.version)
225 })
226 .await
227 })
228 .await
229 }
230
231 /// Probe the participant's overall serving status via the standard
232 /// `grpc.health.v1.Health` service (served on the Ledger API port).
233 ///
234 /// Poll this to react to intermittent or permanent node failure: a healthy
235 /// participant answers [`ServingStatus::Serving`]; an unreachable one
236 /// surfaces a transport [`Error`] (see [`Error::is_retriable`]).
237 ///
238 /// # Errors
239 /// Returns an [`Error`] if the health RPC fails (e.g. the node is down).
240 pub async fn health_check(&self) -> Result<ServingStatus> {
241 telemetry::instrument("health_check", TRANSPORT_GRPC, async {
242 self.with_retry(|| async {
243 let mut client = service!(self, health_pb::health_client::HealthClient::new);
244 // Empty `service` = the server's overall status (Canton does not
245 // register per-service health entries on the Ledger API port).
246 let response = client
247 .check(health_pb::HealthCheckRequest {
248 service: String::new(),
249 })
250 .await?
251 .into_inner();
252 Ok(response.status())
253 })
254 .await
255 })
256 .await
257 }
258
259 /// Submit commands **fire-and-forget** (`CommandSubmissionService.Submit`):
260 /// hand the commands to the participant and return promptly without waiting
261 /// for the transaction. Returns the change-ID `command_id` used, so the
262 /// caller can recover the outcome later with [`Self::await_completion`] (or
263 /// the [`Self::completions`] stream).
264 ///
265 /// A fresh UUID `command_id` is generated when the caller did not set one,
266 /// so ledger-side de-duplication behaves correctly across retries.
267 ///
268 /// # Errors
269 /// Returns an [`Error`] if authentication fails or the participant rejects
270 /// the submission synchronously (e.g. a preprocessing error).
271 pub async fn submit(&self, submit: crate::command::Submit) -> Result<String> {
272 let submission = self.submission(submit);
273 let command_id = submission.change_id().command_id().to_string();
274 submission.submit().await?;
275 Ok(command_id)
276 }
277
278 /// Fix a submission's identity **before** sending it, returning a
279 /// [`Submission`](crate::Submission) that carries its
280 /// [`ChangeId`](crate::ChangeId).
281 ///
282 /// This is the handle to reach for when losing the outcome is not an
283 /// option. A submission whose response is lost — a dropped connection, a
284 /// timeout, a retry the participant de-duplicated — may well have
285 /// committed, and the only way back to the answer is the change ID. If the
286 /// SDK generated the command id inside the call that failed, there is no
287 /// change ID to go back with.
288 ///
289 /// ```no_run
290 /// # async fn run(client: canton_ledger::CantonClient, submit: canton_ledger::Submit)
291 /// # -> canton_ledger::Result<()> {
292 /// use std::time::Duration;
293 ///
294 /// // Where to start reading completions from, taken before submitting.
295 /// let offset = client.ledger_end().await?;
296 /// let submission = client.submission(submit);
297 ///
298 /// if submission.submit_and_wait().await.is_err() {
299 /// // Ambiguous: ask the ledger what actually happened.
300 /// let completion = submission.recover(offset, Duration::from_secs(30)).await?;
301 /// println!("committed after all: {}", completion.update_id);
302 /// }
303 /// # Ok(()) }
304 /// ```
305 #[must_use]
306 pub fn submission(&self, submit: crate::command::Submit) -> crate::submission::Submission {
307 let shape = submit.transaction_shape;
308 let (change_id, commands) = submit.into_commands();
309 crate::submission::Submission::new(self.clone(), change_id, commands, shape)
310 }
311
312 pub(crate) async fn submit_commands(&self, commands: pb::Commands) -> Result<()> {
313 validate_commands(&commands)?;
314 telemetry::instrument("submit", TRANSPORT_GRPC, async move {
315 let attempt = std::sync::atomic::AtomicU32::new(0);
316 let command_id = commands.command_id.clone();
317 self.with_retry(|| {
318 let commands = commands.clone();
319 let command_id = command_id.clone();
320 let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
321 async move {
322 let mut client = service!(
323 self,
324 pb::command_submission_service_client::CommandSubmissionServiceClient::new
325 );
326 match client
327 .submit(pb::SubmitRequest {
328 commands: Some(commands),
329 })
330 .await
331 {
332 Ok(_) => Ok(()),
333 Err(status) if retry && is_duplicate_of_our_own(&status) => {
334 // The command we are retrying is already at the
335 // participant, and we are the ones who put it
336 // there: a previous attempt of this same retry loop
337 // was accepted and its response was lost. Reporting
338 // the duplicate rejection would tell the caller
339 // their command failed when it did precisely the
340 // opposite — the failure mode this whole change ID
341 // exists to prevent.
342 tracing::debug!(
343 %command_id,
344 "submission retry was de-duplicated; the earlier attempt is the one that landed"
345 );
346 Ok(())
347 }
348 Err(status) => Err(Error::from(status)),
349 }
350 }
351 })
352 .await
353 })
354 .await
355 }
356
357 /// Submit commands and wait for the result **without** fetching the
358 /// transaction (`CommandService.SubmitAndWait`): blocks until the command
359 /// commits (or is rejected) and returns the `update_id` and completion
360 /// offset. Lighter than [`Self::submit_and_wait_for_transaction`] when the
361 /// caller does not need the event payload.
362 ///
363 /// # Errors
364 /// Returns an [`Error`] if authentication fails or the command is rejected.
365 /// The retry caveat on [`Self::submit_and_wait_for_transaction`] applies.
366 pub async fn submit_and_wait(
367 &self,
368 submit: crate::command::Submit,
369 ) -> Result<pb::SubmitAndWaitResponse> {
370 self.submission(submit).submit_and_wait().await
371 }
372
373 pub(crate) async fn submit_and_wait_commands(
374 &self,
375 commands: pb::Commands,
376 ) -> Result<pb::SubmitAndWaitResponse> {
377 validate_commands(&commands)?;
378 telemetry::instrument("submit_and_wait", TRANSPORT_GRPC, async move {
379 let request = pb::SubmitAndWaitRequest {
380 commands: Some(commands),
381 };
382 self.with_retry(|| {
383 let request = request.clone();
384 async move {
385 let mut client =
386 service!(self, pb::command_service_client::CommandServiceClient::new);
387 Ok(client.submit_and_wait(request).await?.into_inner())
388 }
389 })
390 .await
391 })
392 .await
393 }
394
395 /// Submit commands and wait for the resulting transaction.
396 ///
397 /// Fills the change ID's `command_id` with a fresh UUID when the caller did
398 /// not set one, so ledger-side de-duplication behaves correctly. The
399 /// returned transaction is shaped as `LEDGER_EFFECTS` and filtered to the
400 /// acting party (wildcard), so created events are visible in the response.
401 ///
402 /// # Errors
403 /// Returns an [`Error`] if authentication fails, the command is rejected, or
404 /// the response contains no transaction.
405 ///
406 /// # Example
407 /// ```no_run
408 /// # async fn run(client: canton_ledger::CantonClient, party: &str, pkg: &str)
409 /// # -> canton_ledger::Result<()> {
410 /// use canton_ledger::{Submit, create, identifier, record};
411 ///
412 /// let tx = client
413 /// .submit_and_wait_for_transaction(
414 /// Submit::new(party)
415 /// .add_command(create(identifier(pkg, "M", "T"), record(vec![]))),
416 /// )
417 /// .await?;
418 /// println!("committed {} at offset {}", tx.update_id, tx.offset);
419 /// # Ok(()) }
420 /// ```
421 ///
422 /// # Retry caveat (exactly-once)
423 /// With retry enabled ([`Config::with_retry`]), a submission that commits
424 /// on the ledger but whose response is lost to a retriable error is re-sent
425 /// with the same `command_id` and de-duplicated by the participant.
426 ///
427 /// [`Self::submit`] can answer that on its own: a duplicate rejection of a
428 /// retry it made itself means its earlier attempt was accepted, which is
429 /// success, so it reports success. **This method cannot.** Its result is
430 /// the committed transaction, and a de-duplicated retry does not carry one
431 /// — so the duplicate rejection reaches the caller, and the transaction has
432 /// to be read back rather than invented.
433 ///
434 /// Take a [`Submission`](crate::Submission) from [`Self::submission`]
435 /// before submitting, and on any error recover the outcome with
436 /// [`Submission::recover`](crate::Submission::recover) — it knows the
437 /// change ID, which is the only way back to a command whose response was
438 /// lost. See the `recover_a_submission` example.
439 pub async fn submit_and_wait_for_transaction(
440 &self,
441 submit: crate::command::Submit,
442 ) -> Result<pb::Transaction> {
443 self.submission(submit)
444 .submit_and_wait_for_transaction()
445 .await
446 }
447
448 pub(crate) async fn submit_and_wait_for_transaction_commands(
449 &self,
450 commands: pb::Commands,
451 shape: crate::request::TransactionShape,
452 ) -> Result<pb::Transaction> {
453 validate_commands(&commands)?;
454 // The filter covers `read_as` as well as `act_as`: the Ledger API's own
455 // default for a submission is both, and a command submitted with
456 // `read_as` set is one whose result the caller expects to see through
457 // those parties too. Filtering to `act_as` alone silently returned a
458 // transaction with events missing.
459 let mut parties = commands.act_as.clone();
460 parties.extend(commands.read_as.iter().cloned());
461 parties.sort_unstable();
462 parties.dedup();
463 let request = pb::SubmitAndWaitForTransactionRequest {
464 transaction_format: Some(pb::TransactionFormat {
465 event_format: Some(wildcard_event_format(&parties)),
466 transaction_shape: shape.as_grpc() as i32,
467 }),
468 commands: Some(commands),
469 };
470 telemetry::instrument(
471 "submit_and_wait_for_transaction",
472 TRANSPORT_GRPC,
473 async move {
474 let response = self
475 .with_retry(|| {
476 let request = request.clone();
477 async move {
478 let mut client = service!(
479 self,
480 pb::command_service_client::CommandServiceClient::new
481 );
482 Ok(client
483 .submit_and_wait_for_transaction(request)
484 .await?
485 .into_inner())
486 }
487 })
488 .await?;
489
490 response.transaction.ok_or_else(|| {
491 Error::UnexpectedResponse("response contained no transaction".to_string())
492 })
493 },
494 )
495 .await
496 }
497
498 /// Subscribe to the command-completion stream for `parties`, starting after
499 /// `begin_offset` (exclusive). Offset checkpoints are filtered out, so the
500 /// stream yields only [`pb::Completion`]s.
501 ///
502 /// # Errors
503 /// Returns an [`Error`] if authentication or opening the stream fails.
504 pub async fn completions(
505 &self,
506 parties: Vec<String>,
507 begin_offset: i64,
508 ) -> Result<impl Stream<Item = Result<pb::Completion>> + Send + use<>> {
509 self.completions_with(crate::request::CompletionsRequest::new(
510 parties,
511 begin_offset,
512 ))
513 .await
514 }
515
516 /// Like [`Self::completions`], with the full request surface: a
517 /// [`CompletionsRequest`](crate::request::CompletionsRequest) additionally
518 /// selects the `user_id` whose command completions to stream (pair it with
519 /// [`Submit::with_user_id`](crate::Submit::with_user_id)).
520 ///
521 /// # Errors
522 /// Returns an [`Error`] if authentication or opening the stream fails.
523 pub async fn completions_with(
524 &self,
525 request: crate::request::CompletionsRequest,
526 ) -> Result<impl Stream<Item = Result<pb::Completion>> + Send + use<>> {
527 request.validate()?;
528 telemetry::instrument("completions", TRANSPORT_GRPC, async move {
529 let mut client = service!(
530 self,
531 pb::command_completion_service_client::CommandCompletionServiceClient::new
532 );
533 let stream = client
534 .completion_stream(request.into_grpc())
535 .await?
536 .into_inner();
537
538 let stream = stream.filter_map(|item| match item {
539 Ok(response) => match response.completion_response {
540 Some(pb::completion_stream_response::CompletionResponse::Completion(
541 completion,
542 )) => Some(Ok(completion)),
543 _ => None, // skip offset checkpoints
544 },
545 Err(status) => Some(Err(Error::from(status))),
546 });
547 Ok(telemetry::instrument_stream(
548 "completions",
549 TRANSPORT_GRPC,
550 stream,
551 ))
552 })
553 .await
554 }
555
556 /// Recover the completion for a specific command by scanning the completion
557 /// stream from `begin_offset`, up to `timeout`.
558 ///
559 /// This is the command-recovery path: after a crash, lost connection, or
560 /// timeout, the outcome of a pending command is read back from the
561 /// completion endpoint instead of blindly re-submitting. If the command's
562 /// completion reports a non-OK status, this returns [`Error::CommandRejected`].
563 ///
564 /// Matching is on the whole [`ChangeId`](crate::ChangeId) — user, acting
565 /// parties and command id — because that is what identifies a command to
566 /// Canton. A command id on its own is not unique across the users of a
567 /// participant, and answering with somebody else's completion is worse
568 /// than answering with none.
569 ///
570 /// The completion stream is a live subscription that does not self-terminate,
571 /// so `timeout` bounds how long to wait for the target completion.
572 ///
573 /// # Errors
574 /// Returns [`Error::Timeout`] if the completion is not seen within `timeout`,
575 /// [`Error::CommandRejected`] if the ledger rejected the command, or another
576 /// [`Error`] if the stream fails.
577 pub async fn await_completion(
578 &self,
579 change_id: &crate::command::ChangeId,
580 begin_offset: i64,
581 timeout: Duration,
582 ) -> Result<pb::Completion> {
583 let scan = async {
584 let stream = self
585 .completions(change_id.act_as().to_vec(), begin_offset)
586 .await?;
587 tokio::pin!(stream);
588 while let Some(item) = stream.next().await {
589 let completion = item?;
590 if change_id.matches(&completion) {
591 // A non-OK gRPC status on the completion means the ledger
592 // rejected the command for business/interpretation reasons.
593 if let Some(status) = &completion.status {
594 // google.rpc.Status code 0 == OK; anything else is a rejection.
595 if status.code != 0 {
596 return Err(Error::CommandRejected {
597 code: format!("{:?}", tonic::Code::from(status.code)),
598 message: status.message.clone(),
599 });
600 }
601 }
602 return Ok(completion);
603 }
604 }
605 Err(Error::UnexpectedResponse(format!(
606 "completion stream ended before command {} was seen",
607 change_id.command_id()
608 )))
609 };
610
611 tokio::time::timeout(timeout, scan)
612 .await
613 .map_err(|_| Error::Timeout)?
614 }
615
616 /// Return the current ledger end offset as seen by the participant.
617 ///
618 /// A value of `0` means the participant's view of the ledger is empty.
619 /// This is an authenticated endpoint.
620 ///
621 /// # Errors
622 /// Returns an [`Error`] if authentication or the RPC fails.
623 pub async fn ledger_end(&self) -> Result<i64> {
624 telemetry::instrument("ledger_end", TRANSPORT_GRPC, async {
625 self.with_retry(|| async {
626 let mut client = service!(self, pb::state_service_client::StateServiceClient::new);
627 let response = client
628 .get_ledger_end(pb::GetLedgerEndRequest {})
629 .await?
630 .into_inner();
631 Ok(response.offset)
632 })
633 .await
634 })
635 .await
636 }
637
638 /// Fetch the created and/or archived events for a contract by id
639 /// (`EventQueryService.GetEventsByContractId`), with verbose records and
640 /// no created-event blob. To obtain a contract's `created_event_blob`
641 /// (for disclosure), use a template-filtered
642 /// [`Self::active_contracts_with`] read with
643 /// [`ActiveContractsRequest::with_created_event_blobs`](crate::request::ActiveContractsRequest::with_created_event_blobs).
644 ///
645 /// # Errors
646 /// Returns an [`Error`] if authentication or the RPC fails.
647 pub async fn events_by_contract_id(
648 &self,
649 contract_id: impl Into<String>,
650 parties: Vec<String>,
651 ) -> Result<pb::GetEventsByContractIdResponse> {
652 let contract_id = contract_id.into();
653 telemetry::instrument("events_by_contract_id", TRANSPORT_GRPC, async move {
654 // Retried like the other reads: this is a lookup, so a transient
655 // failure is worth another attempt rather than a caller's error
656 // path. Only submissions have a reason to be careful here.
657 self.with_retry(|| {
658 let request = pb::GetEventsByContractIdRequest {
659 contract_id: contract_id.clone(),
660 event_format: Some(wildcard_event_format(&parties)),
661 };
662 async move {
663 let mut client = service!(
664 self,
665 pb::event_query_service_client::EventQueryServiceClient::new
666 );
667 Ok(client
668 .get_events_by_contract_id(request)
669 .await?
670 .into_inner())
671 }
672 })
673 .await
674 })
675 .await
676 }
677
678 /// Fetch one **page** of the Active Contract Set for `parties` as of
679 /// `active_at_offset`. Returns the page's active contracts and the next page
680 /// token (`None` once the last page has been read); pass the token back in
681 /// to fetch the following page.
682 ///
683 /// # Errors
684 /// Returns an [`Error`] if authentication or the RPC fails.
685 pub async fn active_contracts_page(
686 &self,
687 parties: Vec<String>,
688 active_at_offset: i64,
689 max_page_size: i32,
690 page_token: Option<Vec<u8>>,
691 ) -> Result<(Vec<pb::ActiveContract>, Option<Vec<u8>>)> {
692 let request = crate::request::ActiveContractsRequest::new(parties, active_at_offset);
693 self.active_contracts_page_with(&request, max_page_size, page_token)
694 .await
695 }
696
697 /// Like [`Self::active_contracts_page`], with the full request surface of
698 /// an [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
699 /// (template/interface filters, created-event blobs, non-verbose records).
700 ///
701 /// # Errors
702 /// Returns an [`Error`] if authentication or the RPC fails.
703 pub async fn active_contracts_page_with(
704 &self,
705 request: &crate::request::ActiveContractsRequest,
706 max_page_size: i32,
707 page_token: Option<Vec<u8>>,
708 ) -> Result<(Vec<pb::ActiveContract>, Option<Vec<u8>>)> {
709 let (entries, next) = self
710 .acs_page_with(request, max_page_size, page_token)
711 .await?;
712 Ok((
713 entries
714 .into_iter()
715 .filter_map(AcsEntry::into_active)
716 .collect(),
717 next,
718 ))
719 }
720
721 /// One page of the Active Contract Set for `parties`, **losslessly**: every
722 /// entry the participant sent, active or incomplete (see [`AcsEntry`]).
723 ///
724 /// # Errors
725 /// Returns an [`Error`] if authentication or the RPC fails.
726 pub async fn acs_page(
727 &self,
728 parties: Vec<String>,
729 active_at_offset: i64,
730 max_page_size: i32,
731 page_token: Option<Vec<u8>>,
732 ) -> Result<(Vec<AcsEntry>, Option<Vec<u8>>)> {
733 let request = crate::request::ActiveContractsRequest::new(parties, active_at_offset);
734 self.acs_page_with(&request, max_page_size, page_token)
735 .await
736 }
737
738 /// Like [`Self::acs_page`], with the full request surface of an
739 /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
740 ///
741 /// # Errors
742 /// Returns an [`Error`] if authentication or the RPC fails.
743 pub async fn acs_page_with(
744 &self,
745 request: &crate::request::ActiveContractsRequest,
746 max_page_size: i32,
747 page_token: Option<Vec<u8>>,
748 ) -> Result<(Vec<AcsEntry>, Option<Vec<u8>>)> {
749 request.validate()?;
750 telemetry::instrument("active_contracts_page", TRANSPORT_GRPC, async move {
751 self.with_retry(|| {
752 let page_request = pb::GetActiveContractsPageRequest {
753 active_at_offset: Some(request.active_at_offset),
754 event_format: Some(request.event_format()),
755 max_page_size: Some(max_page_size),
756 page_token: page_token.clone(),
757 };
758 async move {
759 let mut client =
760 service!(self, pb::state_service_client::StateServiceClient::new);
761 let response = client
762 .get_active_contracts_page(page_request)
763 .await?
764 .into_inner();
765
766 let entries = response
767 .active_contracts
768 .into_iter()
769 .filter_map(|entry| acs_entry(entry.contract_entry))
770 .collect();
771 Ok((entries, response.next_page_token))
772 }
773 })
774 .await
775 })
776 .await
777 }
778
779 /// Fetch one **page** of updates in the offset range
780 /// `(begin_offset_exclusive, end_offset_inclusive]`, optionally in reverse
781 /// (`descending`) order. Returns the page items and the next page token
782 /// (`None` once the last page has been read).
783 ///
784 /// # Errors
785 /// Returns an [`Error`] if authentication or the RPC fails.
786 pub async fn updates_page(
787 &self,
788 parties: Vec<String>,
789 begin_offset_exclusive: i64,
790 end_offset_inclusive: i64,
791 max_page_size: i32,
792 descending: bool,
793 page_token: Option<Vec<u8>>,
794 ) -> Result<(Vec<pb::GetUpdateResponse>, Option<Vec<u8>>)> {
795 let mut request = crate::request::UpdatesRequest::new(parties, begin_offset_exclusive)
796 .until(end_offset_inclusive);
797 if descending {
798 request = request.descending();
799 }
800 self.updates_page_with(&request, max_page_size, page_token)
801 .await
802 }
803
804 /// Like [`Self::updates_page`], with the full request surface of an
805 /// [`UpdatesRequest`](crate::request::UpdatesRequest) (template/interface
806 /// filters, transaction shape, descending order, created-event blobs,
807 /// topology events, non-verbose records). The request's bounds supply the
808 /// page range, so
809 /// [`UpdatesRequest::until`](crate::request::UpdatesRequest::until) is
810 /// required here — the paged read is inherently bounded.
811 ///
812 /// # Errors
813 /// Returns [`Error::InvalidRequest`] if the request has no end offset, or
814 /// another [`Error`] if authentication or the RPC fails.
815 pub async fn updates_page_with(
816 &self,
817 request: &crate::request::UpdatesRequest,
818 max_page_size: i32,
819 page_token: Option<Vec<u8>>,
820 ) -> Result<(Vec<pb::GetUpdateResponse>, Option<Vec<u8>>)> {
821 request.validate()?;
822 let (begin_exclusive, end_inclusive) = request.bounds();
823 let Some(end_inclusive) = end_inclusive else {
824 return Err(Error::InvalidRequest(
825 "updates_page_with requires a bounded request: set UpdatesRequest::until"
826 .to_string(),
827 ));
828 };
829 telemetry::instrument("updates_page", TRANSPORT_GRPC, async move {
830 self.with_retry(|| {
831 let page_request = pb::GetUpdatesPageRequest {
832 begin_offset_exclusive: Some(begin_exclusive),
833 end_offset_inclusive: Some(end_inclusive),
834 max_page_size: Some(max_page_size),
835 update_format: Some(request.update_format()),
836 descending_order: request.is_descending(),
837 page_token: page_token.clone(),
838 };
839 async move {
840 let mut client =
841 service!(self, pb::update_service_client::UpdateServiceClient::new);
842 let response = client.get_updates_page(page_request).await?.into_inner();
843 Ok((response.updates, response.next_page_token))
844 }
845 })
846 .await
847 })
848 .await
849 }
850
851 /// Stream the Active Contract Set for `parties` as of `active_at_offset`
852 /// (typically the current ledger end). Yields the active contracts,
853 /// wildcard-filtered to the given parties.
854 ///
855 /// # Errors
856 /// Returns an [`Error`] if authentication or opening the stream fails.
857 pub async fn active_contracts(
858 &self,
859 parties: Vec<String>,
860 active_at_offset: i64,
861 ) -> Result<impl Stream<Item = Result<pb::ActiveContract>> + Send + use<>> {
862 self.active_contracts_with(crate::request::ActiveContractsRequest::new(
863 parties,
864 active_at_offset,
865 ))
866 .await
867 }
868
869 /// Like [`Self::active_contracts`], with the full request surface: an
870 /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
871 /// additionally filters by template or interface, includes created-event
872 /// blobs, and drops record labels.
873 ///
874 /// # Errors
875 /// Returns an [`Error`] if authentication or opening the stream fails.
876 pub async fn active_contracts_with(
877 &self,
878 request: crate::request::ActiveContractsRequest,
879 ) -> Result<impl Stream<Item = Result<pb::ActiveContract>> + Send + use<>> {
880 let stream = self.acs_entries_with(request).await?;
881 Ok(stream.filter_map(|item| match item {
882 Ok(entry) => entry.into_active().map(Ok),
883 Err(err) => Some(Err(err)),
884 }))
885 }
886
887 /// Stream the Active Contract Set for `parties` **losslessly**: every entry
888 /// the participant sends, active or incomplete (see [`AcsEntry`]).
889 ///
890 /// # Errors
891 /// Returns an [`Error`] if authentication or opening the stream fails.
892 pub async fn acs_entries(
893 &self,
894 parties: Vec<String>,
895 active_at_offset: i64,
896 ) -> Result<impl Stream<Item = Result<AcsEntry>> + Send + use<>> {
897 self.acs_entries_with(crate::request::ActiveContractsRequest::new(
898 parties,
899 active_at_offset,
900 ))
901 .await
902 }
903
904 /// Like [`Self::acs_entries`], with the full request surface of an
905 /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
906 ///
907 /// # Errors
908 /// Returns an [`Error`] if authentication or opening the stream fails.
909 pub async fn acs_entries_with(
910 &self,
911 request: crate::request::ActiveContractsRequest,
912 ) -> Result<impl Stream<Item = Result<AcsEntry>> + Send + use<>> {
913 request.validate()?;
914 telemetry::instrument("active_contracts", TRANSPORT_GRPC, async move {
915 let mut client = service!(self, pb::state_service_client::StateServiceClient::new);
916 let stream = client
917 .get_active_contracts(pb::GetActiveContractsRequest {
918 active_at_offset: request.active_at_offset,
919 event_format: Some(request.event_format()),
920 stream_continuation_token: None,
921 })
922 .await?
923 .into_inner();
924
925 let stream = stream.filter_map(|item| match item {
926 Ok(response) => acs_entry(response.contract_entry).map(Ok),
927 Err(status) => Some(Err(Error::from(status))),
928 });
929 Ok(telemetry::instrument_stream(
930 "active_contracts",
931 TRANSPORT_GRPC,
932 stream,
933 ))
934 })
935 .await
936 }
937
938 /// Like [`Self::active_contracts`], but **resumable**: reads the ACS
939 /// snapshot page-by-page (continuation tokens), retrying a failed page on
940 /// retriable errors from the last token instead of restarting the snapshot
941 /// from zero. `max_page_size` bounds each page RPC.
942 pub fn active_contracts_resumable(
943 &self,
944 parties: Vec<String>,
945 active_at_offset: i64,
946 max_page_size: i32,
947 ) -> impl Stream<Item = Result<pb::ActiveContract>> + Send + use<> {
948 self.active_contracts_resumable_with(
949 crate::request::ActiveContractsRequest::new(parties, active_at_offset),
950 max_page_size,
951 )
952 }
953
954 /// Like [`Self::active_contracts_resumable`], with the full request
955 /// surface of an
956 /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
957 pub fn active_contracts_resumable_with(
958 &self,
959 request: crate::request::ActiveContractsRequest,
960 max_page_size: i32,
961 ) -> impl Stream<Item = Result<pb::ActiveContract>> + Send + use<> {
962 self.acs_entries_resumable_with(request, max_page_size)
963 .filter_map(|item| match item {
964 Ok(entry) => entry.into_active().map(Ok),
965 Err(err) => Some(Err(err)),
966 })
967 }
968
969 /// Like [`Self::acs_entries`], but **resumable** and page-based: reads the
970 /// snapshot page by page and retries a failed page from the last
971 /// continuation token instead of restarting from zero. Every entry the
972 /// participant sent is yielded (see [`AcsEntry`]).
973 pub fn acs_entries_resumable(
974 &self,
975 parties: Vec<String>,
976 active_at_offset: i64,
977 max_page_size: i32,
978 ) -> impl Stream<Item = Result<AcsEntry>> + Send + use<> {
979 self.acs_entries_resumable_with(
980 crate::request::ActiveContractsRequest::new(parties, active_at_offset),
981 max_page_size,
982 )
983 }
984
985 /// Like [`Self::acs_entries_resumable`], with the full request surface of
986 /// an [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
987 pub fn acs_entries_resumable_with(
988 &self,
989 request: crate::request::ActiveContractsRequest,
990 max_page_size: i32,
991 ) -> impl Stream<Item = Result<AcsEntry>> + Send + use<> {
992 let client = self.clone();
993 let (max_reconnects, backoff_unit) = client.reconnect_policy();
994 async_stream::stream! {
995 let mut page_token: Option<Vec<u8>> = None;
996 let mut reconnects = 0u32;
997 loop {
998 match client
999 .acs_page_with(
1000 &request,
1001 max_page_size,
1002 page_token.clone(),
1003 )
1004 .await
1005 {
1006 Ok((entries, next)) => {
1007 reconnects = 0;
1008 for entry in entries {
1009 yield Ok(entry);
1010 }
1011 match next {
1012 Some(next) => page_token = Some(next),
1013 None => return, // snapshot fully delivered
1014 }
1015 }
1016 Err(err) if err.is_retriable() => {
1017 reconnects += 1;
1018 if reconnects > max_reconnects {
1019 // The participant's own failure, for the reason the
1020 // update stream keeps it: the status and its
1021 // classification are what the caller acts on.
1022 tracing::warn!(
1023 max_reconnects,
1024 "acs page failed to resume; reporting the failure that caused it"
1025 );
1026 yield Err(err);
1027 return;
1028 }
1029 tokio::time::sleep(backoff_unit * reconnects).await;
1030 }
1031 Err(err) => {
1032 yield Err(err);
1033 return;
1034 }
1035 }
1036 }
1037 }
1038 }
1039
1040 /// The reconnect policy for resumable streams: `(max_reconnects,
1041 /// backoff_unit)`. Derived from the client's [`RetryConfig`] when one is
1042 /// configured (attempts → reconnect budget, initial backoff → step), else
1043 /// the defaults (5 reconnects, 250ms step).
1044 ///
1045 /// [`RetryConfig`]: canton_core::RetryConfig
1046 fn reconnect_policy(&self) -> (u32, Duration) {
1047 match self.config.retry() {
1048 Some(retry) => (retry.max_attempts, retry.initial_backoff),
1049 None => (5, Duration::from_millis(250)),
1050 }
1051 }
1052
1053 /// Stream ledger updates — transactions and reassignments — for `parties`,
1054 /// starting after `begin_offset` (exclusive). Offset checkpoints are
1055 /// filtered out. Reassignments are surfaced as their own case (each
1056 /// carrying the distinct `Unassigned`/`Assigned` events). Topology events
1057 /// are **not** included; ask for them with
1058 /// [`UpdatesRequest::with_topology_events`](crate::request::UpdatesRequest::with_topology_events)
1059 /// on [`Self::updates_with`].
1060 ///
1061 /// # Errors
1062 /// Returns an [`Error`] if authentication or opening the stream fails.
1063 pub async fn updates(
1064 &self,
1065 parties: Vec<String>,
1066 begin_offset: i64,
1067 ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1068 self.updates_with(crate::request::UpdatesRequest::new(parties, begin_offset))
1069 .await
1070 }
1071
1072 /// Like [`Self::updates`], with the full request surface: an
1073 /// [`UpdatesRequest`](crate::request::UpdatesRequest) additionally bounds
1074 /// the stream at an end offset (`until` — the catch-up/sync form, after
1075 /// which the stream terminates), filters by template or interface, selects
1076 /// the ACS-delta shape, includes created-event blobs or topology events,
1077 /// and drops reassignments or record labels.
1078 ///
1079 /// # Errors
1080 /// Returns an [`Error`] if authentication or opening the stream fails.
1081 pub async fn updates_with(
1082 &self,
1083 request: crate::request::UpdatesRequest,
1084 ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1085 let stream = self.updates_including_checkpoints(request).await?;
1086 Ok(stream.filter(|item| {
1087 !matches!(
1088 item,
1089 Ok(pb::get_updates_response::Update::OffsetCheckpoint(_))
1090 )
1091 }))
1092 }
1093
1094 /// The update stream with `OffsetCheckpoint` frames left in.
1095 ///
1096 /// A checkpoint is not an update anyone subscribed for, which is why
1097 /// [`Self::updates_with`] drops it. It is, however, the participant naming
1098 /// an offset that is safe to restart from — and it arrives on a quiet
1099 /// stream, where no transaction has moved the position for a while. The
1100 /// resumable path needs exactly that, so it reads this and filters for
1101 /// itself.
1102 async fn updates_including_checkpoints(
1103 &self,
1104 request: crate::request::UpdatesRequest,
1105 ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1106 request.validate()?;
1107 let (_, end_inclusive) = request.bounds();
1108 if request.is_descending() && end_inclusive.is_none() {
1109 return Err(Error::InvalidRequest(
1110 "descending order requires a bounded request: set UpdatesRequest::until"
1111 .to_string(),
1112 ));
1113 }
1114 telemetry::instrument("updates", TRANSPORT_GRPC, async move {
1115 let mut client = service!(self, pb::update_service_client::UpdateServiceClient::new);
1116 let stream = client.get_updates(request.into_grpc()).await?.into_inner();
1117
1118 let stream = stream.filter_map(|item| match item {
1119 Ok(response) => response.update.map(Ok),
1120 Err(status) => Some(Err(Error::from(status))),
1121 });
1122 // Instrumented for the life of the stream, not just its opening:
1123 // a subscription that fails an hour in is the failure that matters.
1124 Ok(telemetry::instrument_stream(
1125 "updates",
1126 TRANSPORT_GRPC,
1127 stream,
1128 ))
1129 })
1130 .await
1131 }
1132
1133 /// Like [`Self::updates`], but **resumable**: on a retriable stream error it
1134 /// reconnects from the last offset it *observed* (rather than restarting
1135 /// from `begin_offset` or losing position), with a short backoff and a
1136 /// bounded number of consecutive reconnects (see the client's
1137 /// [`RetryConfig`](canton_core::RetryConfig)).
1138 ///
1139 /// Observed includes the participant's `OffsetCheckpoint` frames, which are
1140 /// filtered out of what this yields but are the only thing that advances the
1141 /// resume point on a stream where nothing is happening. Once the reconnect
1142 /// budget is spent the stream yields **the failure that caused it** — the
1143 /// participant's status, details and retriable classification intact —
1144 /// rather than an error of the SDK's own.
1145 ///
1146 /// # Example
1147 /// ```no_run
1148 /// # async fn run(client: canton_ledger::CantonClient, party: String)
1149 /// # -> canton_ledger::Result<()> {
1150 /// use tokio_stream::StreamExt as _;
1151 ///
1152 /// let stream = client.updates_resumable(vec![party], 0);
1153 /// tokio::pin!(stream);
1154 /// while let Some(update) = stream.next().await {
1155 /// println!("update: {:?}", update?);
1156 /// }
1157 /// # Ok(()) }
1158 /// ```
1159 pub fn updates_resumable(
1160 &self,
1161 parties: Vec<String>,
1162 begin_offset: i64,
1163 ) -> impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<> {
1164 self.updates_resumable_with(crate::request::UpdatesRequest::new(parties, begin_offset))
1165 }
1166
1167 /// Like [`Self::updates_resumable`], with the full request surface of an
1168 /// [`UpdatesRequest`](crate::request::UpdatesRequest). A bounded request
1169 /// (`until`) makes this a *resilient catch-up read*: reconnects resume
1170 /// from the last observed offset, and the stream ends once the participant
1171 /// closes it at the end offset.
1172 pub fn updates_resumable_with(
1173 &self,
1174 request: crate::request::UpdatesRequest,
1175 ) -> impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<> {
1176 let client = self.clone();
1177 let (max_reconnects, backoff_unit) = client.reconnect_policy();
1178 async_stream::stream! {
1179 if let Err(err) = request.validate() {
1180 yield Err(err);
1181 return;
1182 }
1183 // Resume tracking assumes ascending offsets; a descending request
1184 // would silently re-read on every reconnect, so refuse it.
1185 if request.is_descending() {
1186 yield Err(Error::InvalidRequest(
1187 "the resumable stream requires ascending order; use updates_with \
1188 (bounded) for descending reads".to_string(),
1189 ));
1190 return;
1191 }
1192 let mut offset = request.begin_exclusive;
1193 let mut reconnects = 0u32;
1194 loop {
1195 // What made this reconnect necessary. Carried out of the inner
1196 // loop so that giving up reports the participant's own failure
1197 // rather than a fresh error of ours: the status, its structured
1198 // details, the correlation id and the retriable classification
1199 // are what an application branches on, and the moment the
1200 // stream gives up is when it needs them most.
1201 let cause = match client
1202 .updates_including_checkpoints(request.resume_after(offset))
1203 .await
1204 {
1205 Ok(stream) => {
1206 tokio::pin!(stream);
1207 loop {
1208 match stream.next().await {
1209 Some(Ok(update)) => {
1210 // Checkpoints are included here: on a quiet
1211 // stream a checkpoint is the only thing that
1212 // moves the resume point, and after pruning
1213 // the older offset may not be servable at all.
1214 offset = update_offset(&update);
1215 reconnects = 0;
1216 if matches!(
1217 update,
1218 pb::get_updates_response::Update::OffsetCheckpoint(_)
1219 ) {
1220 continue;
1221 }
1222 yield Ok(update);
1223 }
1224 Some(Err(err)) if err.is_retriable() => break err,
1225 Some(Err(err)) => {
1226 yield Err(err);
1227 return;
1228 }
1229 None => return, // server closed the stream cleanly
1230 }
1231 }
1232 }
1233 Err(err) if err.is_retriable() => err,
1234 Err(err) => {
1235 yield Err(err);
1236 return;
1237 }
1238 };
1239
1240 reconnects += 1;
1241 if reconnects > max_reconnects {
1242 tracing::warn!(
1243 max_reconnects,
1244 offset,
1245 "update stream gave up resuming; reporting the failure that caused it"
1246 );
1247 yield Err(cause);
1248 return;
1249 }
1250 tokio::time::sleep(backoff_unit * reconnects).await;
1251 }
1252 }
1253 }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use super::CantonClient;
1259 use crate::Config;
1260 use canton_core::Error;
1261
1262 // Compile-time guarantee that the client is Send + Sync, so consumers can
1263 // share it across tasks and `tokio::spawn` work that holds it. (The streaming
1264 // methods carry `+ Send` in their signatures, which the compiler enforces at
1265 // this crate's build time.)
1266 #[test]
1267 fn client_is_send_and_sync() {
1268 fn assert_send_sync<T: Send + Sync>() {}
1269 assert_send_sync::<CantonClient>();
1270 }
1271
1272 #[tokio::test]
1273 async fn connect_lazy_accepts_a_valid_endpoint() {
1274 // connect_lazy builds a lazy Channel, which needs a Tokio runtime.
1275 assert!(CantonClient::connect_lazy(Config::new("http://localhost:3901")).is_ok());
1276 }
1277
1278 #[tokio::test]
1279 async fn connect_lazy_rejects_a_malformed_endpoint() {
1280 for bad in ["", "not a uri", "http://[bad"] {
1281 let result = CantonClient::connect_lazy(Config::new(bad));
1282 assert!(
1283 matches!(result, Err(Error::InvalidRequest(_))),
1284 "endpoint {bad:?} should be rejected as InvalidRequest, got {result:?}"
1285 );
1286 }
1287 }
1288
1289 // The paged read is inherently bounded, so a request without an end offset
1290 // must be refused before any RPC is attempted.
1291 // Every one of these is refused before a connection is even attempted: the
1292 // client is pointed at a port nothing listens on, so anything that reaches
1293 // the wire fails differently.
1294 #[tokio::test]
1295 async fn requests_the_participant_would_refuse_are_refused_here() {
1296 use crate::request::{ActiveContractsRequest, CompletionsRequest, UpdatesRequest};
1297
1298 let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1299 panic!("lazy connect accepts a valid endpoint");
1300 };
1301 let party = || vec!["alice".to_string()];
1302 let invalid = |result: &Result<_, Error>| matches!(result, Err(Error::InvalidRequest(_)));
1303
1304 // Negative offsets.
1305 assert!(invalid(
1306 &client
1307 .updates_with(UpdatesRequest::new(party(), -1))
1308 .await
1309 .map(|_| ())
1310 ));
1311 assert!(invalid(
1312 &client
1313 .acs_entries_with(ActiveContractsRequest::new(party(), -5))
1314 .await
1315 .map(|_| ())
1316 ));
1317 assert!(invalid(
1318 &client
1319 .completions_with(CompletionsRequest::new(party(), -1))
1320 .await
1321 .map(|_| ())
1322 ));
1323
1324 // A range that ends before it begins.
1325 assert!(invalid(
1326 &client
1327 .updates_with(UpdatesRequest::new(party(), 100).until(50))
1328 .await
1329 .map(|_| ())
1330 ));
1331
1332 // No parties: a subscription that looks alive and can never yield.
1333 assert!(invalid(
1334 &client
1335 .updates_with(UpdatesRequest::new(vec![], 0))
1336 .await
1337 .map(|_| ())
1338 ));
1339 }
1340
1341 #[tokio::test]
1342 async fn submissions_that_cannot_succeed_are_refused_here() {
1343 use crate::command::Submit;
1344
1345 let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1346 panic!("lazy connect accepts a valid endpoint");
1347 };
1348
1349 // No commands: a round trip to be told nothing was asked for.
1350 let empty = client.submission(Submit::new("alice"));
1351 assert!(matches!(
1352 empty.submit().await,
1353 Err(Error::InvalidRequest(_))
1354 ));
1355
1356 // The two minimum-ledger-time forms are mutually exclusive.
1357 let both = client.submission(
1358 Submit::new("alice")
1359 .add_command(crate::command::create(
1360 crate::command::identifier("pkg", "M", "T"),
1361 crate::command::record(vec![]),
1362 ))
1363 .with_min_ledger_time_rel(std::time::Duration::from_secs(1))
1364 .with_min_ledger_time_abs(prost_types::Timestamp::default()),
1365 );
1366 assert!(matches!(both.submit().await, Err(Error::InvalidRequest(_))));
1367 }
1368
1369 #[tokio::test]
1370 async fn updates_page_with_requires_a_bounded_request() {
1371 let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1372 panic!("lazy connect accepts a valid endpoint");
1373 };
1374 let unbounded = crate::request::UpdatesRequest::new(vec!["p".to_string()], 0);
1375 let result = client.updates_page_with(&unbounded, 10, None).await;
1376 assert!(
1377 matches!(result, Err(Error::InvalidRequest(_))),
1378 "expected InvalidRequest, got {result:?}"
1379 );
1380 }
1381
1382 // Descending order needs an end offset (streams), and never composes with
1383 // resume tracking — both must fail fast, before any RPC.
1384 #[tokio::test]
1385 async fn descending_requires_bounds_and_is_refused_on_resumable() {
1386 use tokio_stream::StreamExt as _;
1387
1388 let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1389 panic!("lazy connect accepts a valid endpoint");
1390 };
1391 let descending_unbounded =
1392 crate::request::UpdatesRequest::new(vec!["p".to_string()], 0).descending();
1393
1394 let result = client.updates_with(descending_unbounded.clone()).await;
1395 assert!(matches!(result.err(), Some(Error::InvalidRequest(_))));
1396
1397 let stream = client.updates_resumable_with(descending_unbounded.until(10));
1398 tokio::pin!(stream);
1399 let Some(first) = stream.next().await else {
1400 panic!("the resumable stream should yield the rejection");
1401 };
1402 assert!(matches!(first, Err(Error::InvalidRequest(_))));
1403 }
1404
1405 // A reassignment carries its `Unassigned` (source) and `Assigned` (target)
1406 // events as distinct cases rather than collapsing them into one "reassign"
1407 // event — the multi-synchronizer faithfulness the update stream promises.
1408 // (LocalNet is single-synchronizer, so this is verified structurally.)
1409 #[test]
1410 fn reassignment_preserves_the_unassigned_assigned_split() {
1411 use super::pb;
1412 use pb::get_updates_response::Update;
1413 use pb::reassignment_event::Event;
1414
1415 let update = Update::Reassignment(pb::Reassignment {
1416 update_id: "u1".to_string(),
1417 events: vec![
1418 pb::ReassignmentEvent {
1419 event: Some(Event::Unassigned(pb::UnassignedEvent::default())),
1420 },
1421 pb::ReassignmentEvent {
1422 event: Some(Event::Assigned(pb::AssignedEvent::default())),
1423 },
1424 ],
1425 ..Default::default()
1426 });
1427
1428 let Update::Reassignment(reassignment) = update else {
1429 panic!("expected a reassignment update");
1430 };
1431 assert_eq!(reassignment.events.len(), 2, "both legs are surfaced");
1432 assert!(matches!(
1433 reassignment.events[0].event,
1434 Some(Event::Unassigned(_))
1435 ));
1436 assert!(matches!(
1437 reassignment.events[1].event,
1438 Some(Event::Assigned(_))
1439 ));
1440 }
1441}