use canton_proto::com::daml::ledger::api::v2 as pb;
#[derive(Clone, Debug)]
pub struct Submit {
pub(crate) act_as: Vec<String>,
pub(crate) commands: Vec<pb::Command>,
pub(crate) command_id: Option<String>,
pub(crate) user_id: Option<String>,
pub(crate) read_as: Vec<String>,
pub(crate) workflow_id: Option<String>,
pub(crate) synchronizer_id: Option<String>,
pub(crate) deduplication: Option<pb::commands::DeduplicationPeriod>,
pub(crate) transaction_shape: crate::request::TransactionShape,
pub(crate) submission_id: Option<String>,
pub(crate) disclosed_contracts: Vec<pb::DisclosedContract>,
pub(crate) package_id_selection_preference: Vec<String>,
pub(crate) min_ledger_time_abs: Option<prost_types::Timestamp>,
pub(crate) min_ledger_time_rel: Option<std::time::Duration>,
pub(crate) prefetch_contract_keys: Vec<pb::PrefetchContractKey>,
pub(crate) taps_max_passes: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangeId {
user_id: String,
act_as: Vec<String>,
command_id: String,
}
impl ChangeId {
#[must_use]
pub fn new(
user_id: impl Into<String>,
act_as: Vec<String>,
command_id: impl Into<String>,
) -> Self {
Self {
user_id: user_id.into(),
act_as,
command_id: command_id.into(),
}
}
#[must_use]
pub fn command_id(&self) -> &str {
&self.command_id
}
#[must_use]
pub fn user_id(&self) -> &str {
&self.user_id
}
#[must_use]
pub fn act_as(&self) -> &[String] {
&self.act_as
}
#[must_use]
pub fn matches(&self, completion: &pb::Completion) -> bool {
self.matches_parts(
&completion.command_id,
&completion.user_id,
&completion.act_as,
)
}
#[must_use]
pub fn matches_json(&self, completion: &serde_json::Value) -> bool {
let command_id = completion
.get("commandId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let user_id = completion
.get("userId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let act_as: Vec<String> = completion
.get("actAs")
.and_then(serde_json::Value::as_array)
.map(|parties| {
parties
.iter()
.filter_map(|party| party.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
self.matches_parts(command_id, user_id, &act_as)
}
fn matches_parts(&self, command_id: &str, user_id: &str, act_as: &[String]) -> bool {
if command_id != self.command_id {
return false;
}
if !self.user_id.is_empty() && user_id != self.user_id {
return false;
}
if act_as.is_empty() {
return true;
}
let normalise = |parties: &[String]| {
let mut parties: Vec<String> = parties.to_vec();
parties.sort_unstable();
parties.dedup();
parties
};
normalise(act_as) == normalise(&self.act_as)
}
}
impl Submit {
pub fn new(act_as: impl Into<String>) -> Self {
Self::new_multi(vec![act_as.into()])
}
#[must_use]
pub fn new_multi(act_as: Vec<String>) -> Self {
Self {
act_as,
commands: Vec::new(),
command_id: None,
user_id: None,
read_as: Vec::new(),
workflow_id: None,
synchronizer_id: None,
deduplication: None,
transaction_shape: crate::request::TransactionShape::default(),
submission_id: None,
disclosed_contracts: Vec::new(),
package_id_selection_preference: Vec::new(),
min_ledger_time_abs: None,
min_ledger_time_rel: None,
prefetch_contract_keys: Vec::new(),
taps_max_passes: None,
}
}
#[must_use]
pub fn add_command(mut self, command: pb::Command) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
self.command_id = Some(command_id.into());
self
}
#[must_use]
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
#[must_use]
pub fn with_read_as(mut self, read_as: Vec<String>) -> Self {
self.read_as = read_as;
self
}
#[must_use]
pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
self.workflow_id = Some(workflow_id.into());
self
}
#[must_use]
pub fn with_synchronizer_id(mut self, synchronizer_id: impl Into<String>) -> Self {
self.synchronizer_id = Some(synchronizer_id.into());
self
}
#[must_use]
pub fn with_deduplication_duration(mut self, duration: std::time::Duration) -> Self {
self.deduplication = Some(pb::commands::DeduplicationPeriod::DeduplicationDuration(
prost_types::Duration {
seconds: i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
nanos: i32::try_from(duration.subsec_nanos()).unwrap_or(0),
},
));
self
}
#[must_use]
pub fn with_deduplication_offset(mut self, offset: i64) -> Self {
self.deduplication = Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(
offset,
));
self
}
#[must_use]
pub fn with_transaction_shape(mut self, shape: crate::request::TransactionShape) -> Self {
self.transaction_shape = shape;
self
}
#[must_use]
pub fn with_submission_id(mut self, submission_id: impl Into<String>) -> Self {
self.submission_id = Some(submission_id.into());
self
}
#[must_use]
pub fn add_disclosed_contract(mut self, contract: pb::DisclosedContract) -> Self {
self.disclosed_contracts.push(contract);
self
}
#[must_use]
pub fn with_package_id_selection_preference(mut self, package_ids: Vec<String>) -> Self {
self.package_id_selection_preference = package_ids;
self
}
#[must_use]
pub fn with_min_ledger_time_abs(mut self, time: prost_types::Timestamp) -> Self {
self.min_ledger_time_abs = Some(time);
self
}
#[must_use]
pub fn with_min_ledger_time_rel(mut self, duration: std::time::Duration) -> Self {
self.min_ledger_time_rel = Some(duration);
self
}
#[must_use]
pub fn with_prefetch_contract_keys(mut self, keys: Vec<pb::PrefetchContractKey>) -> Self {
self.prefetch_contract_keys = keys;
self
}
#[must_use]
pub fn with_taps_max_passes(mut self, passes: u32) -> Self {
self.taps_max_passes = Some(passes);
self
}
pub(crate) fn into_commands(self) -> (ChangeId, pb::Commands) {
let command_id = self
.command_id
.unwrap_or_else(|| format!("sdk-{}", uuid::Uuid::new_v4()));
let change_id = ChangeId::new(
self.user_id.clone().unwrap_or_default(),
self.act_as.clone(),
command_id.clone(),
);
let commands = pb::Commands {
command_id: command_id.clone(),
act_as: self.act_as,
read_as: self.read_as,
user_id: self.user_id.unwrap_or_default(),
workflow_id: self.workflow_id.unwrap_or_default(),
synchronizer_id: self.synchronizer_id.unwrap_or_default(),
commands: self.commands,
deduplication_period: self.deduplication,
submission_id: self.submission_id.unwrap_or_default(),
disclosed_contracts: self.disclosed_contracts,
package_id_selection_preference: self.package_id_selection_preference,
min_ledger_time_abs: self.min_ledger_time_abs,
min_ledger_time_rel: self.min_ledger_time_rel.map(|d| prost_types::Duration {
seconds: i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
nanos: i32::try_from(d.subsec_nanos()).unwrap_or(0),
}),
prefetch_contract_keys: self.prefetch_contract_keys,
taps_max_passes: self.taps_max_passes,
};
(change_id, commands)
}
}
#[must_use]
pub fn create(template_id: pb::Identifier, arguments: pb::Record) -> pb::Command {
pb::Command {
command: Some(pb::command::Command::Create(pb::CreateCommand {
template_id: Some(template_id),
create_arguments: Some(arguments),
})),
}
}
#[must_use]
pub fn exercise(
template_id: pb::Identifier,
contract_id: impl Into<String>,
choice: impl Into<String>,
argument: pb::Value,
) -> pb::Command {
pb::Command {
command: Some(pb::command::Command::Exercise(pb::ExerciseCommand {
template_id: Some(template_id),
contract_id: contract_id.into(),
choice: choice.into(),
choice_argument: Some(argument),
})),
}
}
#[must_use]
pub fn identifier(
package_id: impl Into<String>,
module_name: impl Into<String>,
entity_name: impl Into<String>,
) -> pb::Identifier {
pb::Identifier {
package_id: package_id.into(),
module_name: module_name.into(),
entity_name: entity_name.into(),
}
}
#[must_use]
pub fn record(fields: Vec<(&str, pb::Value)>) -> pb::Record {
pb::Record {
record_id: None,
fields: fields
.into_iter()
.map(|(label, value)| pb::RecordField {
label: label.to_string(),
value: Some(value),
})
.collect(),
}
}
pub mod value {
use canton_proto::com::daml::ledger::api::v2 as pb;
fn wrap(sum: pb::value::Sum) -> pb::Value {
pb::Value { sum: Some(sum) }
}
#[must_use]
pub fn party(party: impl Into<String>) -> pb::Value {
wrap(pb::value::Sum::Party(party.into()))
}
#[must_use]
pub fn text(text: impl Into<String>) -> pb::Value {
wrap(pb::value::Sum::Text(text.into()))
}
#[must_use]
pub fn record(record: pb::Record) -> pb::Value {
wrap(pb::value::Sum::Record(record))
}
#[must_use]
pub fn text_map(entries: Vec<(&str, pb::Value)>) -> pb::Value {
wrap(pb::value::Sum::TextMap(pb::TextMap {
entries: entries
.into_iter()
.map(|(key, value)| pb::text_map::Entry {
key: key.to_string(),
value: Some(value),
})
.collect(),
}))
}
#[must_use]
pub fn empty_text_map() -> pb::Value {
text_map(Vec::new())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn create_builds_a_create_command_with_template_and_args() {
let command = create(
identifier("pkg-1", "Licensing.AppInstall", "AppInstallRequest"),
record(vec![("owner", value::party("alice"))]),
);
let Some(pb::command::Command::Create(create_cmd)) = command.command else {
panic!("expected a create command");
};
let template = create_cmd.template_id.unwrap();
assert_eq!(template.package_id, "pkg-1");
assert_eq!(template.module_name, "Licensing.AppInstall");
assert_eq!(template.entity_name, "AppInstallRequest");
let args = create_cmd.create_arguments.unwrap();
assert_eq!(args.fields.len(), 1);
assert_eq!(args.fields[0].label, "owner");
assert!(matches!(
args.fields[0].value.as_ref().unwrap().sum,
Some(pb::value::Sum::Party(_))
));
}
#[test]
fn exercise_builds_an_exercise_command() {
let command = exercise(
identifier("pkg-1", "M", "T"),
"cid-1",
"Accept",
value::record(record(vec![])),
);
let Some(pb::command::Command::Exercise(ex)) = command.command else {
panic!("expected an exercise command");
};
assert_eq!(ex.contract_id, "cid-1");
assert_eq!(ex.choice, "Accept");
assert_eq!(ex.template_id.unwrap().entity_name, "T");
assert!(ex.choice_argument.is_some());
}
#[test]
fn submit_collects_multiple_commands_in_order() {
let submit = Submit::new("alice")
.add_command(create(identifier("p", "M", "A"), record(vec![])))
.add_command(create(identifier("p", "M", "B"), record(vec![])));
assert_eq!(submit.commands.len(), 2);
}
#[test]
fn submit_builder_collects_parties_commands_and_id() {
let submit = Submit::new("alice")
.with_command_id("cmd-42")
.add_command(create(identifier("p", "M", "E"), record(vec![])));
assert_eq!(submit.act_as, vec!["alice".to_string()]);
assert_eq!(submit.command_id.as_deref(), Some("cmd-42"));
assert_eq!(submit.commands.len(), 1);
}
fn completion(command_id: &str, user_id: &str, act_as: &[&str]) -> pb::Completion {
pb::Completion {
command_id: command_id.to_string(),
user_id: user_id.to_string(),
act_as: act_as.iter().map(|p| (*p).to_string()).collect(),
..Default::default()
}
}
#[test]
fn a_change_id_matches_only_its_own_completion() {
let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
assert!(change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
assert!(!change_id.matches(&completion("cmd-1", "app-2", &["alice"])));
assert!(!change_id.matches(&completion("cmd-1", "app-1", &["bob"])));
assert!(!change_id.matches(&completion("cmd-2", "app-1", &["alice"])));
}
#[test]
fn the_json_matcher_reads_the_same_identity_from_camel_case() {
let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
let completion = |command_id: &str, user_id: &str, act_as: &[&str]| {
serde_json::json!({
"commandId": command_id,
"userId": user_id,
"actAs": act_as,
})
};
assert!(change_id.matches_json(&completion("cmd-1", "app-1", &["alice"])));
assert!(!change_id.matches_json(&completion("cmd-1", "app-2", &["alice"])));
assert!(!change_id.matches_json(&completion("cmd-1", "app-1", &["bob"])));
assert!(!change_id.matches_json(&completion("cmd-2", "app-1", &["alice"])));
let unordered = ChangeId::new(
"app-1",
vec!["alice".to_string(), "bob".to_string()],
"cmd-1",
);
assert!(unordered.matches_json(&completion("cmd-1", "app-1", &["bob", "alice"])));
}
#[test]
fn the_two_transports_cannot_disagree_about_what_identifies_a_command() {
let change_id = ChangeId::new("app-1", vec!["alice".to_string()], "cmd-1");
for (command_id, user_id, act_as) in [
("cmd-1", "app-1", vec!["alice"]),
("cmd-1", "app-2", vec!["alice"]),
("cmd-1", "app-1", vec!["bob"]),
("cmd-2", "app-1", vec!["alice"]),
("cmd-1", "app-1", vec!["alice", "bob"]),
] {
let grpc = completion(command_id, user_id, &act_as);
let json = serde_json::json!({
"commandId": command_id, "userId": user_id, "actAs": act_as,
});
assert_eq!(
change_id.matches(&grpc),
change_id.matches_json(&json),
"transports disagree on ({command_id}, {user_id}, {act_as:?})"
);
}
}
#[test]
fn a_json_completion_missing_its_fields_is_read_the_same_way_as_grpc() {
let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
assert!(change_id.matches_json(&serde_json::json!({ "commandId": "cmd-1" })));
assert!(!change_id.matches_json(&serde_json::json!({ "commandId": "other" })));
assert!(!change_id.matches_json(&serde_json::json!({})));
}
#[test]
fn acting_parties_are_a_set_not_a_sequence() {
let change_id = ChangeId::new(
"app-1",
vec!["alice".to_string(), "bob".to_string()],
"cmd-1",
);
assert!(change_id.matches(&completion("cmd-1", "app-1", &["bob", "alice"])));
assert!(!change_id.matches(&completion("cmd-1", "app-1", &["alice"])));
}
#[test]
fn an_unknown_user_id_is_not_compared() {
let change_id = ChangeId::new("", vec!["alice".to_string()], "cmd-1");
assert!(change_id.matches(&completion("cmd-1", "whoever", &["alice"])));
assert!(change_id.matches(&completion("cmd-1", "whoever", &[])));
}
#[test]
fn into_commands_wires_every_field_and_generates_an_id() {
let disclosed = pb::DisclosedContract {
contract_id: "cid-1".to_string(),
..Default::default()
};
let (change_id, commands) = Submit::new("alice")
.with_user_id("user-1")
.with_read_as(vec!["bob".to_string()])
.with_workflow_id("wf-1")
.with_synchronizer_id("sync-1")
.with_deduplication_duration(std::time::Duration::from_secs(30))
.with_submission_id("sub-1")
.add_disclosed_contract(disclosed)
.with_package_id_selection_preference(vec!["pkg-1".to_string()])
.with_min_ledger_time_rel(std::time::Duration::from_millis(1500))
.with_prefetch_contract_keys(vec![pb::PrefetchContractKey::default()])
.with_taps_max_passes(3)
.add_command(create(identifier("p", "M", "E"), record(vec![])))
.into_commands();
assert!(
change_id.command_id().starts_with("sdk-"),
"generated uuid id"
);
assert_eq!(commands.command_id, change_id.command_id());
assert_eq!(change_id.user_id(), "user-1");
assert_eq!(change_id.act_as(), ["alice".to_string()]);
assert_eq!(commands.act_as, vec!["alice".to_string()]);
assert_eq!(commands.read_as, vec!["bob".to_string()]);
assert_eq!(commands.user_id, "user-1");
assert_eq!(commands.workflow_id, "wf-1");
assert_eq!(commands.synchronizer_id, "sync-1");
assert_eq!(commands.commands.len(), 1);
assert_eq!(commands.submission_id, "sub-1");
assert_eq!(commands.disclosed_contracts.len(), 1);
assert_eq!(commands.disclosed_contracts[0].contract_id, "cid-1");
assert_eq!(
commands.package_id_selection_preference,
vec!["pkg-1".to_string()]
);
let Some(rel) = commands.min_ledger_time_rel else {
panic!("expected a relative min ledger time");
};
assert_eq!((rel.seconds, rel.nanos), (1, 500_000_000));
assert_eq!(commands.prefetch_contract_keys.len(), 1);
assert_eq!(commands.taps_max_passes, Some(3));
let Some(pb::commands::DeduplicationPeriod::DeduplicationDuration(d)) =
commands.deduplication_period
else {
panic!("expected a deduplication duration");
};
assert_eq!(d.seconds, 30);
}
#[test]
fn into_commands_wires_an_absolute_min_ledger_time() {
let (_, commands) = Submit::new("alice")
.with_min_ledger_time_abs(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
})
.into_commands();
let Some(abs) = commands.min_ledger_time_abs else {
panic!("expected an absolute min ledger time");
};
assert_eq!(abs.seconds, 1_700_000_000);
}
#[test]
fn new_multi_carries_every_acting_party() {
let (_, commands) = Submit::new_multi(vec!["a".to_string(), "b".to_string()])
.add_command(create(identifier("p", "M", "E"), record(vec![])))
.into_commands();
assert_eq!(commands.act_as, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn into_commands_preserves_an_explicit_id_and_offset_dedup() {
let (change_id, commands) = Submit::new("alice")
.with_command_id("cmd-7")
.with_deduplication_offset(42)
.into_commands();
assert_eq!(change_id.command_id(), "cmd-7");
assert!(change_id.user_id().is_empty());
assert_eq!(commands.command_id, "cmd-7");
assert!(matches!(
commands.deduplication_period,
Some(pb::commands::DeduplicationPeriod::DeduplicationOffset(42))
));
assert!(commands.user_id.is_empty());
assert!(commands.workflow_id.is_empty());
}
#[test]
fn empty_text_map_is_a_textmap_with_no_entries() {
let Some(pb::value::Sum::TextMap(map)) = value::empty_text_map().sum else {
panic!("expected a text map value");
};
assert!(map.entries.is_empty());
}
#[test]
fn text_map_preserves_entries() {
let Some(pb::value::Sum::TextMap(map)) = value::text_map(vec![("k", value::text("v"))]).sum
else {
panic!("expected a text map value");
};
assert_eq!(map.entries.len(), 1);
assert_eq!(map.entries[0].key, "k");
}
}