use canton_core::{Error, Result};
use canton_proto::com::daml::ledger::api::v2 as pb;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TransactionShape {
AcsDelta,
#[default]
LedgerEffects,
}
impl TransactionShape {
pub(crate) fn as_grpc(self) -> pb::TransactionShape {
match self {
Self::AcsDelta => pb::TransactionShape::AcsDelta,
Self::LedgerEffects => pb::TransactionShape::LedgerEffects,
}
}
}
#[derive(Clone, Debug)]
#[must_use = "a request does nothing until passed to a client method"]
#[allow(clippy::struct_excessive_bools)]
pub struct UpdatesRequest {
pub(crate) parties: Vec<String>,
pub(crate) begin_exclusive: i64,
end_inclusive: Option<i64>,
shape: TransactionShape,
templates: Vec<pb::Identifier>,
interfaces: Vec<pb::Identifier>,
include_created_event_blobs: bool,
include_reassignments: bool,
include_topology_events: bool,
verbose: bool,
any_party: bool,
descending: bool,
}
impl UpdatesRequest {
pub(crate) fn validate(&self) -> crate::Result<()> {
use canton_core::Error;
if self.parties.is_empty() && !self.any_party {
return Err(Error::InvalidRequest(
"a read needs at least one party (or filters_for_any_party)".to_string(),
));
}
if self.begin_exclusive < 0 {
return Err(Error::InvalidRequest(format!(
"begin offset must not be negative, got {}",
self.begin_exclusive
)));
}
if let Some(end) = self.end_inclusive {
if end < 0 {
return Err(Error::InvalidRequest(format!(
"end offset must not be negative, got {end}"
)));
}
if !self.descending && end < self.begin_exclusive {
return Err(Error::InvalidRequest(format!(
"end offset {end} is before the begin offset {}",
self.begin_exclusive
)));
}
}
Ok(())
}
pub fn new(parties: Vec<String>, begin_exclusive: i64) -> Self {
Self {
parties,
begin_exclusive,
end_inclusive: None,
shape: TransactionShape::LedgerEffects,
templates: Vec::new(),
interfaces: Vec::new(),
include_created_event_blobs: false,
include_reassignments: true,
include_topology_events: false,
verbose: true,
any_party: false,
descending: false,
}
}
pub fn until(mut self, end_inclusive: i64) -> Self {
self.end_inclusive = Some(end_inclusive);
self
}
pub fn descending(mut self) -> Self {
self.descending = true;
self
}
pub fn for_any_party(mut self) -> Self {
self.any_party = true;
self
}
pub fn with_shape(mut self, shape: TransactionShape) -> Self {
self.shape = shape;
self
}
pub fn for_templates<I, S>(mut self, template_ids: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for id in template_ids {
self.templates.push(parse_identifier(id.as_ref())?);
}
Ok(self)
}
pub fn for_interfaces<I, S>(mut self, interface_ids: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for id in interface_ids {
self.interfaces.push(parse_identifier(id.as_ref())?);
}
Ok(self)
}
pub fn with_created_event_blobs(mut self) -> Self {
self.include_created_event_blobs = true;
self
}
pub fn without_reassignments(mut self) -> Self {
self.include_reassignments = false;
self
}
pub fn with_topology_events(mut self) -> Self {
self.include_topology_events = true;
self
}
pub fn non_verbose(mut self) -> Self {
self.verbose = false;
self
}
pub(crate) fn resume_after(&self, offset: i64) -> Self {
let mut request = self.clone();
request.begin_exclusive = offset;
request
}
pub(crate) fn bounds(&self) -> (i64, Option<i64>) {
(self.begin_exclusive, self.end_inclusive)
}
pub(crate) fn is_descending(&self) -> bool {
self.descending
}
pub(crate) fn update_format(&self) -> pb::UpdateFormat {
let filters = build_filters(
&self.templates,
&self.interfaces,
self.include_created_event_blobs,
);
let event_format = |verbose: bool| pb::EventFormat {
filters_by_party: self
.parties
.iter()
.map(|party| (party.clone(), filters.clone()))
.collect(),
filters_for_any_party: self.any_party.then(|| filters.clone()),
verbose,
};
pb::UpdateFormat {
include_transactions: Some(pb::TransactionFormat {
event_format: Some(event_format(self.verbose)),
transaction_shape: self.shape.as_grpc() as i32,
}),
include_reassignments: self
.include_reassignments
.then(|| event_format(self.verbose)),
include_topology_events: self.include_topology_events.then(|| pb::TopologyFormat {
include_participant_authorization_events: Some(
pb::ParticipantAuthorizationTopologyFormat {
parties: self.parties.clone(),
},
),
}),
}
}
pub(crate) fn into_grpc(self) -> pb::GetUpdatesRequest {
pb::GetUpdatesRequest {
begin_exclusive: self.begin_exclusive,
end_inclusive: self.end_inclusive,
descending_order: self.descending,
update_format: Some(self.update_format()),
}
}
pub(crate) fn json_body(&self) -> serde_json::Value {
let event_format = || {
event_format_json(
&self.parties,
&self.templates,
&self.interfaces,
self.include_created_event_blobs,
self.verbose,
self.any_party,
)
};
let shape = match self.shape {
TransactionShape::AcsDelta => "TRANSACTION_SHAPE_ACS_DELTA",
TransactionShape::LedgerEffects => "TRANSACTION_SHAPE_LEDGER_EFFECTS",
};
let mut update_format = serde_json::json!({
"includeTransactions": {
"eventFormat": event_format(),
"transactionShape": shape,
}
});
if self.include_reassignments {
update_format["includeReassignments"] = event_format();
}
if self.include_topology_events {
update_format["includeTopologyEvents"] = serde_json::json!({
"includeParticipantAuthorizationEvents": { "parties": self.parties }
});
}
let mut body = serde_json::json!({
"beginExclusive": self.begin_exclusive,
"updateFormat": update_format,
});
if let Some(end) = self.end_inclusive {
body["endInclusive"] = serde_json::json!(end);
}
if self.descending {
body["descendingOrder"] = serde_json::json!(true);
}
body
}
}
#[derive(Clone, Debug)]
#[must_use = "a request does nothing until passed to a client method"]
pub struct ActiveContractsRequest {
pub(crate) parties: Vec<String>,
pub(crate) active_at_offset: i64,
templates: Vec<pb::Identifier>,
interfaces: Vec<pb::Identifier>,
include_created_event_blobs: bool,
verbose: bool,
any_party: bool,
}
impl ActiveContractsRequest {
pub(crate) fn validate(&self) -> crate::Result<()> {
use canton_core::Error;
if self.parties.is_empty() && !self.any_party {
return Err(Error::InvalidRequest(
"an ACS read needs at least one party (or filters_for_any_party)".to_string(),
));
}
if self.active_at_offset < 0 {
return Err(Error::InvalidRequest(format!(
"active_at_offset must not be negative, got {}",
self.active_at_offset
)));
}
Ok(())
}
pub fn new(parties: Vec<String>, active_at_offset: i64) -> Self {
Self {
parties,
active_at_offset,
templates: Vec::new(),
interfaces: Vec::new(),
include_created_event_blobs: false,
verbose: true,
any_party: false,
}
}
pub fn for_any_party(mut self) -> Self {
self.any_party = true;
self
}
pub fn for_templates<I, S>(mut self, template_ids: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for id in template_ids {
self.templates.push(parse_identifier(id.as_ref())?);
}
Ok(self)
}
pub fn for_interfaces<I, S>(mut self, interface_ids: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for id in interface_ids {
self.interfaces.push(parse_identifier(id.as_ref())?);
}
Ok(self)
}
pub fn with_created_event_blobs(mut self) -> Self {
self.include_created_event_blobs = true;
self
}
pub fn non_verbose(mut self) -> Self {
self.verbose = false;
self
}
pub(crate) fn event_format(&self) -> pb::EventFormat {
let filters = build_filters(
&self.templates,
&self.interfaces,
self.include_created_event_blobs,
);
pb::EventFormat {
filters_by_party: self
.parties
.iter()
.map(|party| (party.clone(), filters.clone()))
.collect(),
filters_for_any_party: self.any_party.then(|| filters.clone()),
verbose: self.verbose,
}
}
pub(crate) fn json_body(&self) -> serde_json::Value {
serde_json::json!({
"activeAtOffset": self.active_at_offset,
"eventFormat": event_format_json(
&self.parties,
&self.templates,
&self.interfaces,
self.include_created_event_blobs,
self.verbose,
self.any_party,
),
})
}
}
#[derive(Clone, Debug)]
#[must_use = "a request does nothing until passed to a client method"]
pub struct CompletionsRequest {
pub(crate) parties: Vec<String>,
pub(crate) begin_exclusive: i64,
user_id: Option<String>,
}
impl CompletionsRequest {
pub(crate) fn validate(&self) -> crate::Result<()> {
use canton_core::Error;
if self.parties.is_empty() {
return Err(Error::InvalidRequest(
"a completion subscription needs at least one party".to_string(),
));
}
if self.begin_exclusive < 0 {
return Err(Error::InvalidRequest(format!(
"begin offset must not be negative, got {}",
self.begin_exclusive
)));
}
Ok(())
}
pub fn new(parties: Vec<String>, begin_exclusive: i64) -> Self {
Self {
parties,
begin_exclusive,
user_id: None,
}
}
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub(crate) fn into_grpc(self) -> pb::CompletionStreamRequest {
pb::CompletionStreamRequest {
user_id: self.user_id.unwrap_or_default(),
parties: self.parties,
begin_exclusive: self.begin_exclusive,
}
}
#[cfg(feature = "ws")]
pub(crate) fn json_body(&self) -> serde_json::Value {
let mut body = serde_json::json!({
"parties": self.parties,
"beginExclusive": self.begin_exclusive,
});
if let Some(user_id) = &self.user_id {
body["userId"] = serde_json::json!(user_id);
}
body
}
}
fn build_filters(
templates: &[pb::Identifier],
interfaces: &[pb::Identifier],
include_created_event_blobs: bool,
) -> pb::Filters {
use pb::cumulative_filter::IdentifierFilter;
if templates.is_empty() && interfaces.is_empty() {
return pb::Filters {
cumulative: vec![pb::CumulativeFilter {
identifier_filter: Some(IdentifierFilter::WildcardFilter(pb::WildcardFilter {
include_created_event_blob: include_created_event_blobs,
})),
}],
};
}
let template_filters = templates.iter().map(|id| {
IdentifierFilter::TemplateFilter(pb::TemplateFilter {
template_id: Some(id.clone()),
include_created_event_blob: include_created_event_blobs,
})
});
let interface_filters = interfaces.iter().map(|id| {
IdentifierFilter::InterfaceFilter(pb::InterfaceFilter {
interface_id: Some(id.clone()),
include_interface_view: true,
include_created_event_blob: include_created_event_blobs,
})
});
pb::Filters {
cumulative: template_filters
.chain(interface_filters)
.map(|filter| pb::CumulativeFilter {
identifier_filter: Some(filter),
})
.collect(),
}
}
fn event_format_json(
parties: &[String],
templates: &[pb::Identifier],
interfaces: &[pb::Identifier],
include_created_event_blobs: bool,
verbose: bool,
any_party: bool,
) -> serde_json::Value {
use serde_json::json;
let cumulative: Vec<serde_json::Value> = if templates.is_empty() && interfaces.is_empty() {
vec![json!({
"identifierFilter": {
"WildcardFilter": {
"value": { "includeCreatedEventBlob": include_created_event_blobs }
}
}
})]
} else {
let identifier = |id: &pb::Identifier| {
format!("{}:{}:{}", id.package_id, id.module_name, id.entity_name)
};
templates
.iter()
.map(|id| {
json!({
"identifierFilter": {
"TemplateFilter": {
"value": {
"templateId": identifier(id),
"includeCreatedEventBlob": include_created_event_blobs,
}
}
}
})
})
.chain(interfaces.iter().map(|id| {
json!({
"identifierFilter": {
"InterfaceFilter": {
"value": {
"interfaceId": identifier(id),
"includeInterfaceView": true,
"includeCreatedEventBlob": include_created_event_blobs,
}
}
}
})
}))
.collect()
};
let filters_by_party: serde_json::Map<String, serde_json::Value> = parties
.iter()
.map(|party| (party.clone(), json!({ "cumulative": cumulative })))
.collect();
let mut format = json!({ "filtersByParty": filters_by_party, "verbose": verbose });
if any_party {
format["filtersForAnyParty"] = json!({ "cumulative": cumulative });
}
format
}
fn parse_identifier(id: &str) -> Result<pb::Identifier> {
let mut parts = id.splitn(3, ':');
match (parts.next(), parts.next(), parts.next()) {
(Some(package), Some(module), Some(entity))
if !package.is_empty() && !module.is_empty() && !entity.is_empty() =>
{
Ok(pb::Identifier {
package_id: package.to_string(),
module_name: module.to_string(),
entity_name: entity.to_string(),
})
}
_ => Err(Error::InvalidRequest(format!(
"malformed identifier `{id}`: expected `package:Module:Entity` \
(package id, or `#package-name`)"
))),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn the_default_request_matches_the_plain_updates_call() {
let request = UpdatesRequest::new(vec!["alice".to_string()], 7).into_grpc();
assert_eq!(request.begin_exclusive, 7);
assert_eq!(request.end_inclusive, None);
assert!(!request.descending_order);
let format = request.update_format.unwrap();
let transactions = format.include_transactions.unwrap();
assert_eq!(
transactions.transaction_shape,
pb::TransactionShape::LedgerEffects as i32
);
let events = transactions.event_format.unwrap();
assert!(events.verbose);
assert!(events.filters_for_any_party.is_none());
let filters = &events.filters_by_party["alice"];
assert_eq!(filters.cumulative.len(), 1);
assert!(matches!(
filters.cumulative[0].identifier_filter,
Some(pb::cumulative_filter::IdentifierFilter::WildcardFilter(
pb::WildcardFilter {
include_created_event_blob: false
}
))
));
assert!(format.include_reassignments.is_some());
assert!(format.include_topology_events.is_none());
}
#[test]
fn every_builder_knob_reaches_the_wire_request() {
let request = UpdatesRequest::new(vec!["alice".to_string()], 0)
.until(41)
.with_shape(TransactionShape::AcsDelta)
.for_templates(["#my-app:My.Mod:Asset"])
.unwrap()
.for_interfaces(["#my-app:My.Api:IAsset"])
.unwrap()
.with_created_event_blobs()
.without_reassignments()
.with_topology_events()
.non_verbose()
.into_grpc();
assert_eq!(request.end_inclusive, Some(41));
let format = request.update_format.unwrap();
assert!(format.include_reassignments.is_none());
assert_eq!(
format
.include_topology_events
.unwrap()
.include_participant_authorization_events
.unwrap()
.parties,
vec!["alice".to_string()]
);
let transactions = format.include_transactions.unwrap();
assert_eq!(
transactions.transaction_shape,
pb::TransactionShape::AcsDelta as i32
);
let events = transactions.event_format.unwrap();
assert!(!events.verbose);
let filters = &events.filters_by_party["alice"].cumulative;
assert_eq!(filters.len(), 2, "one template + one interface filter");
let Some(pb::cumulative_filter::IdentifierFilter::TemplateFilter(template)) =
&filters[0].identifier_filter
else {
panic!("expected a template filter first");
};
assert_eq!(template.template_id.as_ref().unwrap().package_id, "#my-app");
assert!(template.include_created_event_blob);
let Some(pb::cumulative_filter::IdentifierFilter::InterfaceFilter(interface)) =
&filters[1].identifier_filter
else {
panic!("expected an interface filter second");
};
assert_eq!(
interface.interface_id.as_ref().unwrap().entity_name,
"IAsset"
);
assert!(interface.include_interface_view);
}
#[test]
fn identifiers_parse_and_malformed_ones_are_refused() {
let id = parse_identifier("#pkg-name:Some.Dotted.Module:Entity").unwrap();
assert_eq!(id.package_id, "#pkg-name");
assert_eq!(id.module_name, "Some.Dotted.Module");
assert_eq!(id.entity_name, "Entity");
for bad in ["", "nope", "a:b", ":Mod:Ent", "pkg::Ent", "pkg:Mod:"] {
assert!(parse_identifier(bad).is_err(), "`{bad}` should be refused");
}
}
#[test]
fn acs_request_knobs_reach_the_wire_and_json_bodies() {
let request = ActiveContractsRequest::new(vec!["alice".to_string()], 42)
.for_templates(["#app:Mod:Asset"])
.unwrap()
.for_interfaces(["#app:Api:IAsset"])
.unwrap()
.with_created_event_blobs()
.non_verbose();
let format = request.event_format();
assert!(!format.verbose);
let filters = &format.filters_by_party["alice"].cumulative;
assert_eq!(filters.len(), 2);
assert!(matches!(
&filters[0].identifier_filter,
Some(pb::cumulative_filter::IdentifierFilter::TemplateFilter(t))
if t.include_created_event_blob
));
let body = request.json_body();
assert_eq!(body["activeAtOffset"], 42);
let cumulative = &body["eventFormat"]["filtersByParty"]["alice"]["cumulative"];
assert_eq!(
cumulative[0]["identifierFilter"]["TemplateFilter"]["value"]["templateId"],
"#app:Mod:Asset"
);
assert_eq!(
cumulative[1]["identifierFilter"]["InterfaceFilter"]["value"]["interfaceId"],
"#app:Api:IAsset"
);
assert_eq!(body["eventFormat"]["verbose"], false);
let plain = ActiveContractsRequest::new(vec!["alice".to_string()], 42).json_body();
assert!(plain["eventFormat"]["filtersByParty"]["alice"]["cumulative"][0]
["identifierFilter"]["WildcardFilter"]
.is_object());
assert_eq!(plain["eventFormat"]["verbose"], true);
}
#[test]
fn updates_json_body_mirrors_the_grpc_query() {
let body = UpdatesRequest::new(vec!["alice".to_string()], 5)
.until(9)
.with_shape(TransactionShape::AcsDelta)
.for_templates(["#app:Mod:Asset"])
.unwrap()
.without_reassignments()
.with_topology_events()
.json_body();
assert_eq!(body["beginExclusive"], 5);
assert_eq!(body["endInclusive"], 9);
let format = &body["updateFormat"];
assert_eq!(
format["includeTransactions"]["transactionShape"],
"TRANSACTION_SHAPE_ACS_DELTA"
);
assert!(format.get("includeReassignments").is_none());
assert_eq!(
format["includeTopologyEvents"]["includeParticipantAuthorizationEvents"]["parties"][0],
"alice"
);
assert_eq!(
format["includeTransactions"]["eventFormat"]["filtersByParty"]["alice"]["cumulative"]
[0]["identifierFilter"]["TemplateFilter"]["value"]["templateId"],
"#app:Mod:Asset"
);
}
#[test]
fn descending_and_any_party_reach_both_wire_shapes() {
let request = UpdatesRequest::new(vec!["alice".to_string()], 5)
.until(9)
.descending()
.for_any_party();
let body = request.clone().json_body();
assert_eq!(body["descendingOrder"], true);
let format = &body["updateFormat"]["includeTransactions"]["eventFormat"];
assert!(format["filtersForAnyParty"]["cumulative"].is_array());
assert!(format["filtersByParty"]["alice"].is_object());
let grpc = request.into_grpc();
assert!(grpc.descending_order);
let Some(format) = grpc
.update_format
.and_then(|f| f.include_transactions)
.and_then(|t| t.event_format)
else {
panic!("expected an event format");
};
assert!(format.filters_for_any_party.is_some());
assert!(format.filters_by_party.contains_key("alice"));
let plain = UpdatesRequest::new(vec!["alice".to_string()], 5).json_body();
assert!(plain.get("descendingOrder").is_none());
assert!(
plain["updateFormat"]["includeTransactions"]["eventFormat"]
.get("filtersForAnyParty")
.is_none()
);
}
#[test]
fn acs_any_party_reaches_both_wire_shapes() {
let request = ActiveContractsRequest::new(vec![], 7).for_any_party();
let body = request.json_body();
assert!(body["eventFormat"]["filtersForAnyParty"]["cumulative"].is_array());
let format = request.event_format();
assert!(format.filters_for_any_party.is_some());
assert!(format.filters_by_party.is_empty());
}
#[test]
#[cfg(feature = "ws")] fn completions_json_body_carries_the_user_id_only_when_set() {
let plain = CompletionsRequest::new(vec!["p".to_string()], 3).json_body();
assert!(plain.get("userId").is_none());
assert_eq!(plain["beginExclusive"], 3);
let scoped = CompletionsRequest::new(vec!["p".to_string()], 3)
.with_user_id("sync-tool")
.json_body();
assert_eq!(scoped["userId"], "sync-tool");
}
#[test]
fn completions_request_carries_the_user_id() {
let plain = CompletionsRequest::new(vec!["p".to_string()], 3).into_grpc();
assert_eq!(plain.user_id, "");
assert_eq!(plain.begin_exclusive, 3);
let scoped = CompletionsRequest::new(vec!["p".to_string()], 3)
.with_user_id("sync-tool")
.into_grpc();
assert_eq!(scoped.user_id, "sync-tool");
}
}