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/// Give an execute request a submission id if it has none.
46///
47/// The change ID de-duplicates a submission; the submission id only tells the
48/// completions of separate *attempts* apart, and the Ledger API asks for a
49/// different one on each retry. Generating it per attempt rather than when the
50/// request is built is what makes attempts distinguishable while leaving the
51/// change ID — and therefore the de-duplication — untouched.
52trait HasSubmissionId {
53    fn submission_id_mut(&mut self) -> &mut String;
54}
55
56macro_rules! has_submission_id {
57    ($($t:ty),+) => {$(
58        impl HasSubmissionId for $t {
59            fn submission_id_mut(&mut self) -> &mut String {
60                &mut self.submission_id
61            }
62        }
63    )+};
64}
65
66has_submission_id!(
67    pb::interactive::ExecuteSubmissionRequest,
68    pb::interactive::ExecuteSubmissionAndWaitRequest,
69    pb::interactive::ExecuteSubmissionAndWaitForTransactionRequest
70);
71
72fn fresh_submission_id<T: HasSubmissionId>(mut request: T) -> T {
73    let id = request.submission_id_mut();
74    if id.is_empty() {
75        *id = uuid::Uuid::new_v4().to_string();
76    }
77    request
78}
79
80/// Whether a failed submission is the participant refusing a command it already
81/// has — and, on a retry, one this client is responsible for having sent.
82///
83/// `ALREADY_EXISTS` is what Canton's `DUPLICATE_COMMAND` arrives as. On a first
84/// attempt it means the *caller* reused a change ID from an earlier submission,
85/// which is a genuine rejection they must see. On a retry it means our own
86/// previous attempt was accepted, which is the opposite of a failure — so the
87/// caller of this predicate checks that first.
88fn is_duplicate_of_our_own(status: &tonic::Status) -> bool {
89    status.code() == tonic::Code::AlreadyExists
90}
91
92/// Refuse a command set the participant is certain to refuse.
93///
94/// Every one of these costs a round trip and comes back as a server-side error
95/// that reads like the ledger's fault. They are the caller's, and they are
96/// knowable here.
97fn validate_commands(commands: &pb::Commands) -> Result<()> {
98    if commands.act_as.is_empty() {
99        return Err(Error::InvalidRequest(
100            "a submission needs at least one acting party".to_string(),
101        ));
102    }
103    if commands.commands.is_empty() {
104        return Err(Error::InvalidRequest(
105            "a submission needs at least one command".to_string(),
106        ));
107    }
108    if commands.min_ledger_time_abs.is_some() && commands.min_ledger_time_rel.is_some() {
109        return Err(Error::InvalidRequest(
110            "min_ledger_time_abs and min_ledger_time_rel are mutually exclusive: set one"
111                .to_string(),
112        ));
113    }
114    Ok(())
115}
116
117/// One entry of an Active Contract Set snapshot.
118///
119/// A snapshot is not only active contracts. A reassignment that is half-done at
120/// the snapshot offset appears as an *incomplete* entry — unassigned from one
121/// synchronizer with no matching assignment yet, or assigned to one with the
122/// unassignment out of view — and an application that reads only
123/// [`Active`](Self::Active) sees a contract that has left one synchronizer and
124/// not arrived at the other simply vanish. The Ledger API sends these precisely
125/// so a multi-synchronizer application can bootstrap without that hole, which is
126/// why there is a lossless read.
127///
128/// `#[non_exhaustive]`: the Ledger API may add an entry kind.
129#[derive(Clone, Debug, PartialEq)]
130#[non_exhaustive]
131pub enum AcsEntry {
132    /// A contract active on the synchronizer named in the entry.
133    Active(pb::ActiveContract),
134    /// Unassigned before the snapshot offset with no assignment before it —
135    /// the contract is in flight *out of* this synchronizer.
136    IncompleteUnassigned(pb::IncompleteUnassigned),
137    /// Assigned before the snapshot offset with no unassignment before it.
138    /// Note this does **not** say the contract is active on the target
139    /// synchronizer; the proto is explicit about that.
140    IncompleteAssigned(pb::IncompleteAssigned),
141}
142
143impl AcsEntry {
144    /// The active contract, if this entry is one.
145    #[must_use]
146    pub fn active(&self) -> Option<&pb::ActiveContract> {
147        match self {
148            AcsEntry::Active(active) => Some(active),
149            _ => None,
150        }
151    }
152
153    /// Consume the entry, yielding the active contract if it is one.
154    #[must_use]
155    pub fn into_active(self) -> Option<pb::ActiveContract> {
156        match self {
157            AcsEntry::Active(active) => Some(active),
158            _ => None,
159        }
160    }
161}
162
163/// Convert one wire entry, dropping only the case the participant left empty
164/// (the `oneof` is required, so an empty entry is a participant that sent
165/// nothing to report).
166fn acs_entry(entry: Option<pb::get_active_contracts_response::ContractEntry>) -> Option<AcsEntry> {
167    use pb::get_active_contracts_response::ContractEntry;
168    match entry {
169        Some(ContractEntry::ActiveContract(active)) => Some(AcsEntry::Active(active)),
170        Some(ContractEntry::IncompleteUnassigned(unassigned)) => {
171            Some(AcsEntry::IncompleteUnassigned(unassigned))
172        }
173        Some(ContractEntry::IncompleteAssigned(assigned)) => {
174            Some(AcsEntry::IncompleteAssigned(assigned))
175        }
176        None => None,
177    }
178}
179
180/// The offset of an update, for resumable-stream position tracking.
181fn update_offset(update: &pb::get_updates_response::Update) -> i64 {
182    use pb::get_updates_response::Update;
183    match update {
184        Update::Transaction(t) => t.offset,
185        Update::Reassignment(r) => r.offset,
186        Update::TopologyTransaction(t) => t.offset,
187        Update::OffsetCheckpoint(c) => c.offset,
188    }
189}
190
191/// Build a gRPC service client on the authenticated channel, with this
192/// client's decode limit applied.
193///
194/// A macro rather than a function because `max_decoding_message_size` is an
195/// inherent method on each generated client — tonic exposes no trait for it —
196/// so there is nothing to be generic over. Keeping every construction site
197/// behind one expansion is the point: `tonic`'s 4 MiB default is small enough
198/// that a real ACS page trips it, and a new RPC added later would otherwise
199/// pick the default up silently.
200macro_rules! service {
201    ($self:ident, $ctor:expr) => {
202        $ctor($self.intercepted().await?)
203            .max_decoding_message_size($self.config.max_decoding_message_size())
204    };
205}
206
207/// An async client for the Canton Ledger API over gRPC.
208///
209/// The client owns a lazily-connected [`Channel`]; cloning it is cheap and
210/// clones share the underlying connection pool, so it is safe to hand a clone
211/// to each task.
212#[derive(Clone, Debug)]
213pub struct CantonClient {
214    channel: Channel,
215    config: Arc<Config>,
216}
217
218impl CantonClient {
219    /// Build a lazily-connected client. Returns immediately; the TCP/TLS
220    /// handshake happens on the first RPC.
221    ///
222    /// # Errors
223    /// Returns [`Error::InvalidRequest`] if the endpoint is not a valid URI.
224    pub fn connect_lazy(config: Config) -> Result<Self> {
225        Ok(Self {
226            channel: config.connect_channel()?,
227            config: Arc::new(config),
228        })
229    }
230
231    /// A channel wrapped with a fresh bearer-token interceptor for this call.
232    async fn intercepted(&self) -> Result<Intercepted> {
233        auth::intercepted(&self.channel, self.config.auth()).await
234    }
235
236    /// Run a unary operation under the configured retry policy (no-op when
237    /// retrying is not enabled).
238    async fn with_retry<T, F, Fut>(&self, op: F) -> Result<T>
239    where
240        F: FnMut() -> Fut,
241        Fut: std::future::Future<Output = Result<T>>,
242    {
243        canton_core::retry::run_with_retry(self.config.retry(), op).await
244    }
245
246    /// Return the participant's Ledger API version string (e.g. `"3.5.7"`).
247    ///
248    /// # Errors
249    /// Returns an [`Error`] if the RPC fails.
250    pub async fn version(&self) -> Result<String> {
251        telemetry::instrument("version", TRANSPORT_GRPC, async {
252            self.with_retry(|| async {
253                let mut client =
254                    service!(self, pb::version_service_client::VersionServiceClient::new);
255                let response = client
256                    .get_ledger_api_version(pb::GetLedgerApiVersionRequest {})
257                    .await?
258                    .into_inner();
259                Ok(response.version)
260            })
261            .await
262        })
263        .await
264    }
265
266    /// Probe the participant's overall serving status via the standard
267    /// `grpc.health.v1.Health` service (served on the Ledger API port).
268    ///
269    /// Poll this to react to intermittent or permanent node failure: a healthy
270    /// participant answers [`ServingStatus::Serving`]; an unreachable one
271    /// surfaces a transport [`Error`] (see [`Error::is_retriable`]).
272    ///
273    /// # Errors
274    /// Returns an [`Error`] if the health RPC fails (e.g. the node is down).
275    pub async fn health_check(&self) -> Result<ServingStatus> {
276        telemetry::instrument("health_check", TRANSPORT_GRPC, async {
277            self.with_retry(|| async {
278                let mut client = service!(self, health_pb::health_client::HealthClient::new);
279                // Empty `service` = the server's overall status (Canton does not
280                // register per-service health entries on the Ledger API port).
281                let response = client
282                    .check(health_pb::HealthCheckRequest {
283                        service: String::new(),
284                    })
285                    .await?
286                    .into_inner();
287                Ok(response.status())
288            })
289            .await
290        })
291        .await
292    }
293
294    /// Submit commands **fire-and-forget** (`CommandSubmissionService.Submit`):
295    /// hand the commands to the participant and return promptly without waiting
296    /// for the transaction. Returns the change-ID `command_id` used, so the
297    /// caller can recover the outcome later with [`Self::await_completion`] (or
298    /// the [`Self::completions`] stream).
299    ///
300    /// A fresh UUID `command_id` is generated when the caller did not set one,
301    /// so ledger-side de-duplication behaves correctly across retries.
302    ///
303    /// # Errors
304    /// Returns an [`Error`] if authentication fails or the participant rejects
305    /// the submission synchronously (e.g. a preprocessing error).
306    pub async fn submit(&self, submit: crate::command::Submit) -> Result<String> {
307        let submission = self.submission(submit);
308        let command_id = submission.change_id().command_id().to_string();
309        submission.submit().await?;
310        Ok(command_id)
311    }
312
313    /// Fix a submission's identity **before** sending it, returning a
314    /// [`Submission`](crate::Submission) that carries its
315    /// [`ChangeId`](crate::ChangeId).
316    ///
317    /// This is the handle to reach for when losing the outcome is not an
318    /// option. A submission whose response is lost — a dropped connection, a
319    /// timeout, a retry the participant de-duplicated — may well have
320    /// committed, and the only way back to the answer is the change ID. If the
321    /// SDK generated the command id inside the call that failed, there is no
322    /// change ID to go back with.
323    ///
324    /// ```no_run
325    /// # async fn run(client: canton_ledger::CantonClient, submit: canton_ledger::Submit)
326    /// #     -> canton_ledger::Result<()> {
327    /// use std::time::Duration;
328    ///
329    /// // Where to start reading completions from, taken before submitting.
330    /// let offset = client.ledger_end().await?;
331    /// let submission = client.submission(submit);
332    ///
333    /// if submission.submit_and_wait().await.is_err() {
334    ///     // Ambiguous: ask the ledger what actually happened.
335    ///     let completion = submission.recover(offset, Duration::from_secs(30)).await?;
336    ///     println!("committed after all: {}", completion.update_id);
337    /// }
338    /// # Ok(()) }
339    /// ```
340    #[must_use]
341    pub fn submission(&self, submit: crate::command::Submit) -> crate::submission::Submission {
342        let shape = submit.transaction_shape;
343        let (change_id, commands) = submit.into_commands();
344        crate::submission::Submission::new(self.clone(), change_id, commands, shape)
345    }
346
347    pub(crate) async fn submit_commands(&self, commands: pb::Commands) -> Result<()> {
348        validate_commands(&commands)?;
349        telemetry::instrument("submit", TRANSPORT_GRPC, async move {
350            let attempt = std::sync::atomic::AtomicU32::new(0);
351            let command_id = commands.command_id.clone();
352            self.with_retry(|| {
353                let commands = commands.clone();
354                let command_id = command_id.clone();
355                let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
356                async move {
357                    let mut client = service!(
358                        self,
359                        pb::command_submission_service_client::CommandSubmissionServiceClient::new
360                    );
361                    match client
362                        .submit(pb::SubmitRequest {
363                            commands: Some(commands),
364                        })
365                        .await
366                    {
367                        Ok(_) => Ok(()),
368                        Err(status) if retry && is_duplicate_of_our_own(&status) => {
369                            // The command we are retrying is already at the
370                            // participant, and we are the ones who put it
371                            // there: a previous attempt of this same retry loop
372                            // was accepted and its response was lost. Reporting
373                            // the duplicate rejection would tell the caller
374                            // their command failed when it did precisely the
375                            // opposite — the failure mode this whole change ID
376                            // exists to prevent.
377                            tracing::debug!(
378                                %command_id,
379                                "submission retry was de-duplicated; the earlier attempt is the one that landed"
380                            );
381                            Ok(())
382                        }
383                        Err(status) => Err(Error::from(status)),
384                    }
385                }
386            })
387            .await
388        })
389        .await
390    }
391
392    /// Submit commands and wait for the result **without** fetching the
393    /// transaction (`CommandService.SubmitAndWait`): blocks until the command
394    /// commits (or is rejected) and returns the `update_id` and completion
395    /// offset. Lighter than [`Self::submit_and_wait_for_transaction`] when the
396    /// caller does not need the event payload.
397    ///
398    /// # Errors
399    /// Returns an [`Error`] if authentication fails or the command is rejected.
400    /// The retry caveat on [`Self::submit_and_wait_for_transaction`] applies.
401    pub async fn submit_and_wait(
402        &self,
403        submit: crate::command::Submit,
404    ) -> Result<pb::SubmitAndWaitResponse> {
405        self.submission(submit).submit_and_wait().await
406    }
407
408    pub(crate) async fn submit_and_wait_commands(
409        &self,
410        commands: pb::Commands,
411    ) -> Result<pb::SubmitAndWaitResponse> {
412        validate_commands(&commands)?;
413        telemetry::instrument("submit_and_wait", TRANSPORT_GRPC, async move {
414            let request = pb::SubmitAndWaitRequest {
415                commands: Some(commands),
416            };
417            self.with_retry(|| {
418                let request = request.clone();
419                async move {
420                    let mut client =
421                        service!(self, pb::command_service_client::CommandServiceClient::new);
422                    Ok(client.submit_and_wait(request).await?.into_inner())
423                }
424            })
425            .await
426        })
427        .await
428    }
429
430    /// Submit commands and wait for the resulting transaction.
431    ///
432    /// Fills the change ID's `command_id` with a fresh UUID when the caller did
433    /// not set one, so ledger-side de-duplication behaves correctly. The
434    /// returned transaction is shaped as `LEDGER_EFFECTS` and filtered to the
435    /// acting party (wildcard), so created events are visible in the response.
436    ///
437    /// # Errors
438    /// Returns an [`Error`] if authentication fails, the command is rejected, or
439    /// the response contains no transaction.
440    ///
441    /// # Example
442    /// ```no_run
443    /// # async fn run(client: canton_ledger::CantonClient, party: &str, pkg: &str)
444    /// #     -> canton_ledger::Result<()> {
445    /// use canton_ledger::{Submit, create, identifier, record};
446    ///
447    /// let tx = client
448    ///     .submit_and_wait_for_transaction(
449    ///         Submit::new(party)
450    ///             .add_command(create(identifier(pkg, "M", "T"), record(vec![]))),
451    ///     )
452    ///     .await?;
453    /// println!("committed {} at offset {}", tx.update_id, tx.offset);
454    /// # Ok(()) }
455    /// ```
456    ///
457    /// # Retry caveat (exactly-once)
458    /// With retry enabled ([`Config::with_retry`]), a submission that commits
459    /// on the ledger but whose response is lost to a retriable error is re-sent
460    /// with the same `command_id` and de-duplicated by the participant.
461    ///
462    /// [`Self::submit`] can answer that on its own: a duplicate rejection of a
463    /// retry it made itself means its earlier attempt was accepted, which is
464    /// success, so it reports success. **This method cannot.** Its result is
465    /// the committed transaction, and a de-duplicated retry does not carry one
466    /// — so the duplicate rejection reaches the caller, and the transaction has
467    /// to be read back rather than invented.
468    ///
469    /// Take a [`Submission`](crate::Submission) from [`Self::submission`]
470    /// before submitting, and on any error recover the outcome with
471    /// [`Submission::recover`](crate::Submission::recover) — it knows the
472    /// change ID, which is the only way back to a command whose response was
473    /// lost. See the `recover_a_submission` example.
474    pub async fn submit_and_wait_for_transaction(
475        &self,
476        submit: crate::command::Submit,
477    ) -> Result<pb::Transaction> {
478        self.submission(submit)
479            .submit_and_wait_for_transaction()
480            .await
481    }
482
483    pub(crate) async fn submit_and_wait_for_transaction_commands(
484        &self,
485        commands: pb::Commands,
486        shape: crate::request::TransactionShape,
487    ) -> Result<pb::Transaction> {
488        validate_commands(&commands)?;
489        // The filter covers `read_as` as well as `act_as`: the Ledger API's own
490        // default for a submission is both, and a command submitted with
491        // `read_as` set is one whose result the caller expects to see through
492        // those parties too. Filtering to `act_as` alone silently returned a
493        // transaction with events missing.
494        let mut parties = commands.act_as.clone();
495        parties.extend(commands.read_as.iter().cloned());
496        parties.sort_unstable();
497        parties.dedup();
498        let request = pb::SubmitAndWaitForTransactionRequest {
499            transaction_format: Some(pb::TransactionFormat {
500                event_format: Some(wildcard_event_format(&parties)),
501                transaction_shape: shape.as_grpc() as i32,
502            }),
503            commands: Some(commands),
504        };
505        telemetry::instrument(
506            "submit_and_wait_for_transaction",
507            TRANSPORT_GRPC,
508            async move {
509                let response = self
510                    .with_retry(|| {
511                        let request = request.clone();
512                        async move {
513                            let mut client = service!(
514                                self,
515                                pb::command_service_client::CommandServiceClient::new
516                            );
517                            Ok(client
518                                .submit_and_wait_for_transaction(request)
519                                .await?
520                                .into_inner())
521                        }
522                    })
523                    .await?;
524
525                response.transaction.ok_or_else(|| {
526                    Error::UnexpectedResponse("response contained no transaction".to_string())
527                })
528            },
529        )
530        .await
531    }
532
533    /// The synchronizers this participant is connected to for `party`.
534    ///
535    /// Allocating an external party and preparing a submission both name a
536    /// synchronizer, and a caller has no other way to discover one — so this
537    /// is what turns "which synchronizer?" from configuration into a question
538    /// the participant answers.
539    ///
540    /// # Errors
541    /// Returns an [`Error`] if the RPC fails.
542    pub async fn connected_synchronizers(
543        &self,
544        party: &str,
545    ) -> Result<Vec<pb::get_connected_synchronizers_response::ConnectedSynchronizer>> {
546        let request = pb::GetConnectedSynchronizersRequest {
547            party: party.to_string(),
548            ..Default::default()
549        };
550        telemetry::instrument("connected_synchronizers", TRANSPORT_GRPC, async move {
551            let response = self
552                .with_retry(|| {
553                    let request = request.clone();
554                    async move {
555                        let mut client =
556                            service!(self, pb::state_service_client::StateServiceClient::new);
557                        Ok(client
558                            .get_connected_synchronizers(request)
559                            .await?
560                            .into_inner())
561                    }
562                })
563                .await?;
564            Ok(response.connected_synchronizers)
565        })
566        .await
567    }
568
569    /// Interpret a submission without authorizing it, returning the transaction
570    /// and the hash to sign.
571    ///
572    /// The first half of interactive submission: the participant works out what
573    /// the commands do, and hands back a hash for the party's key — which it
574    /// does not hold — to sign. See [`crate::interactive`] for the flow, and
575    /// for how long a prepared transaction stays executable.
576    ///
577    /// Retried like any other read: preparation commits nothing.
578    ///
579    /// # Errors
580    /// As any gRPC call, plus [`Error::UnexpectedResponse`] if the participant
581    /// returns no hash — which would leave nothing to sign.
582    pub async fn prepare_submission(
583        &self,
584        prepare: crate::interactive::Prepare,
585    ) -> Result<crate::interactive::Prepared> {
586        // Built once, outside the instrumented future, so retries reuse one
587        // command id — the change ID stays stable and the submission remains
588        // de-duplication-safe across attempts, exactly as `submit` does.
589        let act_as = prepare.act_as().to_vec();
590        // Carried through: the Ledger API's default event format for
591        // `…_and_wait_for_transaction` covers `act_as` *and* `read_as`, so
592        // dropping it here would silently return a narrower transaction.
593        let read_as = prepare.read_as().to_vec();
594        let requested_scheme = prepare.requested_hashing_scheme();
595        let trust_participant_hash = prepare.trusts_participant_hash();
596        let (command_id, user_id, request) = prepare.into_request();
597        telemetry::instrument("prepare_submission", TRANSPORT_GRPC, async move {
598            let response = self
599                .with_retry(|| {
600                    let request = request.clone();
601                    async move {
602                        let mut client = service!(
603                            self,
604                            pb::interactive::interactive_submission_service_client::InteractiveSubmissionServiceClient::new
605                        );
606                        Ok(client.prepare_submission(request).await?.into_inner())
607                    }
608                })
609                .await?;
610            crate::interactive::Prepared::from_response(
611                response,
612                requested_scheme,
613                trust_participant_hash,
614                act_as,
615                read_as,
616                command_id,
617                user_id,
618            )
619        })
620        .await
621    }
622
623    /// Submit a signed prepared transaction and return once the participant has
624    /// accepted it for processing.
625    ///
626    /// Acceptance is not commitment: the outcome arrives on the completion
627    /// stream. Use [`execute_submission_and_wait`](Self::execute_submission_and_wait)
628    /// to wait for it.
629    ///
630    /// # Retry caveat
631    /// With retry enabled, an attempt whose response was lost is retried under
632    /// a fresh submission id and the same change id, and the participant
633    /// answers `ALREADY_EXISTS`: that is the earlier attempt having landed,
634    /// and it is reported as success here, as on
635    /// [`submit`](Self::submit). An `Ok` still means only
636    /// *accepted*; whether it committed is a completion, reachable by
637    /// [`await_completion`](Self::await_completion) with the
638    /// [`Executable::change_id`](crate::interactive::Executable::change_id)
639    /// taken before the call.
640    ///
641    /// # Errors
642    /// As any gRPC call. A missing or wrong signature is a rejection from the
643    /// participant, not a local error.
644    pub async fn execute_submission(
645        &self,
646        executable: crate::interactive::Executable,
647    ) -> Result<()> {
648        let request = executable.into_execute_request();
649        telemetry::instrument("execute_submission", TRANSPORT_GRPC, async move {
650            let attempt = std::sync::atomic::AtomicU32::new(0);
651            self.with_retry(|| {
652                let request = fresh_submission_id(request.clone());
653                let retry = attempt.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0;
654                async move {
655                    let mut client = service!(
656                        self,
657                        pb::interactive::interactive_submission_service_client::InteractiveSubmissionServiceClient::new
658                    );
659                    match client.execute_submission(request).await {
660                        Ok(_) => Ok(()),
661                        Err(status) if retry && is_duplicate_of_our_own(&status) => {
662                            // Same reasoning as the ordinary submit: a
663                            // previous attempt of this loop was accepted and
664                            // its answer lost, and reporting the duplicate as
665                            // a failure would say the opposite of what
666                            // happened.
667                            tracing::debug!(
668                                "execute retry was de-duplicated; the earlier attempt is the one that landed"
669                            );
670                            Ok(())
671                        }
672                        Err(status) => Err(Error::from(status)),
673                    }
674                }
675            })
676            .await
677        })
678        .await
679    }
680
681    /// Submit a signed prepared transaction and wait for it to commit.
682    ///
683    /// The response carries the update id **and** the completion offset — the
684    /// offset a follow-up completion or update read starts from, which is why
685    /// it is returned whole rather than as the id alone.
686    ///
687    /// # Retry caveat
688    /// As [`execute_submission`](Self::execute_submission): a retried attempt
689    /// that the participant de-duplicates is reported as the rejection it
690    /// answers with, so on an ambiguous outcome recover through
691    /// [`await_completion`](Self::await_completion) with the change id taken
692    /// before the call.
693    ///
694    /// # Errors
695    /// As any gRPC call, plus a rejection if the transaction fails.
696    pub async fn execute_submission_and_wait(
697        &self,
698        executable: crate::interactive::Executable,
699    ) -> Result<pb::interactive::ExecuteSubmissionAndWaitResponse> {
700        let request = executable.into_execute_and_wait_request();
701        telemetry::instrument("execute_submission_and_wait", TRANSPORT_GRPC, async move {
702            let response = self
703                .with_retry(|| {
704                    let request = fresh_submission_id(request.clone());
705                    async move {
706                        let mut client = service!(
707                            self,
708                            pb::interactive::interactive_submission_service_client::InteractiveSubmissionServiceClient::new
709                        );
710                        Ok(client
711                            .execute_submission_and_wait(request)
712                            .await?
713                            .into_inner())
714                    }
715                })
716                .await?;
717            Ok(response)
718        })
719        .await
720    }
721
722    /// Submit a signed prepared transaction and wait for the committed
723    /// transaction itself.
724    ///
725    /// The events it carries follow
726    /// [`Executable::with_transaction_shape`](crate::interactive::Executable::with_transaction_shape).
727    ///
728    /// # Errors
729    /// As any gRPC call, plus [`Error::UnexpectedResponse`] if the participant
730    /// reports success without a transaction.
731    pub async fn execute_submission_and_wait_for_transaction(
732        &self,
733        executable: crate::interactive::Executable,
734    ) -> Result<pb::Transaction> {
735        let event_format = wildcard_event_format(&executable.witnesses());
736        let request = executable.into_execute_and_wait_for_transaction_request(event_format);
737        telemetry::instrument(
738            "execute_submission_and_wait_for_transaction",
739            TRANSPORT_GRPC,
740            async move {
741                let response = self
742                    .with_retry(|| {
743                        let request = fresh_submission_id(request.clone());
744                        async move {
745                            let mut client = service!(
746                                self,
747                                pb::interactive::interactive_submission_service_client::InteractiveSubmissionServiceClient::new
748                            );
749                            Ok(client
750                                .execute_submission_and_wait_for_transaction(request)
751                                .await?
752                                .into_inner())
753                        }
754                    })
755                    .await?;
756
757                response.transaction.ok_or_else(|| {
758                    Error::UnexpectedResponse("response contained no transaction".to_string())
759                })
760            },
761        )
762        .await
763    }
764
765    /// Subscribe to the command-completion stream for `parties`, starting after
766    /// `begin_offset` (exclusive). Offset checkpoints are filtered out, so the
767    /// stream yields only [`pb::Completion`]s.
768    ///
769    /// # Errors
770    /// Returns an [`Error`] if authentication or opening the stream fails.
771    pub async fn completions(
772        &self,
773        parties: Vec<String>,
774        begin_offset: i64,
775    ) -> Result<impl Stream<Item = Result<pb::Completion>> + Send + use<>> {
776        self.completions_with(crate::request::CompletionsRequest::new(
777            parties,
778            begin_offset,
779        ))
780        .await
781    }
782
783    /// Like [`Self::completions`], with the full request surface: a
784    /// [`CompletionsRequest`](crate::request::CompletionsRequest) additionally
785    /// selects the `user_id` whose command completions to stream (pair it with
786    /// [`Submit::with_user_id`](crate::Submit::with_user_id)).
787    ///
788    /// # Errors
789    /// Returns an [`Error`] if authentication or opening the stream fails.
790    pub async fn completions_with(
791        &self,
792        request: crate::request::CompletionsRequest,
793    ) -> Result<impl Stream<Item = Result<pb::Completion>> + Send + use<>> {
794        request.validate()?;
795        telemetry::instrument("completions", TRANSPORT_GRPC, async move {
796            let mut client = service!(
797                self,
798                pb::command_completion_service_client::CommandCompletionServiceClient::new
799            );
800            let stream = client
801                .completion_stream(request.into_grpc())
802                .await?
803                .into_inner();
804
805            let stream = stream.filter_map(|item| match item {
806                Ok(response) => match response.completion_response {
807                    Some(pb::completion_stream_response::CompletionResponse::Completion(
808                        completion,
809                    )) => Some(Ok(completion)),
810                    _ => None, // skip offset checkpoints
811                },
812                Err(status) => Some(Err(Error::from(status))),
813            });
814            Ok(telemetry::instrument_stream(
815                "completions",
816                TRANSPORT_GRPC,
817                stream,
818            ))
819        })
820        .await
821    }
822
823    /// Recover the completion for a specific command by scanning the completion
824    /// stream from `begin_offset`, up to `timeout`.
825    ///
826    /// This is the command-recovery path: after a crash, lost connection, or
827    /// timeout, the outcome of a pending command is read back from the
828    /// completion endpoint instead of blindly re-submitting. If the command's
829    /// completion reports a non-OK status, this returns [`Error::CommandRejected`].
830    ///
831    /// Matching is on the whole [`ChangeId`](crate::ChangeId) — user, acting
832    /// parties and command id — because that is what identifies a command to
833    /// Canton. A command id on its own is not unique across the users of a
834    /// participant, and answering with somebody else's completion is worse
835    /// than answering with none.
836    ///
837    /// The completion stream is a live subscription that does not self-terminate,
838    /// so `timeout` bounds how long to wait for the target completion.
839    ///
840    /// # Errors
841    /// Returns [`Error::Timeout`] if the completion is not seen within `timeout`,
842    /// [`Error::CommandRejected`] if the ledger rejected the command, or another
843    /// [`Error`] if the stream fails.
844    pub async fn await_completion(
845        &self,
846        change_id: &crate::command::ChangeId,
847        begin_offset: i64,
848        timeout: Duration,
849    ) -> Result<pb::Completion> {
850        let scan = async {
851            let stream = self
852                .completions(change_id.act_as().to_vec(), begin_offset)
853                .await?;
854            tokio::pin!(stream);
855            while let Some(item) = stream.next().await {
856                let completion = item?;
857                if change_id.matches(&completion) {
858                    // A non-OK gRPC status on the completion means the ledger
859                    // rejected the command for business/interpretation reasons.
860                    if let Some(status) = &completion.status {
861                        // google.rpc.Status code 0 == OK; anything else is a rejection.
862                        if status.code != 0 {
863                            return Err(Error::CommandRejected {
864                                code: format!("{:?}", tonic::Code::from(status.code)),
865                                message: status.message.clone(),
866                            });
867                        }
868                    }
869                    return Ok(completion);
870                }
871            }
872            Err(Error::UnexpectedResponse(format!(
873                "completion stream ended before command {} was seen",
874                change_id.command_id()
875            )))
876        };
877
878        tokio::time::timeout(timeout, scan)
879            .await
880            .map_err(|_| Error::Timeout)?
881    }
882
883    /// Return the current ledger end offset as seen by the participant.
884    ///
885    /// A value of `0` means the participant's view of the ledger is empty.
886    /// This is an authenticated endpoint.
887    ///
888    /// # Errors
889    /// Returns an [`Error`] if authentication or the RPC fails.
890    pub async fn ledger_end(&self) -> Result<i64> {
891        telemetry::instrument("ledger_end", TRANSPORT_GRPC, async {
892            self.with_retry(|| async {
893                let mut client = service!(self, pb::state_service_client::StateServiceClient::new);
894                let response = client
895                    .get_ledger_end(pb::GetLedgerEndRequest {})
896                    .await?
897                    .into_inner();
898                Ok(response.offset)
899            })
900            .await
901        })
902        .await
903    }
904
905    /// Fetch the created and/or archived events for a contract by id
906    /// (`EventQueryService.GetEventsByContractId`), with verbose records and
907    /// no created-event blob. To obtain a contract's `created_event_blob`
908    /// (for disclosure), use a template-filtered
909    /// [`Self::active_contracts_with`] read with
910    /// [`ActiveContractsRequest::with_created_event_blobs`](crate::request::ActiveContractsRequest::with_created_event_blobs).
911    ///
912    /// # Errors
913    /// Returns an [`Error`] if authentication or the RPC fails.
914    pub async fn events_by_contract_id(
915        &self,
916        contract_id: impl Into<String>,
917        parties: Vec<String>,
918    ) -> Result<pb::GetEventsByContractIdResponse> {
919        let contract_id = contract_id.into();
920        telemetry::instrument("events_by_contract_id", TRANSPORT_GRPC, async move {
921            // Retried like the other reads: this is a lookup, so a transient
922            // failure is worth another attempt rather than a caller's error
923            // path. Only submissions have a reason to be careful here.
924            self.with_retry(|| {
925                let request = pb::GetEventsByContractIdRequest {
926                    contract_id: contract_id.clone(),
927                    event_format: Some(wildcard_event_format(&parties)),
928                };
929                async move {
930                    let mut client = service!(
931                        self,
932                        pb::event_query_service_client::EventQueryServiceClient::new
933                    );
934                    Ok(client
935                        .get_events_by_contract_id(request)
936                        .await?
937                        .into_inner())
938                }
939            })
940            .await
941        })
942        .await
943    }
944
945    /// How far the participant has pruned (`StateService.GetLatestPrunedOffsets`).
946    ///
947    /// A read that starts at or below
948    /// [`participant_pruned_up_to_inclusive`](PrunedOffsets::participant_pruned_up_to_inclusive)
949    /// is refused; [`Error::pruned_earliest_offset`] says the same thing after
950    /// the fact, on the error such a read returns.
951    ///
952    /// # Errors
953    /// Returns an [`Error`] if authentication or the RPC fails.
954    pub async fn latest_pruned_offsets(&self) -> Result<PrunedOffsets> {
955        telemetry::instrument("latest_pruned_offsets", TRANSPORT_GRPC, async {
956            self.with_retry(|| async {
957                let mut client = service!(self, pb::state_service_client::StateServiceClient::new);
958                let response = client
959                    .get_latest_pruned_offsets(pb::GetLatestPrunedOffsetsRequest {})
960                    .await?
961                    .into_inner();
962                Ok(PrunedOffsets {
963                    participant_pruned_up_to_inclusive: response.participant_pruned_up_to_inclusive,
964                    all_divulged_contracts_pruned_up_to_inclusive: response
965                        .all_divulged_contracts_pruned_up_to_inclusive,
966                })
967            })
968            .await
969        })
970        .await
971    }
972
973    /// The update at `offset`, as `parties` see it (`UpdateService.GetUpdateByOffset`),
974    /// in the default format of [`UpdatesRequest::new`](crate::request::UpdatesRequest::new).
975    ///
976    /// Reading back a stored offset without scanning a stream to it. `Ok(None)`
977    /// when there is no update at that offset visible to `parties`.
978    ///
979    /// # Errors
980    /// Returns an [`Error`] if authentication or the RPC fails.
981    pub async fn update_by_offset(
982        &self,
983        offset: i64,
984        parties: Vec<String>,
985    ) -> Result<Option<pb::get_update_response::Update>> {
986        self.update_by_offset_with(offset, &crate::request::UpdatesRequest::new(parties, 0))
987            .await
988    }
989
990    /// Like [`Self::update_by_offset`], in the format an
991    /// [`UpdatesRequest`](crate::request::UpdatesRequest) describes (parties,
992    /// template or interface filters, transaction shape, created-event blobs,
993    /// reassignments, topology events, verbosity). Its offsets are not used.
994    ///
995    /// # Errors
996    /// Returns an [`Error`] if the request names no party, or authentication or
997    /// the RPC fails.
998    pub async fn update_by_offset_with(
999        &self,
1000        offset: i64,
1001        format: &crate::request::UpdatesRequest,
1002    ) -> Result<Option<pb::get_update_response::Update>> {
1003        format.validate()?;
1004        let request = pb::GetUpdateByOffsetRequest {
1005            offset,
1006            update_format: Some(format.update_format()),
1007        };
1008        telemetry::instrument("update_by_offset", TRANSPORT_GRPC, async move {
1009            let response = self
1010                .with_retry(|| {
1011                    let request = request.clone();
1012                    async move {
1013                        let mut client =
1014                            service!(self, pb::update_service_client::UpdateServiceClient::new);
1015                        Ok(client.get_update_by_offset(request).await?.into_inner())
1016                    }
1017                })
1018                .await;
1019            found(response)?.map(present_update).transpose()
1020        })
1021        .await
1022    }
1023
1024    /// The update with id `update_id`, as `parties` see it
1025    /// (`UpdateService.GetUpdateById`), in the default format of
1026    /// [`UpdatesRequest::new`](crate::request::UpdatesRequest::new).
1027    ///
1028    /// The id is what a submission's completion and its transaction carry, so
1029    /// this reads back the outcome of a command without scanning for it.
1030    /// `Ok(None)` when no update with that id is visible to `parties`.
1031    ///
1032    /// # Errors
1033    /// Returns an [`Error`] if authentication or the RPC fails.
1034    pub async fn update_by_id(
1035        &self,
1036        update_id: impl Into<String>,
1037        parties: Vec<String>,
1038    ) -> Result<Option<pb::get_update_response::Update>> {
1039        self.update_by_id_with(update_id, &crate::request::UpdatesRequest::new(parties, 0))
1040            .await
1041    }
1042
1043    /// Like [`Self::update_by_id`], in the format an
1044    /// [`UpdatesRequest`](crate::request::UpdatesRequest) describes. Its
1045    /// offsets are not used.
1046    ///
1047    /// # Errors
1048    /// Returns an [`Error`] if the request names no party, or authentication or
1049    /// the RPC fails.
1050    pub async fn update_by_id_with(
1051        &self,
1052        update_id: impl Into<String>,
1053        format: &crate::request::UpdatesRequest,
1054    ) -> Result<Option<pb::get_update_response::Update>> {
1055        format.validate()?;
1056        let request = pb::GetUpdateByIdRequest {
1057            update_id: update_id.into(),
1058            update_format: Some(format.update_format()),
1059        };
1060        telemetry::instrument("update_by_id", TRANSPORT_GRPC, async move {
1061            let response = self
1062                .with_retry(|| {
1063                    let request = request.clone();
1064                    async move {
1065                        let mut client =
1066                            service!(self, pb::update_service_client::UpdateServiceClient::new);
1067                        Ok(client.get_update_by_id(request).await?.into_inner())
1068                    }
1069                })
1070                .await;
1071            found(response)?.map(present_update).transpose()
1072        })
1073        .await
1074    }
1075
1076    /// Fetch one **page** of the Active Contract Set for `parties` as of
1077    /// `active_at_offset`. Returns the page's active contracts and the next page
1078    /// token (`None` once the last page has been read); pass the token back in
1079    /// to fetch the following page.
1080    ///
1081    /// # Errors
1082    /// Returns an [`Error`] if authentication or the RPC fails.
1083    pub async fn active_contracts_page(
1084        &self,
1085        parties: Vec<String>,
1086        active_at_offset: i64,
1087        max_page_size: i32,
1088        page_token: Option<Vec<u8>>,
1089    ) -> Result<(Vec<pb::ActiveContract>, Option<Vec<u8>>)> {
1090        let request = crate::request::ActiveContractsRequest::new(parties, active_at_offset);
1091        self.active_contracts_page_with(&request, max_page_size, page_token)
1092            .await
1093    }
1094
1095    /// Like [`Self::active_contracts_page`], with the full request surface of
1096    /// an [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
1097    /// (template/interface filters, created-event blobs, non-verbose records).
1098    ///
1099    /// # Errors
1100    /// Returns an [`Error`] if authentication or the RPC fails.
1101    pub async fn active_contracts_page_with(
1102        &self,
1103        request: &crate::request::ActiveContractsRequest,
1104        max_page_size: i32,
1105        page_token: Option<Vec<u8>>,
1106    ) -> Result<(Vec<pb::ActiveContract>, Option<Vec<u8>>)> {
1107        let (entries, next) = self
1108            .acs_page_with(request, max_page_size, page_token)
1109            .await?;
1110        Ok((
1111            entries
1112                .into_iter()
1113                .filter_map(AcsEntry::into_active)
1114                .collect(),
1115            next,
1116        ))
1117    }
1118
1119    /// One page of the Active Contract Set for `parties`, **losslessly**: every
1120    /// entry the participant sent, active or incomplete (see [`AcsEntry`]).
1121    ///
1122    /// # Errors
1123    /// Returns an [`Error`] if authentication or the RPC fails.
1124    pub async fn acs_page(
1125        &self,
1126        parties: Vec<String>,
1127        active_at_offset: i64,
1128        max_page_size: i32,
1129        page_token: Option<Vec<u8>>,
1130    ) -> Result<(Vec<AcsEntry>, Option<Vec<u8>>)> {
1131        let request = crate::request::ActiveContractsRequest::new(parties, active_at_offset);
1132        self.acs_page_with(&request, max_page_size, page_token)
1133            .await
1134    }
1135
1136    /// Like [`Self::acs_page`], with the full request surface of an
1137    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
1138    ///
1139    /// # Errors
1140    /// Returns an [`Error`] if authentication or the RPC fails.
1141    pub async fn acs_page_with(
1142        &self,
1143        request: &crate::request::ActiveContractsRequest,
1144        max_page_size: i32,
1145        page_token: Option<Vec<u8>>,
1146    ) -> Result<(Vec<AcsEntry>, Option<Vec<u8>>)> {
1147        request.validate()?;
1148        telemetry::instrument("active_contracts_page", TRANSPORT_GRPC, async move {
1149            self.with_retry(|| {
1150                let page_request = pb::GetActiveContractsPageRequest {
1151                    active_at_offset: Some(request.active_at_offset),
1152                    event_format: Some(request.event_format()),
1153                    max_page_size: Some(max_page_size),
1154                    page_token: page_token.clone(),
1155                };
1156                async move {
1157                    let mut client =
1158                        service!(self, pb::state_service_client::StateServiceClient::new);
1159                    let response = client
1160                        .get_active_contracts_page(page_request)
1161                        .await?
1162                        .into_inner();
1163
1164                    let entries = response
1165                        .active_contracts
1166                        .into_iter()
1167                        .filter_map(|entry| acs_entry(entry.contract_entry))
1168                        .collect();
1169                    Ok((entries, response.next_page_token))
1170                }
1171            })
1172            .await
1173        })
1174        .await
1175    }
1176
1177    /// Fetch one **page** of updates in the offset range
1178    /// `(begin_offset_exclusive, end_offset_inclusive]`, optionally in reverse
1179    /// (`descending`) order. Returns the page items and the next page token
1180    /// (`None` once the last page has been read).
1181    ///
1182    /// # Errors
1183    /// Returns an [`Error`] if authentication or the RPC fails.
1184    pub async fn updates_page(
1185        &self,
1186        parties: Vec<String>,
1187        begin_offset_exclusive: i64,
1188        end_offset_inclusive: i64,
1189        max_page_size: i32,
1190        descending: bool,
1191        page_token: Option<Vec<u8>>,
1192    ) -> Result<(Vec<pb::GetUpdateResponse>, Option<Vec<u8>>)> {
1193        let mut request = crate::request::UpdatesRequest::new(parties, begin_offset_exclusive)
1194            .until(end_offset_inclusive);
1195        if descending {
1196            request = request.descending();
1197        }
1198        self.updates_page_with(&request, max_page_size, page_token)
1199            .await
1200    }
1201
1202    /// Like [`Self::updates_page`], with the full request surface of an
1203    /// [`UpdatesRequest`](crate::request::UpdatesRequest) (template/interface
1204    /// filters, transaction shape, descending order, created-event blobs,
1205    /// topology events, non-verbose records). The request's bounds supply the
1206    /// page range, so
1207    /// [`UpdatesRequest::until`](crate::request::UpdatesRequest::until) is
1208    /// required here — the paged read is inherently bounded.
1209    ///
1210    /// # Errors
1211    /// Returns [`Error::InvalidRequest`] if the request has no end offset, or
1212    /// another [`Error`] if authentication or the RPC fails.
1213    pub async fn updates_page_with(
1214        &self,
1215        request: &crate::request::UpdatesRequest,
1216        max_page_size: i32,
1217        page_token: Option<Vec<u8>>,
1218    ) -> Result<(Vec<pb::GetUpdateResponse>, Option<Vec<u8>>)> {
1219        request.validate()?;
1220        let (begin_exclusive, end_inclusive) = request.bounds();
1221        let Some(end_inclusive) = end_inclusive else {
1222            return Err(Error::InvalidRequest(
1223                "updates_page_with requires a bounded request: set UpdatesRequest::until"
1224                    .to_string(),
1225            ));
1226        };
1227        telemetry::instrument("updates_page", TRANSPORT_GRPC, async move {
1228            self.with_retry(|| {
1229                let page_request = pb::GetUpdatesPageRequest {
1230                    begin_offset_exclusive: Some(begin_exclusive),
1231                    end_offset_inclusive: Some(end_inclusive),
1232                    max_page_size: Some(max_page_size),
1233                    update_format: Some(request.update_format()),
1234                    descending_order: request.is_descending(),
1235                    page_token: page_token.clone(),
1236                };
1237                async move {
1238                    let mut client =
1239                        service!(self, pb::update_service_client::UpdateServiceClient::new);
1240                    let response = client.get_updates_page(page_request).await?.into_inner();
1241                    Ok((response.updates, response.next_page_token))
1242                }
1243            })
1244            .await
1245        })
1246        .await
1247    }
1248
1249    /// Stream the Active Contract Set for `parties` as of `active_at_offset`
1250    /// (typically the current ledger end). Yields the active contracts,
1251    /// wildcard-filtered to the given parties.
1252    ///
1253    /// # Errors
1254    /// Returns an [`Error`] if authentication or opening the stream fails.
1255    pub async fn active_contracts(
1256        &self,
1257        parties: Vec<String>,
1258        active_at_offset: i64,
1259    ) -> Result<impl Stream<Item = Result<pb::ActiveContract>> + Send + use<>> {
1260        self.active_contracts_with(crate::request::ActiveContractsRequest::new(
1261            parties,
1262            active_at_offset,
1263        ))
1264        .await
1265    }
1266
1267    /// Like [`Self::active_contracts`], with the full request surface: an
1268    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest)
1269    /// additionally filters by template or interface, includes created-event
1270    /// blobs, and drops record labels.
1271    ///
1272    /// # Errors
1273    /// Returns an [`Error`] if authentication or opening the stream fails.
1274    pub async fn active_contracts_with(
1275        &self,
1276        request: crate::request::ActiveContractsRequest,
1277    ) -> Result<impl Stream<Item = Result<pb::ActiveContract>> + Send + use<>> {
1278        let stream = self.acs_entries_with(request).await?;
1279        Ok(stream.filter_map(|item| match item {
1280            Ok(entry) => entry.into_active().map(Ok),
1281            Err(err) => Some(Err(err)),
1282        }))
1283    }
1284
1285    /// Stream the Active Contract Set for `parties` **losslessly**: every entry
1286    /// the participant sends, active or incomplete (see [`AcsEntry`]).
1287    ///
1288    /// # Errors
1289    /// Returns an [`Error`] if authentication or opening the stream fails.
1290    pub async fn acs_entries(
1291        &self,
1292        parties: Vec<String>,
1293        active_at_offset: i64,
1294    ) -> Result<impl Stream<Item = Result<AcsEntry>> + Send + use<>> {
1295        self.acs_entries_with(crate::request::ActiveContractsRequest::new(
1296            parties,
1297            active_at_offset,
1298        ))
1299        .await
1300    }
1301
1302    /// Like [`Self::acs_entries`], with the full request surface of an
1303    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
1304    ///
1305    /// # Errors
1306    /// Returns an [`Error`] if authentication or opening the stream fails.
1307    pub async fn acs_entries_with(
1308        &self,
1309        request: crate::request::ActiveContractsRequest,
1310    ) -> Result<impl Stream<Item = Result<AcsEntry>> + Send + use<>> {
1311        request.validate()?;
1312        telemetry::instrument("active_contracts", TRANSPORT_GRPC, async move {
1313            let mut client = service!(self, pb::state_service_client::StateServiceClient::new);
1314            let stream = client
1315                .get_active_contracts(pb::GetActiveContractsRequest {
1316                    active_at_offset: request.active_at_offset,
1317                    event_format: Some(request.event_format()),
1318                    stream_continuation_token: None,
1319                })
1320                .await?
1321                .into_inner();
1322
1323            let stream = stream.filter_map(|item| match item {
1324                Ok(response) => acs_entry(response.contract_entry).map(Ok),
1325                Err(status) => Some(Err(Error::from(status))),
1326            });
1327            Ok(telemetry::instrument_stream(
1328                "active_contracts",
1329                TRANSPORT_GRPC,
1330                stream,
1331            ))
1332        })
1333        .await
1334    }
1335
1336    /// Like [`Self::active_contracts`], but **resumable**: reads the ACS
1337    /// snapshot page-by-page (continuation tokens), retrying a failed page on
1338    /// retriable errors from the last token instead of restarting the snapshot
1339    /// from zero. `max_page_size` bounds each page RPC.
1340    pub fn active_contracts_resumable(
1341        &self,
1342        parties: Vec<String>,
1343        active_at_offset: i64,
1344        max_page_size: i32,
1345    ) -> impl Stream<Item = Result<pb::ActiveContract>> + Send + use<> {
1346        self.active_contracts_resumable_with(
1347            crate::request::ActiveContractsRequest::new(parties, active_at_offset),
1348            max_page_size,
1349        )
1350    }
1351
1352    /// Like [`Self::active_contracts_resumable`], with the full request
1353    /// surface of an
1354    /// [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
1355    pub fn active_contracts_resumable_with(
1356        &self,
1357        request: crate::request::ActiveContractsRequest,
1358        max_page_size: i32,
1359    ) -> impl Stream<Item = Result<pb::ActiveContract>> + Send + use<> {
1360        self.acs_entries_resumable_with(request, max_page_size)
1361            .filter_map(|item| match item {
1362                Ok(entry) => entry.into_active().map(Ok),
1363                Err(err) => Some(Err(err)),
1364            })
1365    }
1366
1367    /// Like [`Self::acs_entries`], but **resumable** and page-based: reads the
1368    /// snapshot page by page and retries a failed page from the last
1369    /// continuation token instead of restarting from zero. Every entry the
1370    /// participant sent is yielded (see [`AcsEntry`]).
1371    pub fn acs_entries_resumable(
1372        &self,
1373        parties: Vec<String>,
1374        active_at_offset: i64,
1375        max_page_size: i32,
1376    ) -> impl Stream<Item = Result<AcsEntry>> + Send + use<> {
1377        self.acs_entries_resumable_with(
1378            crate::request::ActiveContractsRequest::new(parties, active_at_offset),
1379            max_page_size,
1380        )
1381    }
1382
1383    /// Like [`Self::acs_entries_resumable`], with the full request surface of
1384    /// an [`ActiveContractsRequest`](crate::request::ActiveContractsRequest).
1385    pub fn acs_entries_resumable_with(
1386        &self,
1387        request: crate::request::ActiveContractsRequest,
1388        max_page_size: i32,
1389    ) -> impl Stream<Item = Result<AcsEntry>> + Send + use<> {
1390        let client = self.clone();
1391        let (max_reconnects, backoff_unit) = client.reconnect_policy();
1392        async_stream::stream! {
1393            let mut page_token: Option<Vec<u8>> = None;
1394            let mut reconnects = 0u32;
1395            loop {
1396                match client
1397                    .acs_page_with(
1398                        &request,
1399                        max_page_size,
1400                        page_token.clone(),
1401                    )
1402                    .await
1403                {
1404                    Ok((entries, next)) => {
1405                        reconnects = 0;
1406                        for entry in entries {
1407                            yield Ok(entry);
1408                        }
1409                        match next {
1410                            Some(next) => page_token = Some(next),
1411                            None => return, // snapshot fully delivered
1412                        }
1413                    }
1414                    Err(err) if err.is_retriable() => {
1415                        reconnects += 1;
1416                        if reconnects > max_reconnects {
1417                            // The participant's own failure, for the reason the
1418                            // update stream keeps it: the status and its
1419                            // classification are what the caller acts on.
1420                            tracing::warn!(
1421                                max_reconnects,
1422                                "acs page failed to resume; reporting the failure that caused it"
1423                            );
1424                            yield Err(err);
1425                            return;
1426                        }
1427                        tokio::time::sleep(backoff_unit * reconnects).await;
1428                    }
1429                    Err(err) => {
1430                        yield Err(err);
1431                        return;
1432                    }
1433                }
1434            }
1435        }
1436    }
1437
1438    /// The reconnect policy for resumable streams: `(max_reconnects,
1439    /// backoff_unit)`. Derived from the client's [`RetryConfig`] when one is
1440    /// configured (attempts → reconnect budget, initial backoff → step), else
1441    /// the defaults (5 reconnects, 250ms step).
1442    ///
1443    /// [`RetryConfig`]: canton_core::RetryConfig
1444    fn reconnect_policy(&self) -> (u32, Duration) {
1445        match self.config.retry() {
1446            Some(retry) => (retry.max_attempts, retry.initial_backoff),
1447            None => (5, Duration::from_millis(250)),
1448        }
1449    }
1450
1451    /// Stream ledger updates — transactions and reassignments — for `parties`,
1452    /// starting after `begin_offset` (exclusive). Offset checkpoints are
1453    /// filtered out. Reassignments are surfaced as their own case (each
1454    /// carrying the distinct `Unassigned`/`Assigned` events). Topology events
1455    /// are **not** included; ask for them with
1456    /// [`UpdatesRequest::with_topology_events`](crate::request::UpdatesRequest::with_topology_events)
1457    /// on [`Self::updates_with`].
1458    ///
1459    /// # Errors
1460    /// Returns an [`Error`] if authentication or opening the stream fails.
1461    pub async fn updates(
1462        &self,
1463        parties: Vec<String>,
1464        begin_offset: i64,
1465    ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1466        self.updates_with(crate::request::UpdatesRequest::new(parties, begin_offset))
1467            .await
1468    }
1469
1470    /// Like [`Self::updates`], with the full request surface: an
1471    /// [`UpdatesRequest`](crate::request::UpdatesRequest) additionally bounds
1472    /// the stream at an end offset (`until` — the catch-up/sync form, after
1473    /// which the stream terminates), filters by template or interface, selects
1474    /// the ACS-delta shape, includes created-event blobs or topology events,
1475    /// and drops reassignments or record labels.
1476    ///
1477    /// # Errors
1478    /// Returns an [`Error`] if authentication or opening the stream fails.
1479    pub async fn updates_with(
1480        &self,
1481        request: crate::request::UpdatesRequest,
1482    ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1483        let stream = self.updates_including_checkpoints(request).await?;
1484        Ok(stream.filter(|item| {
1485            !matches!(
1486                item,
1487                Ok(pb::get_updates_response::Update::OffsetCheckpoint(_))
1488            )
1489        }))
1490    }
1491
1492    /// Like [`Self::updates_with`], as the SDK's own
1493    /// [`Update`](crate::update::Update): the type
1494    /// [`JsonClient::updates_typed_with`](crate::JsonClient::updates_typed_with)
1495    /// returns too, so code that reads updates depends on neither the transport
1496    /// nor the vendored protos (ADR-0010 §4). Checkpoints are dropped, as
1497    /// [`Self::updates_with`] drops them.
1498    ///
1499    /// # Errors
1500    /// Returns an [`Error`] if authentication or opening the stream fails; the
1501    /// stream yields one for a malformed update.
1502    pub async fn updates_typed_with(
1503        &self,
1504        request: crate::request::UpdatesRequest,
1505    ) -> Result<impl Stream<Item = Result<crate::update::Update>> + Send + use<>> {
1506        let stream = self.updates_with(request).await?;
1507        Ok(stream.map(|item| item.and_then(crate::update::Update::from_grpc)))
1508    }
1509
1510    /// The update stream with `OffsetCheckpoint` frames left in.
1511    ///
1512    /// A checkpoint is not an update anyone subscribed for, which is why
1513    /// [`Self::updates_with`] drops it. It is, however, the participant naming
1514    /// an offset that is safe to restart from — and it arrives on a quiet
1515    /// stream, where no transaction has moved the position for a while. The
1516    /// resumable path needs exactly that, so it reads this and filters for
1517    /// itself.
1518    async fn updates_including_checkpoints(
1519        &self,
1520        request: crate::request::UpdatesRequest,
1521    ) -> Result<impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<>> {
1522        request.validate()?;
1523        let (_, end_inclusive) = request.bounds();
1524        if request.is_descending() && end_inclusive.is_none() {
1525            return Err(Error::InvalidRequest(
1526                "descending order requires a bounded request: set UpdatesRequest::until"
1527                    .to_string(),
1528            ));
1529        }
1530        telemetry::instrument("updates", TRANSPORT_GRPC, async move {
1531            let mut client = service!(self, pb::update_service_client::UpdateServiceClient::new);
1532            let stream = client.get_updates(request.into_grpc()).await?.into_inner();
1533
1534            let stream = stream.filter_map(|item| match item {
1535                Ok(response) => response.update.map(Ok),
1536                Err(status) => Some(Err(Error::from(status))),
1537            });
1538            // Instrumented for the life of the stream, not just its opening:
1539            // a subscription that fails an hour in is the failure that matters.
1540            Ok(telemetry::instrument_stream(
1541                "updates",
1542                TRANSPORT_GRPC,
1543                stream,
1544            ))
1545        })
1546        .await
1547    }
1548
1549    /// Like [`Self::updates`], but **resumable**: on a retriable stream error it
1550    /// reconnects from the last offset it *observed* (rather than restarting
1551    /// from `begin_offset` or losing position), with a short backoff and a
1552    /// bounded number of consecutive reconnects (see the client's
1553    /// [`RetryConfig`](canton_core::RetryConfig)).
1554    ///
1555    /// Observed includes the participant's `OffsetCheckpoint` frames, which are
1556    /// filtered out of what this yields but are the only thing that advances the
1557    /// resume point on a stream where nothing is happening. Once the reconnect
1558    /// budget is spent the stream yields **the failure that caused it** — the
1559    /// participant's status, details and retriable classification intact —
1560    /// rather than an error of the SDK's own.
1561    ///
1562    /// A resume point the participant has since pruned is not retried: the
1563    /// stream yields the refusal, and
1564    /// [`Error::pruned_earliest_offset`](canton_core::Error::pruned_earliest_offset)
1565    /// names where readable data begins. Moving forward to it loses the pruned
1566    /// history, so that is the caller's call, never the stream's.
1567    ///
1568    /// # Example
1569    /// ```no_run
1570    /// # async fn run(client: canton_ledger::CantonClient, party: String)
1571    /// #     -> canton_ledger::Result<()> {
1572    /// use tokio_stream::StreamExt as _;
1573    ///
1574    /// let stream = client.updates_resumable(vec![party], 0);
1575    /// tokio::pin!(stream);
1576    /// while let Some(update) = stream.next().await {
1577    ///     println!("update: {:?}", update?);
1578    /// }
1579    /// # Ok(()) }
1580    /// ```
1581    pub fn updates_resumable(
1582        &self,
1583        parties: Vec<String>,
1584        begin_offset: i64,
1585    ) -> impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<> {
1586        self.updates_resumable_with(crate::request::UpdatesRequest::new(parties, begin_offset))
1587    }
1588
1589    /// Like [`Self::updates_resumable_with`], as the SDK's own
1590    /// [`Update`](crate::update::Update).
1591    pub fn updates_resumable_typed_with(
1592        &self,
1593        request: crate::request::UpdatesRequest,
1594    ) -> impl Stream<Item = Result<crate::update::Update>> + Send + use<> {
1595        self.updates_resumable_with(request)
1596            .map(|item| item.and_then(crate::update::Update::from_grpc))
1597    }
1598
1599    /// Like [`Self::updates_resumable`], with the full request surface of an
1600    /// [`UpdatesRequest`](crate::request::UpdatesRequest). A bounded request
1601    /// (`until`) makes this a *resilient catch-up read*: reconnects resume
1602    /// from the last observed offset, and the stream ends once the participant
1603    /// closes it at the end offset.
1604    pub fn updates_resumable_with(
1605        &self,
1606        request: crate::request::UpdatesRequest,
1607    ) -> impl Stream<Item = Result<pb::get_updates_response::Update>> + Send + use<> {
1608        let client = self.clone();
1609        let (max_reconnects, backoff_unit) = client.reconnect_policy();
1610        async_stream::stream! {
1611            if let Err(err) = request.validate() {
1612                yield Err(err);
1613                return;
1614            }
1615            // Resume tracking assumes ascending offsets; a descending request
1616            // would silently re-read on every reconnect, so refuse it.
1617            if request.is_descending() {
1618                yield Err(Error::InvalidRequest(
1619                    "the resumable stream requires ascending order; use updates_with \
1620                     (bounded) for descending reads".to_string(),
1621                ));
1622                return;
1623            }
1624            let mut offset = request.begin_exclusive;
1625            let mut reconnects = 0u32;
1626            loop {
1627                // What made this reconnect necessary. Carried out of the inner
1628                // loop so that giving up reports the participant's own failure
1629                // rather than a fresh error of ours: the status, its structured
1630                // details, the correlation id and the retriable classification
1631                // are what an application branches on, and the moment the
1632                // stream gives up is when it needs them most.
1633                let cause = match client
1634                    .updates_including_checkpoints(request.resume_after(offset))
1635                    .await
1636                {
1637                    Ok(stream) => {
1638                        tokio::pin!(stream);
1639                        loop {
1640                            match stream.next().await {
1641                                Some(Ok(update)) => {
1642                                    // Checkpoints are included here: on a quiet
1643                                    // stream a checkpoint is the only thing that
1644                                    // moves the resume point, and after pruning
1645                                    // the older offset may not be servable at all.
1646                                    offset = update_offset(&update);
1647                                    reconnects = 0;
1648                                    if matches!(
1649                                        update,
1650                                        pb::get_updates_response::Update::OffsetCheckpoint(_)
1651                                    ) {
1652                                        continue;
1653                                    }
1654                                    yield Ok(update);
1655                                }
1656                                Some(Err(err)) if err.is_retriable() => break err,
1657                                Some(Err(err)) => {
1658                                    yield Err(err);
1659                                    return;
1660                                }
1661                                None => return, // server closed the stream cleanly
1662                            }
1663                        }
1664                    }
1665                    Err(err) if err.is_retriable() => err,
1666                    Err(err) => {
1667                        yield Err(err);
1668                        return;
1669                    }
1670                };
1671
1672                reconnects += 1;
1673                if reconnects > max_reconnects {
1674                    tracing::warn!(
1675                        max_reconnects,
1676                        offset,
1677                        "update stream gave up resuming; reporting the failure that caused it"
1678                    );
1679                    yield Err(cause);
1680                    return;
1681                }
1682                tokio::time::sleep(backoff_unit * reconnects).await;
1683            }
1684        }
1685    }
1686}
1687
1688/// How far a participant has pruned, as `latest_pruned_offsets` reports it on
1689/// either transport.
1690#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1691#[non_exhaustive]
1692pub struct PrunedOffsets {
1693    /// Updates and completions at or below this offset are gone; a read must
1694    /// start after it.
1695    pub participant_pruned_up_to_inclusive: i64,
1696    /// Divulged contracts are pruned up to this offset, which may lag the
1697    /// participant's own pruning point.
1698    pub all_divulged_contracts_pruned_up_to_inclusive: i64,
1699}
1700
1701/// A point read's "not there": `Ok(None)` rather than an error, because asking
1702/// whether an offset or an id holds an update the caller can see is a question
1703/// with two ordinary answers. Every other failure stays an error.
1704pub(crate) fn found<T>(result: Result<T>) -> Result<Option<T>> {
1705    match result {
1706        Ok(value) => Ok(Some(value)),
1707        Err(error) if is_update_not_found(&error) => Ok(None),
1708        Err(error) => Err(error),
1709    }
1710}
1711
1712fn is_update_not_found(error: &Error) -> bool {
1713    match error.error_info() {
1714        Some(info) => info.reason == "UPDATE_NOT_FOUND",
1715        None => error.code() == Some(tonic::Code::NotFound),
1716    }
1717}
1718
1719/// A point read's response carries its update in a oneof that a well-formed
1720/// response always sets.
1721fn present_update(response: pb::GetUpdateResponse) -> Result<pb::get_update_response::Update> {
1722    response.update.ok_or_else(|| {
1723        Error::UnexpectedResponse(
1724            "the participant returned an update response with no update".to_string(),
1725        )
1726    })
1727}
1728
1729#[cfg(test)]
1730mod tests {
1731    use super::CantonClient;
1732    use crate::Config;
1733    use canton_core::Error;
1734
1735    // Compile-time guarantee that the client is Send + Sync, so consumers can
1736    // share it across tasks and `tokio::spawn` work that holds it. (The streaming
1737    // methods carry `+ Send` in their signatures, which the compiler enforces at
1738    // this crate's build time.)
1739    #[test]
1740    fn client_is_send_and_sync() {
1741        fn assert_send_sync<T: Send + Sync>() {}
1742        assert_send_sync::<CantonClient>();
1743    }
1744
1745    #[tokio::test]
1746    async fn connect_lazy_accepts_a_valid_endpoint() {
1747        // connect_lazy builds a lazy Channel, which needs a Tokio runtime.
1748        assert!(CantonClient::connect_lazy(Config::new("http://localhost:3901")).is_ok());
1749    }
1750
1751    #[tokio::test]
1752    async fn connect_lazy_rejects_a_malformed_endpoint() {
1753        for bad in ["", "not a uri", "http://[bad"] {
1754            let result = CantonClient::connect_lazy(Config::new(bad));
1755            assert!(
1756                matches!(result, Err(Error::InvalidRequest(_))),
1757                "endpoint {bad:?} should be rejected as InvalidRequest, got {result:?}"
1758            );
1759        }
1760    }
1761
1762    // The paged read is inherently bounded, so a request without an end offset
1763    // must be refused before any RPC is attempted.
1764    // Every one of these is refused before a connection is even attempted: the
1765    // client is pointed at a port nothing listens on, so anything that reaches
1766    // the wire fails differently.
1767    #[tokio::test]
1768    async fn requests_the_participant_would_refuse_are_refused_here() {
1769        use crate::request::{ActiveContractsRequest, CompletionsRequest, UpdatesRequest};
1770
1771        let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1772            panic!("lazy connect accepts a valid endpoint");
1773        };
1774        let party = || vec!["alice".to_string()];
1775        let invalid = |result: &Result<_, Error>| matches!(result, Err(Error::InvalidRequest(_)));
1776
1777        // Negative offsets.
1778        assert!(invalid(
1779            &client
1780                .updates_with(UpdatesRequest::new(party(), -1))
1781                .await
1782                .map(|_| ())
1783        ));
1784        assert!(invalid(
1785            &client
1786                .acs_entries_with(ActiveContractsRequest::new(party(), -5))
1787                .await
1788                .map(|_| ())
1789        ));
1790        assert!(invalid(
1791            &client
1792                .completions_with(CompletionsRequest::new(party(), -1))
1793                .await
1794                .map(|_| ())
1795        ));
1796
1797        // A range that ends before it begins.
1798        assert!(invalid(
1799            &client
1800                .updates_with(UpdatesRequest::new(party(), 100).until(50))
1801                .await
1802                .map(|_| ())
1803        ));
1804
1805        // No parties: a subscription that looks alive and can never yield.
1806        assert!(invalid(
1807            &client
1808                .updates_with(UpdatesRequest::new(vec![], 0))
1809                .await
1810                .map(|_| ())
1811        ));
1812    }
1813
1814    #[tokio::test]
1815    async fn submissions_that_cannot_succeed_are_refused_here() {
1816        use crate::command::Submit;
1817
1818        let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1819            panic!("lazy connect accepts a valid endpoint");
1820        };
1821
1822        // No commands: a round trip to be told nothing was asked for.
1823        let empty = client.submission(Submit::new("alice"));
1824        assert!(matches!(
1825            empty.submit().await,
1826            Err(Error::InvalidRequest(_))
1827        ));
1828
1829        // The two minimum-ledger-time forms are mutually exclusive.
1830        let both = client.submission(
1831            Submit::new("alice")
1832                .add_command(crate::command::create(
1833                    crate::command::identifier("pkg", "M", "T"),
1834                    crate::command::record(vec![]),
1835                ))
1836                .with_min_ledger_time_rel(std::time::Duration::from_secs(1))
1837                .with_min_ledger_time_abs(prost_types::Timestamp::default()),
1838        );
1839        assert!(matches!(both.submit().await, Err(Error::InvalidRequest(_))));
1840    }
1841
1842    #[tokio::test]
1843    async fn updates_page_with_requires_a_bounded_request() {
1844        let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1845            panic!("lazy connect accepts a valid endpoint");
1846        };
1847        let unbounded = crate::request::UpdatesRequest::new(vec!["p".to_string()], 0);
1848        let result = client.updates_page_with(&unbounded, 10, None).await;
1849        assert!(
1850            matches!(result, Err(Error::InvalidRequest(_))),
1851            "expected InvalidRequest, got {result:?}"
1852        );
1853    }
1854
1855    // Descending order needs an end offset (streams), and never composes with
1856    // resume tracking — both must fail fast, before any RPC.
1857    #[tokio::test]
1858    async fn descending_requires_bounds_and_is_refused_on_resumable() {
1859        use tokio_stream::StreamExt as _;
1860
1861        let Ok(client) = CantonClient::connect_lazy(Config::new("http://localhost:1")) else {
1862            panic!("lazy connect accepts a valid endpoint");
1863        };
1864        let descending_unbounded =
1865            crate::request::UpdatesRequest::new(vec!["p".to_string()], 0).descending();
1866
1867        let result = client.updates_with(descending_unbounded.clone()).await;
1868        assert!(matches!(result.err(), Some(Error::InvalidRequest(_))));
1869
1870        let stream = client.updates_resumable_with(descending_unbounded.until(10));
1871        tokio::pin!(stream);
1872        let Some(first) = stream.next().await else {
1873            panic!("the resumable stream should yield the rejection");
1874        };
1875        assert!(matches!(first, Err(Error::InvalidRequest(_))));
1876    }
1877
1878    // A reassignment carries its `Unassigned` (source) and `Assigned` (target)
1879    // events as distinct cases rather than collapsing them into one "reassign"
1880    // event — the multi-synchronizer faithfulness the update stream promises.
1881    // (LocalNet is single-synchronizer, so this is verified structurally.)
1882    #[test]
1883    fn reassignment_preserves_the_unassigned_assigned_split() {
1884        use super::pb;
1885        use pb::get_updates_response::Update;
1886        use pb::reassignment_event::Event;
1887
1888        let update = Update::Reassignment(pb::Reassignment {
1889            update_id: "u1".to_string(),
1890            events: vec![
1891                pb::ReassignmentEvent {
1892                    event: Some(Event::Unassigned(pb::UnassignedEvent::default())),
1893                },
1894                pb::ReassignmentEvent {
1895                    event: Some(Event::Assigned(pb::AssignedEvent::default())),
1896                },
1897            ],
1898            ..Default::default()
1899        });
1900
1901        let Update::Reassignment(reassignment) = update else {
1902            panic!("expected a reassignment update");
1903        };
1904        assert_eq!(reassignment.events.len(), 2, "both legs are surfaced");
1905        assert!(matches!(
1906            reassignment.events[0].event,
1907            Some(Event::Unassigned(_))
1908        ));
1909        assert!(matches!(
1910            reassignment.events[1].event,
1911            Some(Event::Assigned(_))
1912        ));
1913    }
1914}