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    /// The contracts this will disclose.
298    ///
299    /// A token-standard choice is meaningless without the registry's
300    /// disclosures, so a caller assembling one wants to see them before
301    /// sending — and a test that claims to check they were attached needs to be
302    /// able to look.
303    #[must_use]
304    pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
305        &self.disclosed_contracts
306    }
307
308    /// Restrict package selection for interpretation to these package ids
309    /// (at most one preference per package name) — the SCU upgrade pin.
310    #[must_use]
311    pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
312        self.package_id_selection_preference = package_ids;
313        self
314    }
315
316    /// Set the lower bound for the ledger-effective time as an absolute
317    /// timestamp (mutually exclusive with
318    /// [`Self::with_min_ledger_time_rel`] — the participant rejects both).
319    #[must_use]
320    pub fn with_min_ledger_time_abs(mut self, time: prost_types::Timestamp) -> Self {
321        self.min_ledger_time_abs = Some(time);
322        self
323    }
324
325    /// Set the lower bound for the ledger-effective time relative to the
326    /// participant's local clock (mutually exclusive with
327    /// [`Self::with_min_ledger_time_abs`]).
328    #[must_use]
329    pub fn with_min_ledger_time_rel(mut self, duration: std::time::Duration) -> Self {
330        self.min_ledger_time_rel = Some(duration);
331        self
332    }
333
334    /// Hint contract keys to resolve eagerly before interpretation (a
335    /// performance knob for key-heavy workflows).
336    #[must_use]
337    pub fn with_prefetch_contract_keys(mut self, keys: Vec<pb::PrefetchContractKey>) -> Self {
338        self.prefetch_contract_keys = keys;
339        self
340    }
341
342    /// Cap the topology-aware package selection passes (defaults to the
343    /// participant's configured value).
344    #[must_use]
345    pub fn with_taps_max_passes(mut self, passes: u32) -> Self {
346        self.taps_max_passes = Some(passes);
347        self
348    }
349
350    /// Build the wire [`pb::Commands`], filling `command_id` with a fresh UUID
351    /// when the caller did not set one. Returns the [`ChangeId`] alongside, so
352    /// the identity of the command is known before it is sent and can be used
353    /// for completion-based recovery afterwards.
354    pub(crate) fn into_commands(self) -> (ChangeId, pb::Commands) {
355        let command_id = self
356            .command_id
357            .unwrap_or_else(|| format!("sdk-{}", uuid::Uuid::new_v4()));
358        let change_id = ChangeId::new(
359            self.user_id.clone().unwrap_or_default(),
360            self.act_as.clone(),
361            command_id.clone(),
362        );
363        let commands = pb::Commands {
364            command_id: command_id.clone(),
365            act_as: self.act_as,
366            read_as: self.read_as,
367            user_id: self.user_id.unwrap_or_default(),
368            workflow_id: self.workflow_id.unwrap_or_default(),
369            synchronizer_id: self.synchronizer_id.unwrap_or_default(),
370            commands: self.commands,
371            deduplication_period: self.deduplication,
372            submission_id: self.submission_id.unwrap_or_default(),
373            disclosed_contracts: self.disclosed_contracts,
374            package_id_selection_preference: self.package_id_selection_preference,
375            min_ledger_time_abs: self.min_ledger_time_abs,
376            min_ledger_time_rel: self.min_ledger_time_rel.map(|d| prost_types::Duration {
377                seconds: i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
378                nanos: i32::try_from(d.subsec_nanos()).unwrap_or(0),
379            }),
380            prefetch_contract_keys: self.prefetch_contract_keys,
381            taps_max_passes: self.taps_max_passes,
382        };
383        (change_id, commands)
384    }
385}
386
387/// Build a create command for `template_id` with the given `arguments` record.
388#[must_use]
389pub fn create(template_id: pb::Identifier, arguments: pb::Record) -> pb::Command {
390    pb::Command {
391        command: Some(pb::command::Command::Create(pb::CreateCommand {
392            template_id: Some(template_id),
393            create_arguments: Some(arguments),
394        })),
395    }
396}
397
398/// Build an exercise command: exercise `choice` (with `argument`) on the
399/// contract `contract_id` of type `template_id`.
400#[must_use]
401pub fn exercise(
402    template_id: pb::Identifier,
403    contract_id: impl Into<String>,
404    choice: impl Into<String>,
405    argument: pb::Value,
406) -> pb::Command {
407    pb::Command {
408        command: Some(pb::command::Command::Exercise(pb::ExerciseCommand {
409            template_id: Some(template_id),
410            contract_id: contract_id.into(),
411            choice: choice.into(),
412            choice_argument: Some(argument),
413        })),
414    }
415}
416
417/// A template/type identifier (`package_id`, `Module.Path`, `EntityName`).
418#[must_use]
419pub fn identifier(
420    package_id: impl Into<String>,
421    module_name: impl Into<String>,
422    entity_name: impl Into<String>,
423) -> pb::Identifier {
424    pb::Identifier {
425        package_id: package_id.into(),
426        module_name: module_name.into(),
427        entity_name: entity_name.into(),
428    }
429}
430
431/// A record value from labelled fields.
432#[must_use]
433pub fn record(fields: Vec<(&str, pb::Value)>) -> pb::Record {
434    pb::Record {
435        record_id: None,
436        fields: fields
437            .into_iter()
438            .map(|(label, value)| pb::RecordField {
439                label: label.to_string(),
440                value: Some(value),
441            })
442            .collect(),
443    }
444}
445
446/// Value constructors for the dynamic command path.
447pub mod value {
448    use canton_proto::com::daml::ledger::api::v2 as pb;
449
450    fn wrap(sum: pb::value::Sum) -> pb::Value {
451        pb::Value { sum: Some(sum) }
452    }
453
454    /// A `Party` value.
455    #[must_use]
456    pub fn party(party: impl Into<String>) -> pb::Value {
457        wrap(pb::value::Sum::Party(party.into()))
458    }
459
460    /// A `Text` value.
461    #[must_use]
462    pub fn text(text: impl Into<String>) -> pb::Value {
463        wrap(pb::value::Sum::Text(text.into()))
464    }
465
466    /// A nested record value.
467    #[must_use]
468    pub fn record(record: pb::Record) -> pb::Value {
469        wrap(pb::value::Sum::Record(record))
470    }
471
472    /// A `TextMap` (`Map Text a`) value from key/value pairs.
473    #[must_use]
474    pub fn text_map(entries: Vec<(&str, pb::Value)>) -> pb::Value {
475        wrap(pb::value::Sum::TextMap(pb::TextMap {
476            entries: entries
477                .into_iter()
478                .map(|(key, value)| pb::text_map::Entry {
479                    key: key.to_string(),
480                    value: Some(value),
481                })
482                .collect(),
483        }))
484    }
485
486    /// An empty `TextMap` value (e.g. an empty Splice `Metadata`).
487    #[must_use]
488    pub fn empty_text_map() -> pb::Value {
489        text_map(Vec::new())
490    }
491}
492
493#[cfg(test)]
494#[allow(clippy::unwrap_used, clippy::panic)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn create_builds_a_create_command_with_template_and_args() {
500        let command = create(
501            identifier("pkg-1", "Licensing.AppInstall", "AppInstallRequest"),
502            record(vec![("owner", value::party("alice"))]),
503        );
504
505        let Some(pb::command::Command::Create(create_cmd)) = command.command else {
506            panic!("expected a create command");
507        };
508        let template = create_cmd.template_id.unwrap();
509        assert_eq!(template.package_id, "pkg-1");
510        assert_eq!(template.module_name, "Licensing.AppInstall");
511        assert_eq!(template.entity_name, "AppInstallRequest");
512
513        let args = create_cmd.create_arguments.unwrap();
514        assert_eq!(args.fields.len(), 1);
515        assert_eq!(args.fields[0].label, "owner");
516        assert!(matches!(
517            args.fields[0].value.as_ref().unwrap().sum,
518            Some(pb::value::Sum::Party(_))
519        ));
520    }
521
522    #[test]
523    fn exercise_builds_an_exercise_command() {
524        let command = exercise(
525            identifier("pkg-1", "M", "T"),
526            "cid-1",
527            "Accept",
528            value::record(record(vec![])),
529        );
530        let Some(pb::command::Command::Exercise(ex)) = command.command else {
531            panic!("expected an exercise command");
532        };
533        assert_eq!(ex.contract_id, "cid-1");
534        assert_eq!(ex.choice, "Accept");
535        assert_eq!(ex.template_id.unwrap().entity_name, "T");
536        assert!(ex.choice_argument.is_some());
537    }
538
539    #[test]
540    fn submit_collects_multiple_commands_in_order() {
541        let submit = Submit::new("alice")
542            .add_command(create(identifier("p", "M", "A"), record(vec![])))
543            .add_command(create(identifier("p", "M", "B"), record(vec![])));
544        assert_eq!(submit.commands.len(), 2);
545    }
546
547    #[test]
548    fn submit_builder_collects_parties_commands_and_id() {
549        let submit = Submit::new("alice")
550            .with_command_id("cmd-42")
551            .add_command(create(identifier("p", "M", "E"), record(vec![])));
552
553        assert_eq!(submit.act_as, vec!["alice".to_string()]);
554        assert_eq!(submit.command_id.as_deref(), Some("cmd-42"));
555        assert_eq!(submit.commands.len(), 1);
556    }
557
558    fn completion(command_id: &str, user_id: &str, act_as: &[&str]) -> pb::Completion {
559        pb::Completion {
560            command_id: command_id.to_string(),
561            user_id: user_id.to_string(),
562            act_as: act_as.iter().map(|p| (*p).to_string()).collect(),
563            ..Default::default()
564        }
565    }
566
567    #[test]
568    fn a_change_id_matches_only_its_own_completion() {
569        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
570
571        assert!(change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
572        // Same command id, different application on the same participant: this
573        // is the collision that matching on the command id alone answers wrongly.
574        assert!(!change_id.matches(&completion("cmd-1", "app-2", &["alice"])));
575        // Same command id, different acting party.
576        assert!(!change_id.matches(&completion("cmd-1", "app-1", &["bob"])));
577        assert!(!change_id.matches(&completion("cmd-2", "app-1", &["alice"])));
578    }
579
580    #[test]
581    fn the_json_matcher_reads_the_same_identity_from_camel_case() {
582        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
583        let completion = |command_id: &str, user_id: &str, act_as: &[&str]| {
584            serde_json::json!({
585                "commandId": command_id,
586                "userId": user_id,
587                "actAs": act_as,
588            })
589        };
590
591        assert!(change_id.matches_json(&completion("cmd-1", "app-1", &["alice"])));
592        assert!(!change_id.matches_json(&completion("cmd-1", "app-2", &["alice"])));
593        assert!(!change_id.matches_json(&completion("cmd-1", "app-1", &["bob"])));
594        assert!(!change_id.matches_json(&completion("cmd-2", "app-1", &["alice"])));
595        // Order is not meaningful over either transport.
596        let unordered = ChangeId::new(
597            "app-1",
598            vec!["alice".to_string(), "bob".to_string()],
599            "cmd-1",
600        );
601        assert!(unordered.matches_json(&completion("cmd-1", "app-1", &["bob", "alice"])));
602    }
603
604    #[test]
605    fn the_two_transports_cannot_disagree_about_what_identifies_a_command() {
606        // The rule lives in one place; this is the assertion that says so. If
607        // the JSON reader ever grows its own opinion, these diverge.
608        let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
609        for (command_id, user_id, act_as) in [
610            ("cmd-1", "app-1", vec!["alice"]),
611            ("cmd-1", "app-2", vec!["alice"]),
612            ("cmd-1", "app-1", vec!["bob"]),
613            ("cmd-2", "app-1", vec!["alice"]),
614            ("cmd-1", "app-1", vec!["alice", "bob"]),
615        ] {
616            let grpc = completion(command_id, user_id, &act_as);
617            let json = serde_json::json!({
618                "commandId": command_id, "userId": user_id, "actAs": act_as,
619            });
620            assert_eq!(
621                change_id.matches(&grpc),
622                change_id.matches_json(&json),
623                "transports disagree on ({command_id}, {user_id}, {act_as:?})"
624            );
625        }
626    }
627
628    #[test]
629    fn a_json_completion_missing_its_fields_is_read_the_same_way_as_grpc() {
630        // Absent `actAs` reads as empty, which the shared rule treats as
631        // "unknown, do not reject" — the same as an empty repeated field on
632        // the wire.
633        let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
634        assert!(change_id.matches_json(&serde_json::json!({ "commandId": "cmd-1" })));
635        assert!(!change_id.matches_json(&serde_json::json!({ "commandId": "other" })));
636        // A body that is not a completion at all matches nothing.
637        assert!(!change_id.matches_json(&serde_json::json!({})));
638    }
639
640    #[test]
641    fn acting_parties_are_a_set_not_a_sequence() {
642        let change_id = ChangeId::new(
643            "app-1",
644            vec!["alice".to_string(), "bob".to_string()],
645            "cmd-1",
646        );
647        assert!(change_id.matches(&completion("cmd-1", "app-1", &["bob", "alice"])));
648        assert!(!change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
649    }
650
651    #[test]
652    fn an_unknown_user_id_is_not_compared() {
653        // Left to the bearer token: the participant resolved it and the client
654        // never learned which user that was, so requiring equality here would
655        // reject the very completion it is looking for.
656        let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
657        assert!(change_id.matches(&completion("cmd-1", "whoever", &["alice"])));
658
659        // A completion reported without acting parties still matches: some
660        // rejections arrive that way, and failing to recognise a rejection is
661        // the worse outcome.
662        assert!(change_id.matches(&completion("cmd-1", "whoever", &[])));
663    }
664
665    #[test]
666    fn into_commands_wires_every_field_and_generates_an_id() {
667        let disclosed = pb::DisclosedContract {
668            contract_id: "cid-1".to_string(),
669            ..Default::default()
670        };
671        let (change_id, commands) = Submit::new("alice")
672            .with_user_id("user-1")
673            .with_read_as(vec!["bob".to_string()])
674            .with_workflow_id("wf-1")
675            .with_synchronizer_id("sync-1")
676            .with_deduplication_duration(std::time::Duration::from_secs(30))
677            .with_submission_id("sub-1")
678            .add_disclosed_contract(disclosed)
679            .with_package_id_selection_preference(vec!["pkg-1".to_string()])
680            .with_min_ledger_time_rel(std::time::Duration::from_millis(1500))
681            .with_prefetch_contract_keys(vec![pb::PrefetchContractKey::default()])
682            .with_taps_max_passes(3)
683            .add_command(create(identifier("p", "M", "E"), record(vec![])))
684            .into_commands();
685
686        assert!(
687            change_id.command_id().starts_with("sdk-"),
688            "generated uuid id"
689        );
690        assert_eq!(commands.command_id, change_id.command_id());
691        assert_eq!(change_id.user_id(), "user-1");
692        assert_eq!(change_id.act_as(), ["alice".to_string()]);
693        assert_eq!(commands.act_as, vec!["alice".to_string()]);
694        assert_eq!(commands.read_as, vec!["bob".to_string()]);
695        assert_eq!(commands.user_id, "user-1");
696        assert_eq!(commands.workflow_id, "wf-1");
697        assert_eq!(commands.synchronizer_id, "sync-1");
698        assert_eq!(commands.commands.len(), 1);
699        assert_eq!(commands.submission_id, "sub-1");
700        assert_eq!(commands.disclosed_contracts.len(), 1);
701        assert_eq!(commands.disclosed_contracts[0].contract_id, "cid-1");
702        assert_eq!(
703            commands.package_id_selection_preference,
704            vec!["pkg-1".to_string()]
705        );
706        let Some(rel) = commands.min_ledger_time_rel else {
707            panic!("expected a relative min ledger time");
708        };
709        assert_eq!((rel.seconds, rel.nanos), (1, 500_000_000));
710        assert_eq!(commands.prefetch_contract_keys.len(), 1);
711        assert_eq!(commands.taps_max_passes, Some(3));
712        let Some(pb::commands::DeduplicationPeriod::DeduplicationDuration(d)) =
713            commands.deduplication_period
714        else {
715            panic!("expected a deduplication duration");
716        };
717        assert_eq!(d.seconds, 30);
718    }
719
720    #[test]
721    fn into_commands_wires_an_absolute_min_ledger_time() {
722        let (_, commands) = Submit::new("alice")
723            .with_min_ledger_time_abs(prost_types::Timestamp {
724                seconds: 1_700_000_000,
725                nanos: 0,
726            })
727            .into_commands();
728        let Some(abs) = commands.min_ledger_time_abs else {
729            panic!("expected an absolute min ledger time");
730        };
731        assert_eq!(abs.seconds, 1_700_000_000);
732    }
733
734    #[test]
735    fn new_multi_carries_every_acting_party() {
736        let (_, commands) = Submit::new_multi(vec!["a".to_string(), "b".to_string()])
737            .add_command(create(identifier("p", "M", "E"), record(vec![])))
738            .into_commands();
739        assert_eq!(commands.act_as, vec!["a".to_string(), "b".to_string()]);
740    }
741
742    #[test]
743    fn into_commands_preserves_an_explicit_id_and_offset_dedup() {
744        let (change_id, commands) = Submit::new("alice")
745            .with_command_id("cmd-7")
746            .with_deduplication_offset(42)
747            .into_commands();
748
749        assert_eq!(change_id.command_id(), "cmd-7");
750        // Left to the token: the participant resolves it, so the client cannot
751        // record a user id it does not know.
752        assert!(change_id.user_id().is_empty());
753        assert_eq!(commands.command_id, "cmd-7");
754        assert!(matches!(
755            commands.deduplication_period,
756            Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(42))
757        ));
758        // Unset optionals stay empty (token-derived user id, no workflow).
759        assert!(commands.user_id.is_empty());
760        assert!(commands.workflow_id.is_empty());
761    }
762
763    #[test]
764    fn empty_text_map_is_a_textmap_with_no_entries() {
765        let Some(pb::value::Sum::TextMap(map)) = value::empty_text_map().sum else {
766            panic!("expected a text map value");
767        };
768        assert!(map.entries.is_empty());
769    }
770
771    #[test]
772    fn text_map_preserves_entries() {
773        let Some(pb::value::Sum::TextMap(map)) = value::text_map(vec![("k", value::text("v"))]).sum
774        else {
775            panic!("expected a text map value");
776        };
777        assert_eq!(map.entries.len(), 1);
778        assert_eq!(map.entries[0].key, "k");
779    }
780}