use canton_core::{Error, Result};
use canton_daml as rt;
use canton_proto::com::daml::ledger::api::v2 as pb;
use canton_splice_api_token_transfer_events_v2::splice_api_token_transfer_events_v2::Splice_Api_Token_TransferEventsV2 as ev;
use rt::Contract as _;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct HoldingsChange {
pub offset: i64,
pub node_id: i32,
pub contract_id: String,
pub change: ev::EventLog_HoldingsChange,
}
pub fn holdings_changes(events: &[pb::Event]) -> Result<Vec<HoldingsChange>> {
let mut changes = Vec::new();
for event in events {
let Some(pb::event::Event::Exercised(exercised)) = &event.event else {
continue;
};
if !is_holdings_change(exercised) {
continue;
}
let argument = exercised.choice_argument.as_ref().ok_or_else(|| {
Error::UnexpectedResponse(format!(
"the holdings change on {} carries no argument",
exercised.contract_id
))
})?;
let change =
<ev::EventLog_HoldingsChange as rt::FromValue>::from_value(argument).map_err(|e| {
Error::UnexpectedResponse(format!(
"the holdings change on {} does not decode: {e}",
exercised.contract_id
))
})?;
changes.push(HoldingsChange {
offset: exercised.offset,
node_id: exercised.node_id,
contract_id: exercised.contract_id.clone(),
change,
});
}
Ok(changes)
}
fn is_holdings_change(exercised: &pb::ExercisedEvent) -> bool {
if exercised.choice != <ev::EventLog_HoldingsChange as rt::Choice<ev::EventLog>>::NAME {
return false;
}
let named = |id: &pb::Identifier| {
id.module_name == ev::EventLog::MODULE_NAME && id.entity_name == ev::EventLog::ENTITY_NAME
};
exercised.interface_id.as_ref().is_some_and(named)
|| exercised.implemented_interfaces.iter().any(named)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use canton_splice_api_token_holding_v2::splice_api_token_holding_v2::Splice_Api_Token_HoldingV2 as h;
use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
fn party(s: &str) -> rt::Party {
rt::Party::parse(s).expect("a party")
}
fn a_change() -> ev::EventLog_HoldingsChange {
ev::EventLog_HoldingsChange {
admin: party("dso::1220ef"),
account: h::Account {
owner: Some(party("alice::1220ab")),
provider: None,
id: "main".to_string(),
},
input_holding_cids: vec![rt::ContractId::new("00in".to_string())],
transfer_leg_sides: Vec::new(),
output_holding_cids: vec![
rt::ContractId::new("00out1".to_string()),
rt::ContractId::new("00out2".to_string()),
],
observers: Vec::new(),
extra_args: md::ExtraArgs {
context: md::ChoiceContext {
values: rt::TextMap::new(),
},
meta: md::Metadata {
values: rt::TextMap::new(),
},
},
}
}
fn interface_id() -> pb::Identifier {
pb::Identifier {
package_id: ev::EventLog::PACKAGE_ID.to_string(),
module_name: ev::EventLog::MODULE_NAME.to_string(),
entity_name: ev::EventLog::ENTITY_NAME.to_string(),
}
}
fn exercised(choice: &str, interface: Option<pb::Identifier>) -> pb::Event {
pb::Event {
event: Some(pb::event::Event::Exercised(pb::ExercisedEvent {
offset: 42,
node_id: 7,
contract_id: "00log".to_string(),
choice: choice.to_string(),
choice_argument: Some(rt::ToValue::to_value(&a_change())),
interface_id: interface,
..Default::default()
})),
}
}
#[test]
fn a_holdings_change_round_trips_through_the_ledger_encoding() {
let changes =
holdings_changes(&[exercised("EventLog_HoldingsChange", Some(interface_id()))])
.expect("decodes");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].offset, 42);
assert_eq!(changes[0].node_id, 7);
assert_eq!(changes[0].contract_id, "00log");
assert_eq!(changes[0].change, a_change());
}
#[test]
fn an_upgraded_package_id_is_still_the_same_interface() {
let mut upgraded = interface_id();
upgraded.package_id = "0".repeat(64);
let changes = holdings_changes(&[exercised("EventLog_HoldingsChange", Some(upgraded))])
.expect("decodes");
assert_eq!(changes.len(), 1, "an upgrade must not hide the event");
}
#[test]
fn events_that_are_not_holdings_changes_are_skipped() {
let created = pb::Event {
event: Some(pb::event::Event::Created(pb::CreatedEvent::default())),
};
let other_choice = exercised("EventLog_SomethingElse", Some(interface_id()));
let changes = holdings_changes(&[created, other_choice]).expect("decodes");
assert_eq!(changes, []);
}
#[test]
fn the_choice_name_alone_is_not_enough_to_claim_an_event() {
let impostor = exercised(
"EventLog_HoldingsChange",
Some(pb::Identifier {
package_id: ev::EventLog::PACKAGE_ID.to_string(),
module_name: "Some.Other.Module".to_string(),
entity_name: "EventLog".to_string(),
}),
);
assert_eq!(holdings_changes(&[impostor]).expect("decodes"), []);
let bare = exercised("EventLog_HoldingsChange", None);
assert_eq!(holdings_changes(&[bare]).expect("decodes"), []);
}
#[test]
fn the_interface_is_recognised_wherever_the_event_reports_it() {
let mut event = exercised("EventLog_HoldingsChange", None);
let Some(pb::event::Event::Exercised(ref mut ex)) = event.event else {
unreachable!("built as an exercise")
};
ex.implemented_interfaces = vec![interface_id()];
assert_eq!(holdings_changes(&[event]).expect("decodes").len(), 1);
}
#[test]
fn an_argument_that_does_not_decode_is_reported_rather_than_dropped() {
let mut event = exercised("EventLog_HoldingsChange", Some(interface_id()));
let Some(pb::event::Event::Exercised(ref mut ex)) = event.event else {
unreachable!("built as an exercise")
};
ex.choice_argument = Some(rt::ToValue::to_value(&"not a record".to_string()));
let error = holdings_changes(&[event]).expect_err("must not be silently skipped");
assert!(
matches!(error, Error::UnexpectedResponse(_)),
"expected an unexpected-response error, got {error:?}"
);
assert!(
error.to_string().contains("00log"),
"the message must name the contract: {error}"
);
}
}