Skip to main content

canton_token/
context.rs

1//! The choice context a registry returns, and what it becomes on the ledger.
2//!
3//! A token-standard choice is exercised through an interface, and the registry
4//! — not the caller — knows what reference data that choice needs. So the
5//! caller asks for a *choice context* and gets two things back: some data to
6//! pass into the choice, and the contracts the participant must be shown for
7//! the choice to resolve. The second half is why explicit disclosure exists:
8//! those contracts are typically not visible to the submitting party at all.
9//!
10//! The wire types here are exactly the token-standard OpenAPI's, so the
11//! translation into the Ledger API happens in one place with the differences
12//! stated: a JSON `createdEventBlob` is base64 and a gRPC one is bytes, and a
13//! template id is one string on the wire and three fields in the API.
14
15use base64::Engine as _;
16use canton_core::{Error, Result};
17use canton_proto::com::daml::ledger::api::v2 as pb;
18use serde::{Deserialize, Serialize};
19
20/// A request for a choice context.
21///
22/// `meta` is passed to the choice and folded into the context by the registry;
23/// the standard provides it for extensibility and most callers send none.
24#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26#[non_exhaustive]
27pub struct ChoiceContextRequest {
28    /// Left out entirely when empty — the field is optional, and an empty
29    /// object is a different thing to say than nothing.
30    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
31    pub meta: std::collections::BTreeMap<String, String>,
32    /// Ask the registry to leave out the debug fields (`debugPayload`,
33    /// `debugPackageName`, `debugCreatedAt` on each disclosed contract).
34    ///
35    /// Every choice-context request in the standard has it — V1's
36    /// `GetChoiceContextRequest` as much as V2's — and this crate reads none
37    /// of the fields it suppresses, so the default is `true`: bytes on the
38    /// wire for no purpose. Set it to `false` to see them while debugging a
39    /// registry.
40    #[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/// A contract the participant must be shown for a choice to resolve.
54///
55/// The `debug*` fields the standard also defines are deliberately not read:
56/// the specification says to use them only if the provider is trusted, since
57/// they need not match the `createdEventBlob` — and nothing here needs them.
58#[derive(Clone, Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[non_exhaustive]
61pub struct WireDisclosedContract {
62    /// `<package>:<Module>:<Entity>`.
63    pub template_id: String,
64    /// The contract id.
65    pub contract_id: String,
66    /// The created-event blob, base64 as JSON requires.
67    pub created_event_blob: String,
68    /// The synchronizer the contract is currently assigned to.
69    pub synchronizer_id: String,
70}
71
72/// What a registry returns for a choice.
73#[derive(Clone, Debug, Deserialize)]
74#[serde(rename_all = "camelCase")]
75#[non_exhaustive]
76pub struct WireChoiceContext {
77    /// The data to pass into the choice, as Daml JSON.
78    pub choice_context_data: serde_json::Value,
79    /// The contracts to disclose.
80    pub disclosed_contracts: Vec<WireDisclosedContract>,
81}
82
83/// A choice context, ready to be used against either transport.
84#[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    /// Translate what the registry returned.
93    ///
94    /// # Errors
95    /// [`Error::UnexpectedResponse`] if a template id is not
96    /// `<package>:<Module>:<Entity>`, or a blob is not valid base64. Both are
97    /// the registry's mistake rather than the caller's, and both would
98    /// otherwise reach the participant as something it cannot parse.
99    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            // The JSON transport takes the wire form unchanged — the blob is
118            // already base64 there, so decoding and re-encoding it would only
119            // be a chance to get it wrong.
120            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    /// The context data, as the registry sent it.
135    #[must_use]
136    pub fn data(&self) -> &serde_json::Value {
137        &self.data
138    }
139
140    /// Decode the context data into a generated type.
141    ///
142    /// The registry encodes it with the Daml JSON API's rules, which is what
143    /// `canton-daml` implements, so the generated `ChoiceContext` deserializes
144    /// straight from it — no hand-written mapping.
145    ///
146    /// # Errors
147    /// [`Error::UnexpectedResponse`] if the data does not match `T`.
148    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    /// The contracts to disclose, for a gRPC submission.
158    #[must_use]
159    pub fn disclosed_contracts(&self) -> &[pb::DisclosedContract] {
160        &self.disclosed
161    }
162
163    /// The contracts to disclose, for the JSON transport.
164    #[must_use]
165    pub fn disclosed_contracts_json(&self) -> &[serde_json::Value] {
166        &self.disclosed_json
167    }
168
169    /// Take ownership of the disclosed contracts, for attaching to a
170    /// submission.
171    #[must_use]
172    pub fn into_disclosed_contracts(self) -> Vec<pb::DisclosedContract> {
173        self.disclosed
174    }
175}
176
177/// `<package>:<Module>:<Entity>` — the Ledger API's three fields in one string.
178///
179/// A module name may itself contain dots (`Splice.Api.Token.HoldingV1`) but
180/// never colons, so exactly three parts is the only well-formed shape. A fourth
181/// is rejected rather than folded into the entity name, where it would reach
182/// the participant as a template that does not exist and come back as an
183/// unrelated interpretation error.
184fn 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    /// The exact JSON a registry sends, decoded by the field names the
221    /// specification uses. A `serde` rename slip here is a field silently left
222    /// at its default, which the participant reports much later as a missing
223    /// disclosure.
224    #[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    /// gRPC takes bytes and JSON takes base64. Sending the base64 *text* as the
245    /// gRPC blob is a disclosure the participant cannot read, and it fails as
246    /// an unrelated interpretation error.
247    #[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    /// A module name has dots in it and an entity name does not have colons, so
260    /// the split is on colons and the module keeps its dots.
261    #[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    /// The context decodes into the generated type, because the registry uses
293    /// the Daml JSON encoding that `canton-daml` implements. This is what makes
294    /// the crate a workflow over generated types rather than a re-declaration
295    /// of them.
296    #[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    /// An empty `meta` is left off the request rather than sent as `{}`: the
313    /// field is optional, and saying nothing is not the same as saying empty.
314    #[test]
315    fn an_empty_meta_is_not_sent() {
316        // The default asks the registry to leave the debug fields out — every
317        // choice-context request in the standard, V1 and V2, has the flag —
318        // and says nothing about `meta`, because an empty object is a
319        // different thing to say than nothing.
320        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        // Asking for the debug fields back is the absence of the flag, which
334        // is also the request as it was sent before the flag existed.
335        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}