1use base64::Engine as _;
16use canton_core::{Error, Result};
17use canton_proto::com::daml::ledger::api::v2 as pb;
18use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26#[non_exhaustive]
27pub struct ChoiceContextRequest {
28 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
31 pub meta: std::collections::BTreeMap<String, String>,
32 #[serde(skip_serializing_if = "std::ops::Not::not")]
41 pub exclude_debug_fields: bool,
42}
43
44impl Default for ChoiceContextRequest {
45 fn default() -> Self {
46 Self {
47 meta: std::collections::BTreeMap::new(),
48 exclude_debug_fields: true,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[non_exhaustive]
61pub struct WireDisclosedContract {
62 pub template_id: String,
64 pub contract_id: String,
66 pub created_event_blob: String,
68 pub synchronizer_id: String,
70}
71
72#[derive(Clone, Debug, Deserialize)]
74#[serde(rename_all = "camelCase")]
75#[non_exhaustive]
76pub struct WireChoiceContext {
77 pub choice_context_data: serde_json::Value,
79 pub disclosed_contracts: Vec<WireDisclosedContract>,
81}
82
83#[derive(Clone, Debug)]
85pub struct ChoiceContext {
86 data: serde_json::Value,
87 disclosed: Vec<pb::DisclosedContract>,
88 disclosed_json: Vec<serde_json::Value>,
89}
90
91impl ChoiceContext {
92 pub fn from_wire(wire: WireChoiceContext) -> Result<Self> {
100 let mut disclosed = Vec::with_capacity(wire.disclosed_contracts.len());
101 let mut disclosed_json = Vec::with_capacity(wire.disclosed_contracts.len());
102 for contract in wire.disclosed_contracts {
103 disclosed.push(pb::DisclosedContract {
104 template_id: Some(identifier(&contract.template_id)?),
105 contract_id: contract.contract_id.clone(),
106 created_event_blob: base64::engine::general_purpose::STANDARD
107 .decode(&contract.created_event_blob)
108 .map_err(|e| {
109 Error::UnexpectedResponse(format!(
110 "the registry disclosed {} with a createdEventBlob that is not \
111 base64: {e}",
112 contract.contract_id
113 ))
114 })?,
115 synchronizer_id: contract.synchronizer_id.clone(),
116 });
117 disclosed_json.push(serde_json::json!({
121 "templateId": contract.template_id,
122 "contractId": contract.contract_id,
123 "createdEventBlob": contract.created_event_blob,
124 "synchronizerId": contract.synchronizer_id,
125 }));
126 }
127 Ok(Self {
128 data: wire.choice_context_data,
129 disclosed,
130 disclosed_json,
131 })
132 }
133
134 #[must_use]
136 pub fn data(&self) -> &serde_json::Value {
137 &self.data
138 }
139
140 pub fn decode<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
149 serde_json::from_value(self.data.clone()).map_err(|e| {
150 Error::UnexpectedResponse(format!(
151 "the registry's choice context does not decode as {}: {e}",
152 std::any::type_name::<T>()
153 ))
154 })
155 }
156
157 #[must_use]
159 pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
160 &self.disclosed
161 }
162
163 #[must_use]
165 pub fn disclosed_contracts_json(&self) -> &[serde_json::Value] {
166 &self.disclosed_json
167 }
168
169 #[must_use]
172 pub fn into_disclosed_contracts(self) -> Vec<pb::DisclosedContract> {
173 self.disclosed
174 }
175}
176
177fn identifier(template_id: &str) -> Result<pb::Identifier> {
185 let mut parts = template_id.split(':');
186 match (parts.next(), parts.next(), parts.next(), parts.next()) {
187 (Some(package_id), Some(module_name), Some(entity_name), None)
188 if !package_id.is_empty() && !module_name.is_empty() && !entity_name.is_empty() =>
189 {
190 Ok(pb::Identifier {
191 package_id: package_id.to_string(),
192 module_name: module_name.to_string(),
193 entity_name: entity_name.to_string(),
194 })
195 }
196 _ => Err(Error::UnexpectedResponse(format!(
197 "the registry disclosed a contract with template id `{template_id}`, which is not \
198 `<package>:<Module>:<Entity>`"
199 ))),
200 }
201}
202
203#[cfg(test)]
204#[allow(clippy::unwrap_used, clippy::expect_used)]
205mod tests {
206 use super::*;
207
208 fn wire(template_id: &str, blob: &str) -> WireChoiceContext {
209 WireChoiceContext {
210 choice_context_data: serde_json::json!({ "values": {} }),
211 disclosed_contracts: vec![WireDisclosedContract {
212 template_id: template_id.to_string(),
213 contract_id: "00abc".to_string(),
214 created_event_blob: blob.to_string(),
215 synchronizer_id: "sync::1220ab".to_string(),
216 }],
217 }
218 }
219
220 #[test]
225 fn the_wire_shape_is_the_one_the_standard_specifies() {
226 let json = serde_json::json!({
227 "choiceContextData": { "values": { "amulet-rules": { "tag": "AV_ContractId", "value": "001" } } },
228 "disclosedContracts": [{
229 "templateId": "pkg:Splice.AmuletRules:AmuletRules",
230 "contractId": "001",
231 "createdEventBlob": "AQI=",
232 "synchronizerId": "sync::1220ab",
233 "debugPackageName": "splice-amulet",
234 "debugPayload": { "ignored": true }
235 }]
236 });
237 let wire: WireChoiceContext = serde_json::from_value(json).expect("decodes");
238 assert_eq!(wire.disclosed_contracts.len(), 1);
239 assert_eq!(wire.disclosed_contracts[0].contract_id, "001");
240 assert_eq!(wire.disclosed_contracts[0].created_event_blob, "AQI=");
241 assert!(wire.choice_context_data.get("values").is_some());
242 }
243
244 #[test]
248 fn the_blob_is_bytes_for_grpc_and_stays_base64_for_json() {
249 let context =
250 ChoiceContext::from_wire(wire("pkg:Splice.AmuletRules:AmuletRules", "AQI=")).unwrap();
251
252 assert_eq!(context.disclosed_contracts()[0].created_event_blob, [1, 2]);
253 assert_eq!(
254 context.disclosed_contracts_json()[0]["createdEventBlob"],
255 "AQI="
256 );
257 }
258
259 #[test]
262 fn a_dotted_module_name_survives_the_split() {
263 let context =
264 ChoiceContext::from_wire(wire("abc123:Splice.Api.Token.HoldingV1:Holding", "AQI="))
265 .unwrap();
266 let id = context.disclosed_contracts()[0]
267 .template_id
268 .as_ref()
269 .unwrap();
270 assert_eq!(id.package_id, "abc123");
271 assert_eq!(id.module_name, "Splice.Api.Token.HoldingV1");
272 assert_eq!(id.entity_name, "Holding");
273 }
274
275 #[test]
276 fn a_template_id_that_is_not_three_parts_is_refused() {
277 let err = ChoiceContext::from_wire(wire("pkg:OnlyTwo", "AQI=")).expect_err("two parts");
278 assert!(err.to_string().contains("OnlyTwo"), "{err}");
279
280 let err = ChoiceContext::from_wire(wire("pkg::Entity", "AQI=")).expect_err("empty module");
281 assert!(err.to_string().contains("Module"), "{err}");
282 }
283
284 #[test]
285 fn a_blob_that_is_not_base64_is_refused_naming_the_contract() {
286 let err =
287 ChoiceContext::from_wire(wire("pkg:M:E", "not base64!!")).expect_err("bad base64");
288 assert!(err.to_string().contains("00abc"), "{err}");
289 assert!(err.to_string().contains("base64"), "{err}");
290 }
291
292 #[test]
297 fn the_context_decodes_into_the_generated_choice_context() {
298 use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1 as md;
299
300 let wire = WireChoiceContext {
301 choice_context_data: serde_json::json!({
302 "values": { "amulet-rules": { "tag": "AV_ContractId", "value": "001" } }
303 }),
304 disclosed_contracts: Vec::new(),
305 };
306 let context = ChoiceContext::from_wire(wire).unwrap();
307 let decoded: md::ChoiceContext = context.decode().expect("the generated type");
308 assert_eq!(decoded.values.len(), 1);
309 assert!(decoded.values.contains_key("amulet-rules"));
310 }
311
312 #[test]
315 fn an_empty_meta_is_not_sent() {
316 let request = ChoiceContextRequest::default();
321 assert_eq!(
322 serde_json::to_value(&request).unwrap(),
323 serde_json::json!({ "excludeDebugFields": true })
324 );
325
326 let mut request = ChoiceContextRequest::default();
327 request.meta.insert("k".to_string(), "v".to_string());
328 assert_eq!(
329 serde_json::to_value(&request).unwrap(),
330 serde_json::json!({ "meta": { "k": "v" }, "excludeDebugFields": true })
331 );
332
333 let request = ChoiceContextRequest {
336 exclude_debug_fields: false,
337 ..ChoiceContextRequest::default()
338 };
339 assert_eq!(
340 serde_json::to_value(&request).unwrap(),
341 serde_json::json!({})
342 );
343 }
344}