Skip to main content

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}