1use canton_proto::com::daml::ledger::api::v2 as pb;
8
9#[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#[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 #[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 #[must_use]
78 pub fn command_id(&self) -> &str {
79 &self.command_id
80 }
81
82 #[must_use]
84 pub fn user_id(&self) -> &str {
85 &self.user_id
86 }
87
88 #[must_use]
90 pub fn act_as(&self) -> &[String] {
91 &self.act_as
92 }
93
94 #[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 #[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 #[must_use]
166 pub fn new(act_as: impl Into<String>) -> Self {
167 Self::new_multi(vec![act_as.into()])
168 }
169
170 #[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 #[must_use]
196 pub fn add_command(mut self, command: pb::Command) -> Self {
197 self.commands.push(command);
198 self
199 }
200
201 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
304 pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
305 &self.disclosed_contracts
306 }
307
308 #[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 #[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 #[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 #[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 #[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 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#[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#[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#[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#[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
446pub 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 #[must_use]
456 pub fn party(party: impl Into<String>) -> pb::Value {
457 wrap(pb::value::Sum::Party(party.into()))
458 }
459
460 #[must_use]
462 pub fn text(text: impl Into<String>) -> pb::Value {
463 wrap(pb::value::Sum::Text(text.into()))
464 }
465
466 #[must_use]
468 pub fn record(record: pb::Record) -> pb::Value {
469 wrap(pb::value::Sum::Record(record))
470 }
471
472 #[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 #[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 assert!(!change_id.matches(&completion("cmd-1", "app-2", &["alice"])));
575 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 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 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 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 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 let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
657 assert!(change_id.matches(&completion("cmd-1", "whoever", &["alice"])));
658
659 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 assert_eq!(change_id.user_id(), "");
753 assert_eq!(commands.command_id, "cmd-7");
754 assert!(matches!(
755 commands.deduplication_period,
756 Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(42))
757 ));
758 assert_eq!(commands.user_id, "");
760 assert_eq!(commands.workflow_id, "");
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_eq!(map.entries, []);
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}