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]
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 #[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 #[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 #[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 #[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 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#[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#[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#[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#[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
435pub 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 #[must_use]
445 pub fn party(party: impl Into<String>) -> pb::Value {
446 wrap(pb::value::Sum::Party(party.into()))
447 }
448
449 #[must_use]
451 pub fn text(text: impl Into<String>) -> pb::Value {
452 wrap(pb::value::Sum::Text(text.into()))
453 }
454
455 #[must_use]
457 pub fn record(record: pb::Record) -> pb::Value {
458 wrap(pb::value::Sum::Record(record))
459 }
460
461 #[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 #[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 assert!(!change_id.matches(&completion("cmd-1", "app-2", &["alice"])));
564 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 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 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 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 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 let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
646 assert!(change_id.matches(&completion("cmd-1", "whoever", &["alice"])));
647
648 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 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 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}