Skip to main content

canton_ledger/
command.rs

1//! Command construction (dynamic / untyped).
2//!
3//! Until the M2 code generator produces typed bindings, commands are built from
4//! the wire protobuf types directly. These helpers keep that construction
5//! readable; the generated typed API will layer over the same wire values.
6
7use canton_proto::com::daml::ledger::api::v2 as pb;
8
9/// A ledger command submission: which parties act, which commands to run, and
10/// the submission metadata (change ID, de-duplication period, workflow).
11///
12/// The command id is one component of the change ID
13/// (`user_id`, `act_as`, `command_id`) that the Ledger API de-duplicates on.
14/// Leave it unset for a fresh UUID, or set it explicitly to make a submission
15/// idempotent across retries. `user_id` defaults to the one derived from the
16/// bearer token.
17#[derive(Clone, Debug)]
18pub struct Submit {
19    pub(crate) act_as: Vec<String>,
20    pub(crate) commands: Vec<pb::Command>,
21    pub(crate) command_id: Option<String>,
22    pub(crate) user_id: Option<String>,
23    pub(crate) read_as: Vec<String>,
24    pub(crate) workflow_id: Option<String>,
25    pub(crate) synchronizer_id: Option<String>,
26    pub(crate) deduplication: Option<pb::commands::DeduplicationPeriod>,
27    pub(crate) transaction_shape: crate::request::TransactionShape,
28    pub(crate) submission_id: Option<String>,
29    pub(crate) disclosed_contracts: Vec<pb::DisclosedContract>,
30    pub(crate) package_id_selection_preference: Vec<String>,
31    pub(crate) min_ledger_time_abs: Option<prost_types::Timestamp>,
32    pub(crate) min_ledger_time_rel: Option<std::time::Duration>,
33    pub(crate) prefetch_contract_keys: Vec<pb::PrefetchContractKey>,
34    pub(crate) taps_max_passes: Option<u32>,
35}
36
37/// The complete identity of a submitted command — Canton's **change ID**.
38///
39/// The Ledger API does not identify a command by its `command_id` alone: it
40/// de-duplicates on the triple (`user_id`, `act_as`, `command_id`), and two
41/// applications sharing a participant can legitimately use the same command id.
42/// Anything that goes looking for a command's outcome afterwards — a completion
43/// after a lost response, say — has to match on all three or it can find
44/// somebody else's answer.
45///
46/// A [`Submission`](crate::Submission) hands one of these back *before* the
47/// command is sent, which is the point: after an ambiguous failure the id is
48/// the only way back to the outcome, and a generated id the caller never saw
49/// is no way back at all.
50#[derive(Clone, Debug, PartialEq, Eq, Hash)]
51pub struct ChangeId {
52    user_id: String,
53    act_as: Vec<String>,
54    command_id: String,
55}
56
57impl ChangeId {
58    /// Assemble a change ID from its three parts.
59    ///
60    /// An empty `user_id` means "whichever user the bearer token resolves to",
61    /// which is what the participant fills in when a submission leaves it
62    /// unset — see [`Self::matches`].
63    #[must_use]
64    pub fn new(
65        user_id: impl Into<String>,
66        act_as: Vec<String>,
67        command_id: impl Into<String>,
68    ) -> Self {
69        Self {
70            user_id: user_id.into(),
71            act_as,
72            command_id: command_id.into(),
73        }
74    }
75
76    /// The command id.
77    #[must_use]
78    pub fn command_id(&self) -> &str {
79        &self.command_id
80    }
81
82    /// The submitting user id; empty when it was left to the token.
83    #[must_use]
84    pub fn user_id(&self) -> &str {
85        &self.user_id
86    }
87
88    /// The acting parties.
89    #[must_use]
90    pub fn act_as(&self) -> &[String] {
91        &self.act_as
92    }
93
94    /// Whether `completion` is the completion of *this* command.
95    ///
96    /// The command id must match, and the acting parties must be the same set —
97    /// order is not meaningful, and Canton may echo them in its own.
98    ///
99    /// Two deliberate asymmetries. A `user_id` this side left empty is not
100    /// compared: the participant resolved it from the token and the client
101    /// genuinely does not know it, so requiring equality would reject every
102    /// completion. And a completion that carries no `act_as` at all is not
103    /// rejected on that ground — some rejections are reported without one, and
104    /// refusing to recognise a rejection is worse than the narrow risk of a
105    /// same-user, same-command-id collision it guards against.
106    #[must_use]
107    pub fn matches(&self, completion: &pb::Completion) -> bool {
108        self.matches_parts(
109            &completion.command_id,
110            &completion.user_id,
111            &completion.act_as,
112        )
113    }
114
115    /// [`Self::matches`] for a completion from the JSON transport, which
116    /// carries the same three fields under their camelCase names.
117    ///
118    /// The rule lives here rather than in the JSON client so that the two
119    /// transports cannot come to disagree about what identifies a command.
120    #[must_use]
121    pub fn matches_json(&self, completion: &serde_json::Value) -> bool {
122        let command_id = completion
123            .get("commandId")
124            .and_then(serde_json::Value::as_str)
125            .unwrap_or_default();
126        let user_id = completion
127            .get("userId")
128            .and_then(serde_json::Value::as_str)
129            .unwrap_or_default();
130        let act_as: Vec<String> = completion
131            .get("actAs")
132            .and_then(serde_json::Value::as_array)
133            .map(|parties| {
134                parties
135                    .iter()
136                    .filter_map(|party| party.as_str().map(str::to_string))
137                    .collect()
138            })
139            .unwrap_or_default();
140        self.matches_parts(command_id, user_id, &act_as)
141    }
142
143    fn matches_parts(&self, command_id: &str, user_id: &str, act_as: &[String]) -> bool {
144        if command_id != self.command_id {
145            return false;
146        }
147        if !self.user_id.is_empty() && user_id != self.user_id {
148            return false;
149        }
150        if act_as.is_empty() {
151            return true;
152        }
153        let normalise = |parties: &[String]| {
154            let mut parties: Vec<String> = parties.to_vec();
155            parties.sort_unstable();
156            parties.dedup();
157            parties
158        };
159        normalise(act_as) == normalise(&self.act_as)
160    }
161}
162
163impl Submit {
164    /// Start a submission acting as a single party.
165    #[must_use]
166    pub fn new(act_as: impl Into<String>) -> Self {
167        Self::new_multi(vec![act_as.into()])
168    }
169
170    /// Start a submission acting as multiple parties (multi-party
171    /// authorization, e.g. proposal-accept or DvP patterns).
172    #[must_use]
173    pub fn new_multi(act_as: Vec<String>) -> Self {
174        Self {
175            act_as,
176            commands: Vec::new(),
177            command_id: None,
178            user_id: None,
179            read_as: Vec::new(),
180            workflow_id: None,
181            synchronizer_id: None,
182            deduplication: None,
183            transaction_shape: crate::request::TransactionShape::default(),
184            submission_id: None,
185            disclosed_contracts: Vec::new(),
186            package_id_selection_preference: Vec::new(),
187            min_ledger_time_abs: None,
188            min_ledger_time_rel: None,
189            prefetch_contract_keys: Vec::new(),
190            taps_max_passes: None,
191        }
192    }
193
194    /// Add a command to the submission.
195    #[must_use]
196    pub fn add_command(mut self, command: pb::Command) -> Self {
197        self.commands.push(command);
198        self
199    }
200
201    /// Set an explicit command id (for de-duplication / retry idempotency).
202    #[must_use]
203    pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
204        self.command_id = Some(command_id.into());
205        self
206    }
207
208    /// Set the acting user id — the first component of the change ID. Defaults
209    /// to the user derived from the bearer token.
210    #[must_use]
211    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
212        self.user_id = Some(user_id.into());
213        self
214    }
215
216    /// Add read-as parties (data visible to these parties may be read during
217    /// interpretation).
218    #[must_use]
219    pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
220        self.read_as = read_as;
221        self
222    }
223
224    /// Set the workflow id (an opaque correlation id carried on the resulting
225    /// transaction).
226    #[must_use]
227    pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
228        self.workflow_id = Some(workflow_id.into());
229        self
230    }
231
232    /// Pin the submission to a specific synchronizer.
233    #[must_use]
234    pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
235        self.synchronizer_id = Some(synchronizer_id.into());
236        self
237    }
238
239    /// Set the de-duplication period as a wall-clock duration: a submission
240    /// with the same change ID within this window is rejected as a duplicate.
241    #[must_use]
242    pub fn with_deduplication_duration(mut self, duration: std::time::Duration) -> Self {
243        self.deduplication = Some(pb::commands::DeduplicationPeriod::DeduplicationDuration(
244            prost_types::Duration {
245                seconds: i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
246                nanos: i32::try_from(duration.subsec_nanos()).unwrap_or(0),
247            },
248        ));
249        self
250    }
251
252    /// Set the de-duplication period as a ledger offset: submissions with the
253    /// same change ID since that offset are rejected as duplicates.
254    #[must_use]
255    pub fn with_deduplication_offset(mut self, offset: i64) -> Self {
256        self.deduplication = Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(
257            offset,
258        ));
259        self
260    }
261
262    /// Select the shape of the transaction returned by
263    /// [`submit_and_wait_for_transaction`] (default:
264    /// [`TransactionShape::LedgerEffects`], the full as-executed view;
265    /// [`TransactionShape::AcsDelta`] returns the net create/archive change).
266    /// Ignored by the submission-only paths, which return no transaction.
267    ///
268    /// [`submit_and_wait_for_transaction`]: crate::CantonClient::submit_and_wait_for_transaction
269    /// [`TransactionShape::LedgerEffects`]: crate::request::TransactionShape::LedgerEffects
270    /// [`TransactionShape::AcsDelta`]: crate::request::TransactionShape::AcsDelta
271    #[must_use]
272    pub fn with_transaction_shape(mut self, shape: crate::request::TransactionShape) -> Self {
273        self.transaction_shape = shape;
274        self
275    }
276
277    /// Set an explicit submission id, to correlate this particular submission
278    /// attempt in completions (unlike the command id, it identifies one
279    /// attempt, not the change). Defaults to participant-generated.
280    #[must_use]
281    pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
282        self.submission_id = Some(submission_id.into());
283        self
284    }
285
286    /// Attach a disclosed contract: an off-ledger contract (obtained as a
287    /// `created_event_blob`, e.g. via
288    /// `UpdatesRequest::with_created_event_blobs` /
289    /// `ActiveContractsRequest::with_created_event_blobs`) made readable to
290    /// this submission's interpretation. May be called repeatedly.
291    #[must_use]
292    pub fn add_disclosed_contract(mut self, contract: pb::DisclosedContract) -> Self {
293        self.disclosed_contracts.push(contract);
294        self
295    }
296
297    /// Restrict package selection for interpretation to these package ids
298    /// (at most one preference per package name) — the SCU upgrade pin.
299    #[must_use]
300    pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
301        self.package_id_selection_preference = package_ids;
302        self
303    }
304
305    /// Set the lower bound for the ledger-effective time as an absolute
306    /// timestamp (mutually exclusive with
307    /// [`Self::with_min_ledger_time_rel`] — the participant rejects both).
308    #[must_use]
309    pub fn with_min_ledger_time_abs(mut self, time: prost_types::Timestamp) -> Self {
310        self.min_ledger_time_abs = Some(time);
311        self
312    }
313
314    /// Set the lower bound for the ledger-effective time relative to the
315    /// participant's local clock (mutually exclusive with
316    /// [`Self::with_min_ledger_time_abs`]).
317    #[must_use]
318    pub fn with_min_ledger_time_rel(mut self, duration: std::time::Duration) -> Self {
319        self.min_ledger_time_rel = Some(duration);
320        self
321    }
322
323    /// Hint contract keys to resolve eagerly before interpretation (a
324    /// performance knob for key-heavy workflows).
325    #[must_use]
326    pub fn with_prefetch_contract_keys(mut self, keys: Vec<pb::PrefetchContractKey>) -> Self {
327        self.prefetch_contract_keys = keys;
328        self
329    }
330
331    /// Cap the topology-aware package selection passes (defaults to the
332    /// participant's configured value).
333    #[must_use]
334    pub fn with_taps_max_passes(mut self, passes: u32) -> Self {
335        self.taps_max_passes = Some(passes);
336        self
337    }
338
339    /// Build the wire [`pb::Commands`], filling `command_id` with a fresh UUID
340    /// when the caller did not set one. Returns the [`ChangeId`] alongside, so
341    /// the identity of the command is known before it is sent and can be used
342    /// for completion-based recovery afterwards.
343    pub(crate) fn into_commands(self) -> (ChangeId, pb::Commands) {
344        let command_id = self
345            .command_id
346            .unwrap_or_else(|| format!("sdk-{}", uuid::Uuid::new_v4()));
347        let change_id = ChangeId::new(
348            self.user_id.clone().unwrap_or_default(),
349            self.act_as.clone(),
350            command_id.clone(),
351        );
352        let commands = pb::Commands {
353            command_id: command_id.clone(),
354            act_as: self.act_as,
355            read_as: self.read_as,
356            user_id: self.user_id.unwrap_or_default(),
357            workflow_id: self.workflow_id.unwrap_or_default(),
358            synchronizer_id: self.synchronizer_id.unwrap_or_default(),
359            commands: self.commands,
360            deduplication_period: self.deduplication,
361            submission_id: self.submission_id.unwrap_or_default(),
362            disclosed_contracts: self.disclosed_contracts,
363            package_id_selection_preference: self.package_id_selection_preference,
364            min_ledger_time_abs: self.min_ledger_time_abs,
365            min_ledger_time_rel: self.min_ledger_time_rel.map(|d| prost_types::Duration {
366                seconds: i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
367                nanos: i32::try_from(d.subsec_nanos()).unwrap_or(0),
368            }),
369            prefetch_contract_keys: self.prefetch_contract_keys,
370            taps_max_passes: self.taps_max_passes,
371        };
372        (change_id, commands)
373    }
374}
375
376/// Build a create command for `template_id` with the given `arguments` record.
377#[must_use]
378pub fn create(template_id: pb::Identifier, arguments: pb::Record) -> pb::Command {
379    pb::Command {
380        command: Some(pb::command::Command::Create(pb::CreateCommand {
381            template_id: Some(template_id),
382            create_arguments: Some(arguments),
383        })),
384    }
385}
386
387/// Build an exercise command: exercise `choice` (with `argument`) on the
388/// contract `contract_id` of type `template_id`.
389#[must_use]
390pub fn exercise(
391    template_id: pb::Identifier,
392    contract_id: impl Into<String>,
393    choice: impl Into<String>,
394    argument: pb::Value,
395) -> pb::Command {
396    pb::Command {
397        command: Some(pb::command::Command::Exercise(pb::ExerciseCommand {
398            template_id: Some(template_id),
399            contract_id: contract_id.into(),
400            choice: choice.into(),
401            choice_argument: Some(argument),
402        })),
403    }
404}
405
406/// A template/type identifier (`package_id`, `Module.Path`, `EntityName`).
407#[must_use]
408pub fn identifier(
409    package_id: impl Into<String>,
410    module_name: impl Into<String>,
411    entity_name: impl Into<String>,
412) -> pb::Identifier {
413    pb::Identifier {
414        package_id: package_id.into(),
415        module_name: module_name.into(),
416        entity_name: entity_name.into(),
417    }
418}
419
420/// A record value from labelled fields.
421#[must_use]
422pub fn record(fields: Vec<(&str, pb::Value)>) -> pb::Record {
423    pb::Record {
424        record_id: None,
425        fields: fields
426            .into_iter()
427            .map(|(label, value)| pb::RecordField {
428                label: label.to_string(),
429                value: Some(value),
430            })
431            .collect(),
432    }
433}
434
435/// Value constructors for the dynamic command path.
436pub mod value {
437    use canton_proto::com::daml::ledger::api::v2 as pb;
438
439    fn wrap(sum: pb::value::Sum) -> pb::Value {
440        pb::Value { sum: Some(sum) }
441    }
442
443    /// A `Party` value.
444    #[must_use]
445    pub fn party(party: impl Into<String>) -> pb::Value {
446        wrap(pb::value::Sum::Party(party.into()))
447    }
448
449    /// A `Text` value.
450    #[must_use]
451    pub fn text(text: impl Into<String>) -> pb::Value {
452        wrap(pb::value::Sum::Text(text.into()))
453    }
454
455    /// A nested record value.
456    #[must_use]
457    pub fn record(record: pb::Record) -> pb::Value {
458        wrap(pb::value::Sum::Record(record))
459    }
460
461    /// A `TextMap` (`Map Text a`) value from key/value pairs.
462    #[must_use]
463    pub fn text_map(entries: Vec<(&str, pb::Value)>) -> pb::Value {
464        wrap(pb::value::Sum::TextMap(pb::TextMap {
465            entries: entries
466                .into_iter()
467                .map(|(key, value)| pb::text_map::Entry {
468                    key: key.to_string(),
469                    value: Some(value),
470                })
471                .collect(),
472        }))
473    }
474
475    /// An empty `TextMap` value (e.g. an empty Splice `Metadata`).
476    #[must_use]
477    pub fn empty_text_map() -> pb::Value {
478        text_map(Vec::new())
479    }
480}
481
482#[cfg(test)]
483#[allow(clippy::unwrap_used, clippy::panic)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn create_builds_a_create_command_with_template_and_args() {
489        let command = create(
490            identifier("pkg-1", "Licensing.AppInstall", "AppInstallRequest"),
491            record(vec![("owner", value::party("alice"))]),
492        );
493
494        let Some(pb::command::Command::Create(create_cmd)) = command.command else {
495            panic!("expected a create command");
496        };
497        let template = create_cmd.template_id.unwrap();
498        assert_eq!(template.package_id, "pkg-1");
499        assert_eq!(template.module_name, "Licensing.AppInstall");
500        assert_eq!(template.entity_name, "AppInstallRequest");
501
502        let args = create_cmd.create_arguments.unwrap();
503        assert_eq!(args.fields.len(), 1);
504        assert_eq!(args.fields[0].label, "owner");
505        assert!(matches!(
506            args.fields[0].value.as_ref().unwrap().sum,
507            Some(pb::value::Sum::Party(_))
508        ));
509    }
510
511    #[test]
512    fn exercise_builds_an_exercise_command() {
513        let command = exercise(
514            identifier("pkg-1", "M", "T"),
515            "cid-1",
516            "Accept",
517            value::record(record(vec![])),
518        );
519        let Some(pb::command::Command::Exercise(ex)) = command.command else {
520            panic!("expected an exercise command");
521        };
522        assert_eq!(ex.contract_id, "cid-1");
523        assert_eq!(ex.choice, "Accept");
524        assert_eq!(ex.template_id.unwrap().entity_name, "T");
525        assert!(ex.choice_argument.is_some());
526    }
527
528    #[test]
529    fn submit_collects_multiple_commands_in_order() {
530        let submit = Submit::new("alice")
531            .add_command(create(identifier("p", "M", "A"), record(vec![])))
532            .add_command(create(identifier("p", "M", "B"), record(vec![])));
533        assert_eq!(submit.commands.len(), 2);
534    }
535
536    #[test]
537    fn submit_builder_collects_parties_commands_and_id() {
538        let submit = Submit::new("alice")
539            .with_command_id("cmd-42")
540            .add_command(create(identifier("p", "M", "E"), record(vec![])));
541
542        assert_eq!(submit.act_as, vec!["alice".to_string()]);
543        assert_eq!(submit.command_id.as_deref(), Some("cmd-42"));
544        assert_eq!(submit.commands.len(), 1);
545    }
546
547    fn completion(command_id: &str, user_id: &str, act_as: &[&str]) -> pb::Completion {
548        pb::Completion {
549            command_id: command_id.to_string(),
550            user_id: user_id.to_string(),
551            act_as: act_as.iter().map(|p| (*p).to_string()).collect(),
552            ..Default::default()
553        }
554    }
555
556    #[test]
557    fn a_change_id_matches_only_its_own_completion() {
558        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
559
560        assert!(change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
561        // Same command id, different application on the same participant: this
562        // is the collision that matching on the command id alone answers wrongly.
563        assert!(!change_id.matches(&completion("cmd-1", "app-2", &["alice"])));
564        // Same command id, different acting party.
565        assert!(!change_id.matches(&completion("cmd-1", "app-1", &["bob"])));
566        assert!(!change_id.matches(&completion("cmd-2", "app-1", &["alice"])));
567    }
568
569    #[test]
570    fn the_json_matcher_reads_the_same_identity_from_camel_case() {
571        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
572        let completion = |command_id: &str, user_id: &str, act_as: &[&str]| {
573            serde_json::json!({
574                "commandId": command_id,
575                "userId": user_id,
576                "actAs": act_as,
577            })
578        };
579
580        assert!(change_id.matches_json(&completion("cmd-1", "app-1", &["alice"])));
581        assert!(!change_id.matches_json(&completion("cmd-1", "app-2", &["alice"])));
582        assert!(!change_id.matches_json(&completion("cmd-1", "app-1", &["bob"])));
583        assert!(!change_id.matches_json(&completion("cmd-2", "app-1", &["alice"])));
584        // Order is not meaningful over either transport.
585        let unordered = ChangeId::new(
586            "app-1",
587            vec!["alice".to_string(), "bob".to_string()],
588            "cmd-1",
589        );
590        assert!(unordered.matches_json(&completion("cmd-1", "app-1", &["bob", "alice"])));
591    }
592
593    #[test]
594    fn the_two_transports_cannot_disagree_about_what_identifies_a_command() {
595        // The rule lives in one place; this is the assertion that says so. If
596        // the JSON reader ever grows its own opinion, these diverge.
597        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
598        for (command_id, user_id, act_as) in [
599            ("cmd-1", "app-1", vec!["alice"]),
600            ("cmd-1", "app-2", vec!["alice"]),
601            ("cmd-1", "app-1", vec!["bob"]),
602            ("cmd-2", "app-1", vec!["alice"]),
603            ("cmd-1", "app-1", vec!["alice", "bob"]),
604        ] {
605            let grpc = completion(command_id, user_id, &act_as);
606            let json = serde_json::json!({
607                "commandId": command_id, "userId": user_id, "actAs": act_as,
608            });
609            assert_eq!(
610                change_id.matches(&grpc),
611                change_id.matches_json(&json),
612                "transports disagree on ({command_id}, {user_id}, {act_as:?})"
613            );
614        }
615    }
616
617    #[test]
618    fn a_json_completion_missing_its_fields_is_read_the_same_way_as_grpc() {
619        // Absent `actAs` reads as empty, which the shared rule treats as
620        // "unknown, do not reject" — the same as an empty repeated field on
621        // the wire.
622        let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
623        assert!(change_id.matches_json(&serde_json::json!({ "commandId": "cmd-1" })));
624        assert!(!change_id.matches_json(&serde_json::json!({ "commandId": "other" })));
625        // A body that is not a completion at all matches nothing.
626        assert!(!change_id.matches_json(&serde_json::json!({})));
627    }
628
629    #[test]
630    fn acting_parties_are_a_set_not_a_sequence() {
631        let change_id = ChangeId::new(
632            "app-1",
633            vec!["alice".to_string(), "bob".to_string()],
634            "cmd-1",
635        );
636        assert!(change_id.matches(&completion("cmd-1", "app-1", &["bob", "alice"])));
637        assert!(!change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
638    }
639
640    #[test]
641    fn an_unknown_user_id_is_not_compared() {
642        // Left to the bearer token: the participant resolved it and the client
643        // never learned which user that was, so requiring equality here would
644        // reject the very completion it is looking for.
645        let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
646        assert!(change_id.matches(&completion("cmd-1", "whoever", &["alice"])));
647
648        // A completion reported without acting parties still matches: some
649        // rejections arrive that way, and failing to recognise a rejection is
650        // the worse outcome.
651        assert!(change_id.matches(&completion("cmd-1", "whoever", &[])));
652    }
653
654    #[test]
655    fn into_commands_wires_every_field_and_generates_an_id() {
656        let disclosed = pb::DisclosedContract {
657            contract_id: "cid-1".to_string(),
658            ..Default::default()
659        };
660        let (change_id, commands) = Submit::new("alice")
661            .with_user_id("user-1")
662            .with_read_as(vec!["bob".to_string()])
663            .with_workflow_id("wf-1")
664            .with_synchronizer_id("sync-1")
665            .with_deduplication_duration(std::time::Duration::from_secs(30))
666            .with_submission_id("sub-1")
667            .add_disclosed_contract(disclosed)
668            .with_package_id_selection_preference(vec!["pkg-1".to_string()])
669            .with_min_ledger_time_rel(std::time::Duration::from_millis(1500))
670            .with_prefetch_contract_keys(vec![pb::PrefetchContractKey::default()])
671            .with_taps_max_passes(3)
672            .add_command(create(identifier("p", "M", "E"), record(vec![])))
673            .into_commands();
674
675        assert!(
676            change_id.command_id().starts_with("sdk-"),
677            "generated uuid id"
678        );
679        assert_eq!(commands.command_id, change_id.command_id());
680        assert_eq!(change_id.user_id(), "user-1");
681        assert_eq!(change_id.act_as(), ["alice".to_string()]);
682        assert_eq!(commands.act_as, vec!["alice".to_string()]);
683        assert_eq!(commands.read_as, vec!["bob".to_string()]);
684        assert_eq!(commands.user_id, "user-1");
685        assert_eq!(commands.workflow_id, "wf-1");
686        assert_eq!(commands.synchronizer_id, "sync-1");
687        assert_eq!(commands.commands.len(), 1);
688        assert_eq!(commands.submission_id, "sub-1");
689        assert_eq!(commands.disclosed_contracts.len(), 1);
690        assert_eq!(commands.disclosed_contracts[0].contract_id, "cid-1");
691        assert_eq!(
692            commands.package_id_selection_preference,
693            vec!["pkg-1".to_string()]
694        );
695        let Some(rel) = commands.min_ledger_time_rel else {
696            panic!("expected a relative min ledger time");
697        };
698        assert_eq!((rel.seconds, rel.nanos), (1, 500_000_000));
699        assert_eq!(commands.prefetch_contract_keys.len(), 1);
700        assert_eq!(commands.taps_max_passes, Some(3));
701        let Some(pb::commands::DeduplicationPeriod::DeduplicationDuration(d)) =
702            commands.deduplication_period
703        else {
704            panic!("expected a deduplication duration");
705        };
706        assert_eq!(d.seconds, 30);
707    }
708
709    #[test]
710    fn into_commands_wires_an_absolute_min_ledger_time() {
711        let (_, commands) = Submit::new("alice")
712            .with_min_ledger_time_abs(prost_types::Timestamp {
713                seconds: 1_700_000_000,
714                nanos: 0,
715            })
716            .into_commands();
717        let Some(abs) = commands.min_ledger_time_abs else {
718            panic!("expected an absolute min ledger time");
719        };
720        assert_eq!(abs.seconds, 1_700_000_000);
721    }
722
723    #[test]
724    fn new_multi_carries_every_acting_party() {
725        let (_, commands) = Submit::new_multi(vec!["a".to_string(), "b".to_string()])
726            .add_command(create(identifier("p", "M", "E"), record(vec![])))
727            .into_commands();
728        assert_eq!(commands.act_as, vec!["a".to_string(), "b".to_string()]);
729    }
730
731    #[test]
732    fn into_commands_preserves_an_explicit_id_and_offset_dedup() {
733        let (change_id, commands) = Submit::new("alice")
734            .with_command_id("cmd-7")
735            .with_deduplication_offset(42)
736            .into_commands();
737
738        assert_eq!(change_id.command_id(), "cmd-7");
739        // Left to the token: the participant resolves it, so the client cannot
740        // record a user id it does not know.
741        assert!(change_id.user_id().is_empty());
742        assert_eq!(commands.command_id, "cmd-7");
743        assert!(matches!(
744            commands.deduplication_period,
745            Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(42))
746        ));
747        // Unset optionals stay empty (token-derived user id, no workflow).
748        assert!(commands.user_id.is_empty());
749        assert!(commands.workflow_id.is_empty());
750    }
751
752    #[test]
753    fn empty_text_map_is_a_textmap_with_no_entries() {
754        let Some(pb::value::Sum::TextMap(map)) = value::empty_text_map().sum else {
755            panic!("expected a text map value");
756        };
757        assert!(map.entries.is_empty());
758    }
759
760    #[test]
761    fn text_map_preserves_entries() {
762        let Some(pb::value::Sum::TextMap(map)) = value::text_map(vec![("k", value::text("v"))]).sum
763        else {
764            panic!("expected a text map value");
765        };
766        assert_eq!(map.entries.len(), 1);
767        assert_eq!(map.entries[0].key, "k");
768    }
769}