use base64::Engine as _;
use canton_core::{Error, Result};
use canton_proto::com::daml::ledger::api::v2 as pb;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ChoiceContextRequest {
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub meta: std::collections::BTreeMap<String, String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub exclude_debug_fields: bool,
}
impl Default for ChoiceContextRequest {
fn default() -> Self {
Self {
meta: std::collections::BTreeMap::new(),
exclude_debug_fields: true,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct WireDisclosedContract {
pub template_id: String,
pub contract_id: String,
pub created_event_blob: String,
pub synchronizer_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct WireChoiceContext {
pub choice_context_data: serde_json::Value,
pub disclosed_contracts: Vec<WireDisclosedContract>,
}
#[derive(Clone, Debug)]
pub struct ChoiceContext {
data: serde_json::Value,
disclosed: Vec<pb::DisclosedContract>,
disclosed_json: Vec<serde_json::Value>,
}
impl ChoiceContext {
pub fn from_wire(wire: WireChoiceContext) -> Result<Self> {
let mut disclosed = Vec::with_capacity(wire.disclosed_contracts.len());
let mut disclosed_json = Vec::with_capacity(wire.disclosed_contracts.len());
for contract in wire.disclosed_contracts {
disclosed.push(pb::DisclosedContract {
template_id: Some(identifier(&contract.template_id)?),
contract_id: contract.contract_id.clone(),
created_event_blob: base64::engine::general_purpose::STANDARD
.decode(&contract.created_event_blob)
.map_err(|e| {
Error::UnexpectedResponse(format!(
"the registry disclosed {} with a createdEventBlob that is not \
base64: {e}",
contract.contract_id
))
})?,
synchronizer_id: contract.synchronizer_id.clone(),
});
disclosed_json.push(serde_json::json!({
"templateId": contract.template_id,
"contractId": contract.contract_id,
"createdEventBlob": contract.created_event_blob,
"synchronizerId": contract.synchronizer_id,
}));
}
Ok(Self {
data: wire.choice_context_data,
disclosed,
disclosed_json,
})
}
#[must_use]
pub fn data(&self) -> &serde_json::Value {
&self.data
}
pub fn decode<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
serde_json::from_value(self.data.clone()).map_err(|e| {
Error::UnexpectedResponse(format!(
"the registry's choice context does not decode as {}: {e}",
std::any::type_name::<T>()
))
})
}
#[must_use]
pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
&self.disclosed
}
#[must_use]
pub fn disclosed_contracts_json(&self) -> &[serde_json::Value] {
&self.disclosed_json
}
#[must_use]
pub fn into_disclosed_contracts(self) -> Vec<pb::DisclosedContract> {
self.disclosed
}
}
fn identifier(template_id: &str) -> Result<pb::Identifier> {
let mut parts = template_id.split(':');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(package_id), Some(module_name), Some(entity_name), None)
if !package_id.is_empty() && !module_name.is_empty() && !entity_name.is_empty() =>
{
Ok(pb::Identifier {
package_id: package_id.to_string(),
module_name: module_name.to_string(),
entity_name: entity_name.to_string(),
})
}
_ => Err(Error::UnexpectedResponse(format!(
"the registry disclosed a contract with template id `{template_id}`, which is not \
`<package>:<Module>:<Entity>`"
))),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn wire(template_id: &str, blob: &str) -> WireChoiceContext {
WireChoiceContext {
choice_context_data: serde_json::json!({ "values": {} }),
disclosed_contracts: vec![WireDisclosedContract {
template_id: template_id.to_string(),
contract_id: "00abc".to_string(),
created_event_blob: blob.to_string(),
synchronizer_id: "sync::1220ab".to_string(),
}],
}
}
#[test]
fn the_wire_shape_is_the_one_the_standard_specifies() {
let json = serde_json::json!({
"choiceContextData": { "values": { "amulet-rules": { "tag": "AV_ContractId", "value": "001" } } },
"disclosedContracts": [{
"templateId": "pkg:Splice.AmuletRules:AmuletRules",
"contractId": "001",
"createdEventBlob": "AQI=",
"synchronizerId": "sync::1220ab",
"debugPackageName": "splice-amulet",
"debugPayload": { "ignored": true }
}]
});
let wire: WireChoiceContext = serde_json::from_value(json).expect("decodes");
assert_eq!(wire.disclosed_contracts.len(), 1);
assert_eq!(wire.disclosed_contracts[0].contract_id, "001");
assert_eq!(wire.disclosed_contracts[0].created_event_blob, "AQI=");
assert!(wire.choice_context_data.get("values").is_some());
}
#[test]
fn the_blob_is_bytes_for_grpc_and_stays_base64_for_json() {
let context =
ChoiceContext::from_wire(wire("pkg:Splice.AmuletRules:AmuletRules", "AQI=")).unwrap();
assert_eq!(context.disclosed_contracts()[0].created_event_blob, [1, 2]);
assert_eq!(
context.disclosed_contracts_json()[0]["createdEventBlob"],
"AQI="
);
}
#[test]
fn a_dotted_module_name_survives_the_split() {
let context =
ChoiceContext::from_wire(wire("abc123:Splice.Api.Token.HoldingV1:Holding", "AQI="))
.unwrap();
let id = context.disclosed_contracts()[0]
.template_id
.as_ref()
.unwrap();
assert_eq!(id.package_id, "abc123");
assert_eq!(id.module_name, "Splice.Api.Token.HoldingV1");
assert_eq!(id.entity_name, "Holding");
}
#[test]
fn a_template_id_that_is_not_three_parts_is_refused() {
let err = ChoiceContext::from_wire(wire("pkg:OnlyTwo", "AQI=")).expect_err("two parts");
assert!(err.to_string().contains("OnlyTwo"), "{err}");
let err = ChoiceContext::from_wire(wire("pkg::Entity", "AQI=")).expect_err("empty module");
assert!(err.to_string().contains("Module"), "{err}");
}
#[test]
fn a_blob_that_is_not_base64_is_refused_naming_the_contract() {
let err =
ChoiceContext::from_wire(wire("pkg:M:E", "not base64!!")).expect_err("bad base64");
assert!(err.to_string().contains("00abc"), "{err}");
assert!(err.to_string().contains("base64"), "{err}");
}
#[test]
fn the_context_decodes_into_the_generated_choice_context() {
use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
let wire = WireChoiceContext {
choice_context_data: serde_json::json!({
"values": { "amulet-rules": { "tag": "AV_ContractId", "value": "001" } }
}),
disclosed_contracts: Vec::new(),
};
let context = ChoiceContext::from_wire(wire).unwrap();
let decoded: md::ChoiceContext = context.decode().expect("the generated type");
assert_eq!(decoded.values.len(), 1);
assert!(decoded.values.contains_key("amulet-rules"));
}
#[test]
fn an_empty_meta_is_not_sent() {
let request = ChoiceContextRequest::default();
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({ "excludeDebugFields": true })
);
let mut request = ChoiceContextRequest::default();
request.meta.insert("k".to_string(), "v".to_string());
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({ "meta": { "k": "v" }, "excludeDebugFields": true })
);
let request = ChoiceContextRequest {
exclude_debug_fields: false,
..ChoiceContextRequest::default()
};
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({})
);
}
}