Skip to main content

canton_daml/
lib.rs

1//! Runtime for `canton-codegen`-generated Daml bindings.
2//!
3//! Generated code depends on this crate (imported as `rt`) for the Daml
4//! primitive types ([`Party`], [`ContractId`], [`Numeric`], [`Timestamp`],
5//! [`Date`], [`TextMap`], [`GenMap`]), the [`Template`] / [`Interface`] /
6//! [`Choice`] traits, the command builders ([`create_command`],
7//! [`exercise_command`], [`exercise_by_key_command`]), and the [`ToValue`] /
8//! [`FromValue`] codecs that move typed values to and from the Ledger API
9//! `Value` on the wire. The typed **read** path is
10//! [`Template::from_created_event`].
11//!
12//! It contains no ledger I/O — it is the thin type/codec layer beneath the
13//! generated bindings, kept separate from the codegen tool (which is
14//! build-time) and from the client crates (which do the actual RPCs).
15//!
16//! # Using it
17//!
18//! You rarely name this crate directly: `canton-codegen` emits bindings that
19//! import it as `rt`. What you *do* touch are its types, and the three things
20//! below — build a payload, turn it into a command, decode a result:
21//!
22//! ```rust,ignore
23//! use canton_daml as rt;
24//! use rt::Template as _; // brings `from_created_event` into scope
25//! use my_bindings::my_app::My_Module::{Asset, Asset_Transfer};
26//!
27//! // A typed payload, built from the runtime's Daml primitives.
28//! let asset = Asset {
29//!     owner: rt::Party::new("alice::1220ab…"),
30//!     price: "12.50".parse::<rt::Numeric>()?,
31//!     // `None` only for a datetime outside the microsecond range of an `i64`.
32//!     minted_at: rt::Timestamp::from_datetime(time::OffsetDateTime::now_utc())
33//!         .expect("now is representable"),
34//! };
35//!
36//! // Commands for the ledger client to submit.
37//! let create = rt::create_command(&asset);
38//! let transfer = rt::exercise_command(&contract_id, &Asset_Transfer {
39//!     new_owner: rt::Party::new("bob::1220cd…"),
40//! });
41//!
42//! // The typed read path: a CreatedEvent from a transaction or the ACS.
43//! let read_back: Asset = Asset::from_created_event(&event)?;
44//! # Ok::<(), Box<dyn std::error::Error>>(())
45//! ```
46//!
47//! Codec failures are [`ValueError`], which converts into `canton_core::Error`,
48//! so one function can call this runtime and the ledger client and use `?` on
49//! both.
50
51mod choice;
52mod commands;
53mod primitives;
54mod template;
55mod value;
56
57pub use choice::Choice;
58pub use commands::{create_command, exercise_by_key_command, exercise_command};
59pub use primitives::{
60    ContractId, Date, GenMap, Int64, NestedOpt, Numeric, NumericParseError, Party, PartyParseError,
61    TextMap, Timestamp, Unit,
62};
63pub use template::{Contract, Interface, Template, WithKey};
64pub use value::{FromValue, ToValue, ValueError, from_record};
65
66/// The pieces an emitted `ToValue`/`FromValue` body is written in terms of.
67///
68/// Public because generated code names them as `rt::…`, but hidden from the
69/// rendered docs: nothing here is meant to be called by hand. To build a
70/// `Value` yourself, use the builders in `canton_ledger`; to decode one, use
71/// [`FromValue`] or [`from_record`].
72#[doc(hidden)]
73pub use value::{
74    AbsentField, enum_constructor, enum_value, find_field, optional_field, record, record_fields,
75    required_field, unexpected_constructor, unit_value, variant_parts, variant_value,
76};
77
78/// The Ledger API `Value` and `Record` — the gRPC wire forms generated
79/// `ToValue`/`FromValue`/`Template::to_record` move to and from. Re-exported so
80/// generated code can name them as `rt::Value` / `rt::Record`.
81pub use canton_proto::com::daml::ledger::api::v2::{Record, Value};
82
83/// Re-exported `serde` so generated code can derive the JSON codec through the
84/// runtime (`#[derive(rt::serde::Serialize, ...)]` + `#[serde(crate = "rt::serde")]`)
85/// without depending on `serde` directly.
86pub use serde;
87
88#[cfg(test)]
89#[allow(
90    clippy::unwrap_used,
91    clippy::expect_used,
92    clippy::assertions_on_constants,
93    non_camel_case_types
94)]
95mod tests {
96    use super::*;
97
98    // As-if-generated shapes: a payload using the runtime types, its
99    // `ToValue`/`FromValue` written exactly as the generator emits them (via
100    // `record` / `record_fields`), and a choice argument with its `Choice` impl.
101    //
102    // "Exactly as the generator emits" is checked, not asserted:
103    // tests/fixture_matches_the_generator.rs runs the real emitter and compares
104    // the constructs. It drifted three times before that existed — the `.at()`
105    // on each decode, the serde derives, and the renames — and each time the
106    // drift silently removed a path from coverage while the tests stayed green.
107    #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
108    struct AppInstall {
109        #[serde(rename = "provider")]
110        provider: Party,
111        #[serde(rename = "amount")]
112        amount: Numeric,
113        #[serde(rename = "tags")]
114        tags: Vec<String>,
115        // The Daml label differs from the Rust name, which is the ordinary
116        // case — Daml writes camelCase and the generator snake-cases it. A
117        // fixture whose labels all happened to match left the rename itself
118        // unexercised here.
119        #[serde(rename = "noteText")]
120        note: Option<String>,
121    }
122
123    impl ToValue for AppInstall {
124        fn to_value(&self) -> Value {
125            record(vec![
126                ("provider", ToValue::to_value(&self.provider)),
127                ("amount", ToValue::to_value(&self.amount)),
128                ("tags", ToValue::to_value(&self.tags)),
129                ("noteText", ToValue::to_value(&self.note)),
130            ])
131        }
132    }
133
134    impl FromValue for AppInstall {
135        fn from_value(value: &Value) -> Result<Self, ValueError> {
136            Ok(Self {
137                provider: FromValue::from_value(required_field(value, 0, "provider")?)
138                    .map_err(|e| e.at("provider"))?,
139                amount: FromValue::from_value(required_field(value, 1, "amount")?)
140                    .map_err(|e| e.at("amount"))?,
141                tags: FromValue::from_value(required_field(value, 2, "tags")?)
142                    .map_err(|e| e.at("tags"))?,
143                note: optional_field(value, 3, "noteText").map_err(|e| e.at("noteText"))?,
144            })
145        }
146    }
147
148    struct AppInstall_Accept;
149    struct AppInstalled;
150
151    impl Choice<AppInstall> for AppInstall_Accept {
152        type Return = ContractId<AppInstalled>;
153        const NAME: &'static str = "Accept";
154        const CONSUMING: bool = true;
155    }
156
157    // A nullary choice argument is an empty record on the wire.
158    impl ToValue for AppInstall_Accept {
159        fn to_value(&self) -> Value {
160            record(vec![])
161        }
162    }
163
164    impl Contract for AppInstall {
165        const PACKAGE_ID: &'static str = "deadbeef";
166        const PACKAGE_NAME: &'static str = "app-install";
167        const MODULE_NAME: &'static str = "Licensing.AppInstall";
168        const ENTITY_NAME: &'static str = "AppInstall";
169    }
170
171    impl Template for AppInstall {
172        fn to_record(&self) -> Record {
173            record_fields(vec![
174                ("provider", ToValue::to_value(&self.provider)),
175                ("amount", ToValue::to_value(&self.amount)),
176                ("tags", ToValue::to_value(&self.tags)),
177                ("noteText", ToValue::to_value(&self.note)),
178            ])
179        }
180    }
181
182    impl WithKey for AppInstall {
183        type Key = Party;
184    }
185
186    // An interface marker + its view and one choice, as generated code emits them.
187    struct Holding;
188    struct HoldingView;
189    struct Holding_Transfer;
190
191    impl Contract for Holding {
192        const PACKAGE_ID: &'static str = "cafef00d";
193        const PACKAGE_NAME: &'static str = "splice-api-token-holding";
194        const MODULE_NAME: &'static str = "Splice.Api.Token.HoldingV1";
195        const ENTITY_NAME: &'static str = "Holding";
196    }
197    impl Interface for Holding {
198        type View = HoldingView;
199    }
200    impl FromValue for HoldingView {
201        fn from_value(_value: &Value) -> Result<Self, ValueError> {
202            Ok(HoldingView)
203        }
204    }
205    impl Choice<Holding> for Holding_Transfer {
206        type Return = ();
207        const NAME: &'static str = "Transfer";
208        const CONSUMING: bool = true;
209    }
210    impl ToValue for Holding_Transfer {
211        fn to_value(&self) -> Value {
212            record(vec![])
213        }
214    }
215
216    /// A failure inside a nested record must say which field it came from.
217    ///
218    /// The generator wraps every field decode in `ValueError::at`, and `at`
219    /// prepends, so the labels accumulate into a path as the error travels up.
220    /// Without it a mismatch three records down arrives as a bare
221    /// "expected Text" and the reader has to guess.
222    #[test]
223    fn a_nested_decode_failure_carries_the_field_path() {
224        // `AppInstall.amount` is a Numeric; hand it a Party instead.
225        let wrong = record(vec![
226            ("provider", ToValue::to_value(&Party::new("alice"))),
227            ("amount", ToValue::to_value(&Party::new("not-a-number"))),
228            ("tags", ToValue::to_value(&Vec::<String>::new())),
229            ("noteText", ToValue::to_value(&Option::<String>::None)),
230        ]);
231
232        let err = <AppInstall as FromValue>::from_value(&wrong).expect_err("amount is not a Party");
233        assert_eq!(err.path(), ["amount"], "the failing field must be named");
234        assert!(err.to_string().contains("amount"), "{err}");
235    }
236
237    /// A type mismatch must name the kind it found and nothing from inside it.
238    ///
239    /// The message travels into `canton_core::Error` and from there into
240    /// whatever the application logs, so echoing the value would put contract
241    /// contents — parties, amounts, free text — into traces and metrics.
242    #[test]
243    fn a_mismatch_names_the_kind_and_never_the_payload() {
244        let secret = "alice::1220deadbeef";
245        let payload = record(vec![
246            ("owner", crate::Party::new(secret).to_value()),
247            (
248                "memo",
249                "wired 4200 to the numbered account".to_string().to_value(),
250            ),
251        ]);
252
253        // Decoding a record as Text is the mismatch a user hits first.
254        let err = <String as FromValue>::from_value(&payload).expect_err("a Record is not a Text");
255        let message = err.to_string();
256
257        assert!(message.contains("expected Text"), "{message}");
258        assert!(
259            message.contains("Record"),
260            "should name the kind found: {message}"
261        );
262        assert!(!message.contains(secret), "leaked a party id: {message}");
263        assert!(
264            !message.contains("4200"),
265            "leaked a payload field: {message}"
266        );
267        assert!(!message.contains("memo"), "leaked a field label: {message}");
268
269        // The same holds once it has been converted into the SDK-wide error,
270        // which is the form that actually reaches a subscriber.
271        let sdk: canton_core::Error = err.into();
272        assert!(!sdk.to_string().contains(secret), "{sdk}");
273    }
274
275    #[test]
276    fn record_round_trips_through_value() {
277        let app = AppInstall {
278            provider: Party::new("alice::1"),
279            amount: Numeric("1.50".to_string()),
280            tags: vec!["defi".to_string(), "wallet".to_string()],
281            note: Some("hi".to_string()),
282        };
283        let back = AppInstall::from_value(&app.to_value()).unwrap();
284        assert_eq!(back, app);
285
286        // A missing field is a typed error, not a panic.
287        let empty = record(vec![]);
288        assert!(AppInstall::from_value(&empty).is_err());
289    }
290
291    #[test]
292    fn primitives_round_trip_through_value() {
293        let party = Party::new("alice::1");
294        assert_eq!(Party::from_value(&party.to_value()).unwrap(), party);
295
296        let numeric = Numeric("1.50".to_string());
297        assert_eq!(Numeric::from_value(&numeric.to_value()).unwrap(), numeric);
298
299        assert_eq!(i64::from_value(&42_i64.to_value()).unwrap(), 42);
300        assert!(bool::from_value(&true.to_value()).unwrap());
301    }
302
303    #[test]
304    fn containers_round_trip_through_value() {
305        let list = vec!["a".to_string(), "b".to_string()];
306        assert_eq!(Vec::<String>::from_value(&list.to_value()).unwrap(), list);
307
308        let some: Option<String> = Some("x".to_string());
309        assert_eq!(
310            Option::<String>::from_value(&some.to_value()).unwrap(),
311            some
312        );
313        let none: Option<String> = None;
314        assert_eq!(
315            Option::<String>::from_value(&none.to_value()).unwrap(),
316            none
317        );
318
319        let mut map = TextMap::new();
320        map.insert("k".to_string(), 7_i64);
321        assert_eq!(TextMap::<i64>::from_value(&map.to_value()).unwrap(), map);
322    }
323
324    #[test]
325    fn contract_id_is_typed_and_round_trips() {
326        let cid: ContractId<AppInstalled> = ContractId::new("00abc");
327        let back: ContractId<AppInstalled> = ContractId::from_value(&cid.to_value()).unwrap();
328        assert_eq!(back, cid);
329        assert_eq!(back.as_str(), "00abc");
330    }
331
332    #[test]
333    fn choice_metadata_is_available() {
334        assert_eq!(<AppInstall_Accept as Choice<AppInstall>>::NAME, "Accept");
335        assert!(<AppInstall_Accept as Choice<AppInstall>>::CONSUMING);
336    }
337
338    #[test]
339    fn wrong_shape_is_an_error() {
340        // A Text value cannot be decoded as a Party.
341        let text = "not a party".to_string().to_value();
342        assert!(Party::from_value(&text).is_err());
343    }
344
345    #[test]
346    fn newtypes_use_lf_json_encoding() {
347        use serde_json::json;
348
349        // String-shaped newtypes encode as JSON strings.
350        assert_eq!(
351            serde_json::to_value(Party::new("alice::1")).unwrap(),
352            json!("alice::1")
353        );
354        assert_eq!(
355            serde_json::to_value(Numeric("1.50".to_string())).unwrap(),
356            json!("1.50")
357        );
358        let cid: ContractId<()> = ContractId::new("00abc");
359        assert_eq!(serde_json::to_value(cid).unwrap(), json!("00abc"));
360
361        // Date encodes as an ISO calendar string.
362        assert_eq!(serde_json::to_value(Date(0)).unwrap(), json!("1970-01-01"));
363        assert_eq!(serde_json::to_value(Date(1)).unwrap(), json!("1970-01-02"));
364
365        // Timestamp and Date round-trip through JSON.
366        for ts in [Timestamp(0), Timestamp(1_700_000_000_000_000)] {
367            let text = serde_json::to_string(&ts).unwrap();
368            assert_eq!(serde_json::from_str::<Timestamp>(&text).unwrap(), ts);
369        }
370        for date in [Date(0), Date(20_000), Date(-100)] {
371            let text = serde_json::to_string(&date).unwrap();
372            assert_eq!(serde_json::from_str::<Date>(&text).unwrap(), date);
373        }
374    }
375
376    #[test]
377    fn int64_uses_the_lf_json_string_form() {
378        use serde_json::json;
379
380        // Emitted as a string (the Ledger API's encodeInt64AsString form)…
381        assert_eq!(serde_json::to_value(Int64(5)).unwrap(), json!("5"));
382        // …and accepted from either the string or the number form on input.
383        assert_eq!(serde_json::from_str::<Int64>("\"5\"").unwrap(), Int64(5));
384        assert_eq!(serde_json::from_str::<Int64>("5").unwrap(), Int64(5));
385        // A value beyond JS's safe integers still round-trips (why it's a string).
386        let big = Int64(9_007_199_254_740_993);
387        assert_eq!(
388            serde_json::from_str::<Int64>(&serde_json::to_string(&big).unwrap()).unwrap(),
389            big
390        );
391        // gRPC `Value` is a plain Int64.
392        assert_eq!(Int64::from_value(&Int64(7).to_value()).unwrap(), Int64(7));
393    }
394
395    #[test]
396    fn numeric_accepts_string_and_number_json() {
397        use serde_json::json;
398
399        // Emitted as a string…
400        assert_eq!(
401            serde_json::to_value(Numeric("1.50".to_string())).unwrap(),
402            json!("1.50")
403        );
404        // …and accepted from a string or a JSON number on input.
405        assert_eq!(
406            serde_json::from_str::<Numeric>("\"1.5\"").unwrap(),
407            Numeric("1.5".to_string())
408        );
409        assert_eq!(
410            serde_json::from_str::<Numeric>("2").unwrap(),
411            Numeric("2".to_string())
412        );
413    }
414
415    #[test]
416    fn nested_optional_uses_the_lf_json_list_form() {
417        use serde_json::json;
418
419        // Daml `Optional (Optional Text)` → `Option<NestedOpt<String>>`.
420        // Top-level: None → null; nested: Some None → [], Some (Some x) → [x].
421        let none: Option<NestedOpt<String>> = None;
422        let some_none: Option<NestedOpt<String>> = Some(NestedOpt(None));
423        let some_some: Option<NestedOpt<String>> = Some(NestedOpt(Some("x".to_string())));
424
425        assert_eq!(serde_json::to_value(&none).unwrap(), json!(null));
426        assert_eq!(serde_json::to_value(&some_none).unwrap(), json!([]));
427        assert_eq!(serde_json::to_value(&some_some).unwrap(), json!(["x"]));
428
429        // The whole point: Some(None) survives a JSON round-trip instead of
430        // collapsing into None.
431        for value in [none, some_none, some_some] {
432            let text = serde_json::to_string(&value).unwrap();
433            assert_eq!(
434                serde_json::from_str::<Option<NestedOpt<String>>>(&text).unwrap(),
435                value
436            );
437        }
438
439        // Triple nesting: Some (Some None) → [[]].
440        let triple: Option<NestedOpt<NestedOpt<String>>> = Some(NestedOpt(Some(NestedOpt(None))));
441        assert_eq!(serde_json::to_value(&triple).unwrap(), json!([[]]));
442    }
443
444    #[test]
445    fn nested_optional_grpc_matches_option() {
446        // On the gRPC wire a nested Optional is still a proto Optional, so
447        // Some(None) and None stay distinct and round-trip.
448        let some_none: Option<NestedOpt<String>> = Some(NestedOpt(None));
449        let back: Option<NestedOpt<String>> = FromValue::from_value(&some_none.to_value()).unwrap();
450        assert_eq!(back, some_none);
451
452        let none: Option<NestedOpt<String>> = None;
453        assert_ne!(none.to_value(), some_none.to_value());
454    }
455
456    #[test]
457    fn unit_is_the_empty_object_in_json() {
458        use serde_json::json;
459
460        // Daml `Unit` is LF-JSON `{}` (not `null`, which is what `()` would be).
461        assert_eq!(serde_json::to_value(Unit).unwrap(), json!({}));
462        assert_eq!(serde_json::from_str::<Unit>("{}").unwrap(), Unit);
463        // Tolerates a null too, so either shape from the API is accepted.
464        assert_eq!(serde_json::from_str::<Unit>("null").unwrap(), Unit);
465        // gRPC `Value` round-trips as the proto Unit.
466        assert_eq!(Unit::from_value(&Unit.to_value()).unwrap(), Unit);
467    }
468
469    #[test]
470    fn command_builders_produce_ledger_api_commands() {
471        use canton_proto::com::daml::ledger::api::v2::command::Command as Cmd;
472
473        let app = AppInstall {
474            provider: Party::new("alice::1"),
475            amount: Numeric("1.00".to_string()),
476            tags: vec![],
477            note: None,
478        };
479
480        // create → CreateCommand with the SCU-friendly `#<package-name>` id and
481        // the payload as its create arguments.
482        let create = create_command(&app);
483        match create.command {
484            Some(Cmd::Create(c)) => {
485                let id = c.template_id.unwrap();
486                assert_eq!(id.package_id, "#app-install");
487                assert_eq!(id.module_name, "Licensing.AppInstall");
488                assert_eq!(id.entity_name, "AppInstall");
489                assert!(c.create_arguments.is_some(), "payload becomes create args");
490            }
491            _ => panic!("expected a Create command"),
492        }
493
494        // exercise → ExerciseCommand carrying the contract id, choice name, arg.
495        let cid: ContractId<AppInstall> = ContractId::new("00cid");
496        let exercise = exercise_command(&cid, &AppInstall_Accept);
497        match exercise.command {
498            Some(Cmd::Exercise(e)) => {
499                assert_eq!(e.contract_id, "00cid");
500                assert_eq!(e.choice, "Accept");
501                assert_eq!(e.template_id.unwrap().entity_name, "AppInstall");
502                assert!(e.choice_argument.is_some());
503            }
504            _ => panic!("expected an Exercise command"),
505        }
506
507        // exercise-by-key → ExerciseByKeyCommand carrying the key as a Value.
508        let key = Party::new("alice::1");
509        let by_key = exercise_by_key_command::<AppInstall, _>(&key, &AppInstall_Accept);
510        match by_key.command {
511            Some(Cmd::ExerciseByKey(e)) => {
512                assert_eq!(e.choice, "Accept");
513                assert_eq!(e.template_id.unwrap().entity_name, "AppInstall");
514                assert!(
515                    e.contract_key.is_some(),
516                    "key becomes the contract_key value"
517                );
518            }
519            _ => panic!("expected an ExerciseByKey command"),
520        }
521    }
522
523    #[test]
524    fn exercise_on_interface_builds_a_command() {
525        use canton_proto::com::daml::ledger::api::v2::command::Command as Cmd;
526
527        // A choice exercised through a `ContractId<Interface>` uses the interface
528        // id — no concrete template needed.
529        let cid: ContractId<Holding> = ContractId::new("00hold");
530        let cmd = exercise_command(&cid, &Holding_Transfer);
531        match cmd.command {
532            Some(Cmd::Exercise(e)) => {
533                assert_eq!(e.contract_id, "00hold");
534                assert_eq!(e.choice, "Transfer");
535                let id = e.template_id.unwrap();
536                assert_eq!(id.entity_name, "Holding");
537                assert_eq!(id.package_id, "#splice-api-token-holding");
538            }
539            _ => panic!("expected an Exercise command on the interface"),
540        }
541    }
542
543    // ---- record decode robustness (Canton wire shapes) ----------------------
544
545    /// A record value as Canton emits it: `labelled` chooses verbose (labels
546    /// present) vs non-verbose (labels empty), `take` truncates trailing fields
547    /// the way record normalization drops empty optionals.
548    fn wire_record(labelled: bool, take: usize) -> Value {
549        use canton_proto::com::daml::ledger::api::v2 as pb;
550        let sample = AppInstall {
551            provider: Party::new("p::1"),
552            amount: Numeric("1.5".to_string()),
553            tags: vec!["a".to_string()],
554            note: None,
555        };
556        let Some(pb::value::Sum::Record(mut record)) = sample.to_value().sum else {
557            panic!("record expected");
558        };
559        record.fields.truncate(take);
560        if !labelled {
561            for field in &mut record.fields {
562                field.label = String::new();
563            }
564        }
565        Value {
566            sum: Some(pb::value::Sum::Record(record)),
567        }
568    }
569
570    #[test]
571    fn decodes_verbose_records_by_label() {
572        let decoded = AppInstall::from_value(&wire_record(true, 4)).unwrap();
573        assert_eq!(decoded.provider, Party::new("p::1"));
574        assert_eq!(decoded.note, None);
575    }
576
577    #[test]
578    fn decodes_non_verbose_records_by_position() {
579        // Non-verbose output omits every label; fields bind by declaration index.
580        let decoded = AppInstall::from_value(&wire_record(false, 4)).unwrap();
581        assert_eq!(decoded.provider, Party::new("p::1"));
582        assert_eq!(decoded.tags, vec!["a".to_string()]);
583    }
584
585    #[test]
586    fn decodes_normalized_records_with_trailing_optionals_dropped() {
587        // Record normalization (Smart Contract Upgrade) drops trailing fields
588        // whose value is an empty Optional — in both verbose and non-verbose
589        // shapes the absent `note` must decode as `None`, not error.
590        for labelled in [true, false] {
591            let decoded = AppInstall::from_value(&wire_record(labelled, 3)).unwrap();
592            assert_eq!(decoded.note, None, "labelled={labelled}");
593            assert_eq!(
594                decoded.amount,
595                Numeric("1.5".to_string()),
596                "labelled={labelled}"
597            );
598        }
599    }
600
601    #[test]
602    fn genmap_is_constructible_for_real_daml_key_types() {
603        // Regression: a derived `Default` bounded `K: Default, V: Default`, so
604        // `GenMap::<Party, Numeric>::new()` did not compile — no Daml primitive
605        // implements Default.
606        let mut map: GenMap<Party, Numeric> = GenMap::new();
607        assert!(map.is_empty());
608        map.insert(Party::new("alice::1"), Numeric::from_wire("1.5"));
609        assert_eq!(map.len(), 1);
610        assert_eq!(GenMap::<Party, Numeric>::default().len(), 0);
611
612        // The collection surface behaves like a collection.
613        let collected: GenMap<Party, Int64> =
614            [(Party::new("a::1"), Int64(1))].into_iter().collect();
615        assert_eq!(collected.iter().count(), 1);
616        assert_eq!((&collected).into_iter().count(), 1);
617        assert_eq!(collected.into_iter().count(), 1);
618    }
619
620    #[test]
621    fn genmap_equality_is_order_insensitive_and_symmetric() {
622        let a = Party::new("a::1");
623        let b = Party::new("b::1");
624
625        // Order is a wire detail.
626        let submitted: GenMap<Party, Int64> =
627            vec![(a.clone(), Int64(1)), (b.clone(), Int64(2))].into();
628        let echoed: GenMap<Party, Int64> =
629            vec![(b.clone(), Int64(2)), (a.clone(), Int64(1))].into();
630        assert_eq!(submitted, echoed);
631
632        // Regression: equality must be a multiset comparison. With a repeated
633        // entry, "every entry of self occurs in other" made `[a, a] == [a, b]`
634        // true while the reverse was false — a broken PartialEq contract.
635        let twice: GenMap<Party, Int64> = vec![(a.clone(), Int64(1)), (a.clone(), Int64(1))].into();
636        let mixed: GenMap<Party, Int64> = vec![(a.clone(), Int64(1)), (b, Int64(2))].into();
637        assert_ne!(twice, mixed);
638        assert_ne!(mixed, twice, "equality must be symmetric");
639        assert_eq!(twice, twice.clone());
640    }
641
642    #[test]
643    fn timestamp_and_date_convert_through_time_without_saturating() {
644        use time::macros::{date, datetime};
645
646        let at = Timestamp::from_datetime(datetime!(2026-07-31 12:00:00 UTC)).unwrap();
647        assert_eq!(
648            at.to_datetime().unwrap(),
649            datetime!(2026-07-31 12:00:00 UTC)
650        );
651        assert_eq!(Timestamp(at.micros()), at);
652
653        // Sub-microsecond digits floor toward the past on both sides of the
654        // epoch — they must never move an instant forward.
655        let before_epoch = datetime!(1969-12-31 23:59:59.999_999_5 UTC);
656        let floored = Timestamp::from_datetime(before_epoch).unwrap();
657        assert!(floored.to_datetime().unwrap() <= before_epoch);
658
659        // Regression: an out-of-range instant used to come back as i64::MAX —
660        // a far-future timestamp silently standing in for an underflow. It is
661        // `None` now. With `time`'s default features every representable
662        // OffsetDateTime fits in i64 microseconds, so the out-of-range case is
663        // only reachable when something in the dependency graph enables
664        // `time/large-dates` (years to ±999_999) — which feature unification
665        // can do without this crate asking. Hence the fallible signature; the
666        // property asserted here is that conversion never fabricates a value.
667        let extreme = time::OffsetDateTime::new_utc(date!(9999 - 12 - 31), time::Time::MIDNIGHT);
668        let converted = Timestamp::from_datetime(extreme).expect("in range for default `time`");
669        assert_eq!(converted.to_datetime().unwrap(), extreme);
670
671        let day = Date::from_date(date!(2026 - 07 - 31)).unwrap();
672        assert_eq!(day.to_date().unwrap(), date!(2026 - 07 - 31));
673        assert_eq!(Date::from_date(date!(1970 - 01 - 01)).unwrap().days(), 0);
674    }
675
676    #[test]
677    fn numeric_parsing_is_strict_for_callers_and_lenient_on_the_wire() {
678        use std::str::FromStr as _;
679
680        // Caller input is validated…
681        assert_eq!(Numeric::from_str("1.5").unwrap().as_str(), "1.5");
682        assert_eq!(Numeric::try_from("-0.25").unwrap().to_string(), "-0.25");
683        let error = Numeric::from_str("twelve").unwrap_err();
684        assert_eq!(error.input(), "twelve");
685
686        // …while a value the ledger vouched for is taken as-is.
687        let wire = Numeric::from_wire("1.5000000000");
688        assert_eq!(wire, Numeric::from_str("1.5").unwrap());
689
690        // `42.` is a legal spelling of `42` and must stay inside canonical
691        // comparison rather than degrading to string equality.
692        assert_eq!(Numeric::from_wire("42."), Numeric::from_wire("42"));
693        assert_eq!(Numeric::from_wire("42."), Numeric::from_wire("42.000"));
694    }
695
696    #[test]
697    fn value_errors_carry_a_path_and_flow_into_the_sdk_error() {
698        // A decode failure deep in a payload names where it happened…
699        let error = ValueError::new("expected Text")
700            .at("city")
701            .at("address")
702            .at("owner");
703        assert_eq!(error.path(), ["owner", "address", "city"]);
704        assert!(
705            error.to_string().contains("`owner.address.city`"),
706            "{error}"
707        );
708        assert_eq!(error.message(), "expected Text");
709
710        // …and `?` carries it into the SDK-wide error, so one function can call
711        // the typed runtime and the ledger client and return canton::Result.
712        let sdk: canton_core::Error = error.clone().into();
713        assert!(!sdk.is_retriable(), "a shape mismatch will not fix itself");
714        assert!(
715            std::error::Error::source(&sdk)
716                .map(ToString::to_string)
717                .unwrap_or_default()
718                .contains("owner.address.city"),
719            "the cause chain keeps the path"
720        );
721    }
722
723    #[test]
724    fn numeric_compares_by_value_not_by_text() {
725        use std::collections::hash_map::DefaultHasher;
726        use std::hash::{Hash, Hasher};
727
728        // The ledger echoes numerics at the type's full scale: a submitted
729        // "1.5" comes back "1.5000000000" (Numeric 10) and must compare equal.
730        let submitted = Numeric("1.5".to_string());
731        let echoed = Numeric("1.5000000000".to_string());
732        assert_eq!(submitted, echoed);
733
734        let hash = |n: &Numeric| {
735            let mut hasher = DefaultHasher::new();
736            n.hash(&mut hasher);
737            hasher.finish()
738        };
739        assert_eq!(hash(&submitted), hash(&echoed), "Eq and Hash must agree");
740
741        // Sign/zero normalization and numeric (not lexicographic) order.
742        assert_eq!(Numeric("-0".to_string()), Numeric("0.00".to_string()));
743        assert_eq!(Numeric("+07.10".to_string()), Numeric("7.1".to_string()));
744        assert!(Numeric("2".to_string()) < Numeric("10".to_string()));
745        assert!(Numeric("1.45".to_string()) < Numeric("1.5".to_string()));
746        assert!(Numeric("-1.5".to_string()) < Numeric("-1.45".to_string()));
747        assert!(Numeric("-1".to_string()) < Numeric("0.1".to_string()));
748
749        // Garbage never equals a valid decimal, and parse rejects it early.
750        assert_ne!(Numeric("abc".to_string()), Numeric("1".to_string()));
751        assert!(Numeric::parse("1.5").is_ok());
752        assert!(Numeric::parse("-12.500").is_ok());
753        // `1.` is a legal spelling of `1` (see canonical_decimal); the rest
754        // are not decimal literals.
755        assert!(Numeric::parse("1.").is_ok());
756        for bad in ["", ".5", "1.2.3", "1e5", "abc", "1,5"] {
757            assert!(Numeric::parse(bad).is_err(), "{bad:?} should be rejected");
758        }
759    }
760
761    #[test]
762    fn contract_id_retags_for_interface_exercise() {
763        use canton_proto::com::daml::ledger::api::v2::command::Command as Cmd;
764
765        // Read back as the concrete template, exercised through the interface.
766        let concrete: ContractId<AppInstall> = ContractId::new("00abc");
767        let as_interface: ContractId<Holding> = concrete.retag();
768        assert_eq!(as_interface.as_str(), "00abc");
769        let cmd = exercise_command(&as_interface, &Holding_Transfer);
770        match cmd.command {
771            Some(Cmd::Exercise(e)) => {
772                assert_eq!(
773                    e.template_id.unwrap().package_id,
774                    "#splice-api-token-holding"
775                );
776            }
777            _ => panic!("expected an interface exercise"),
778        }
779
780        // Typed ids work in hash/ordered collections.
781        let mut set = std::collections::HashSet::new();
782        set.insert(ContractId::<AppInstall>::new("00abc"));
783        assert!(set.contains(&ContractId::<AppInstall>::new("00abc")));
784    }
785
786    #[test]
787    fn from_created_event_decodes_the_typed_read_path() {
788        use canton_proto::com::daml::ledger::api::v2 as pb;
789
790        let payload = AppInstall {
791            provider: Party::new("p::1"),
792            amount: Numeric("2".to_string()),
793            tags: vec![],
794            note: None,
795        };
796        let Some(pb::value::Sum::Record(record)) = payload.to_value().sum else {
797            panic!("record expected");
798        };
799        let event = pb::CreatedEvent {
800            template_id: Some(pb::Identifier {
801                // Any package id: SCU may report any vetted version — not compared.
802                package_id: "someotherhash".to_string(),
803                module_name: AppInstall::MODULE_NAME.to_string(),
804                entity_name: AppInstall::ENTITY_NAME.to_string(),
805            }),
806            package_name: AppInstall::PACKAGE_NAME.to_string(),
807            create_arguments: Some(record),
808            ..Default::default()
809        };
810
811        let decoded = AppInstall::from_created_event(&event).unwrap();
812        assert_eq!(decoded, payload);
813
814        // The wrong template is rejected with a clear identity error, not a
815        // confusing field-shape mismatch.
816        let mut wrong = event.clone();
817        wrong.template_id.as_mut().unwrap().entity_name = "SomethingElse".to_string();
818        let error = AppInstall::from_created_event(&wrong).unwrap_err();
819        assert!(error.message().contains("SomethingElse"), "{error}");
820        assert!(error.message().contains("AppInstall"), "{error}");
821
822        // The same module and entity from another package is another
823        // template, however well its payload fits.
824        let mut foreign = event.clone();
825        foreign.package_name = "someone-elses-app".to_string();
826        let error = AppInstall::from_created_event(&foreign).unwrap_err();
827        assert!(error.message().contains("someone-elses-app"), "{error}");
828
829        // No payload is a typed error.
830        let mut empty = event.clone();
831        empty.create_arguments = None;
832        assert!(AppInstall::from_created_event(&empty).is_err());
833    }
834
835    #[test]
836    fn missing_required_field_is_an_error_naming_the_field() {
837        // Truncating past the optional tail removes the required `tags` field.
838        let error = AppInstall::from_value(&wire_record(true, 2)).unwrap_err();
839        assert!(
840            error.message().contains("tags"),
841            "error should name the missing field: {error}"
842        );
843    }
844
845    /// The JSON counterpart of the typed read path, including the check that
846    /// makes it safe to run over a stream carrying other templates.
847    #[test]
848    fn from_json_created_event_decodes_and_refuses_the_wrong_template() {
849        use serde_json::json;
850
851        let payload = json!({
852            "provider": "alice::1220ab",
853            "amount": "1.5",
854            "tags": ["a"],
855            "noteText": null
856        });
857
858        // Wrapped the way a transaction's `events` are.
859        let event = json!({"CreatedEvent": {
860            "contractId": "00abc",
861            "templateId": "somepackage:Licensing.AppInstall:AppInstall",
862            "packageName": "app-install",
863            "createArgument": payload,
864        }});
865        let decoded = AppInstall::from_json_created_event(&event).unwrap();
866        assert_eq!(decoded.provider, Party::new("alice::1220ab"));
867        assert_eq!(decoded.amount, Numeric::from_wire("1.5"));
868
869        // Bare, the way an ACS entry's `createdEvent` arrives once unwrapped.
870        let bare = json!({
871            "templateId": "somepackage:Licensing.AppInstall:AppInstall",
872            "packageName": "app-install",
873            "createArgument": payload,
874        });
875        assert!(AppInstall::from_json_created_event(&bare).is_ok());
876
877        // A different template whose payload happens to fit. Without the
878        // identity check this decodes cleanly and is the wrong contract — the
879        // whole reason the gRPC path checks, and the reason this one must.
880        let impostor = json!({"CreatedEvent": {
881            "templateId": "somepackage:Other.Module:SomethingElse",
882            "packageName": "app-install",
883            "createArgument": payload,
884        }});
885        let error = AppInstall::from_json_created_event(&impostor)
886            .expect_err("a different template must not decode");
887        assert!(error.to_string().contains("Other.Module"), "{error}");
888        assert!(
889            error.to_string().contains("Licensing.AppInstall"),
890            "{error}"
891        );
892
893        // The package id is not compared: under SCU the ledger reports whichever
894        // version it vetted, so pinning it would reject a legitimate upgrade.
895        let upgraded = json!({"CreatedEvent": {
896            "templateId": "adifferentpackageid:Licensing.AppInstall:AppInstall",
897            "packageName": "app-install",
898            "createArgument": payload,
899        }});
900        assert!(AppInstall::from_json_created_event(&upgraded).is_ok());
901
902        // The package **name** is compared: the same module and entity from an
903        // unrelated package is a different template.
904        let foreign = json!({"CreatedEvent": {
905            "templateId": "adifferentpackageid:Licensing.AppInstall:AppInstall",
906            "packageName": "someone-elses-app",
907            "createArgument": payload,
908        }});
909        let error = AppInstall::from_json_created_event(&foreign)
910            .expect_err("another package must not decode");
911        assert!(error.to_string().contains("someone-elses-app"), "{error}");
912
913        // No payload at all is an error, not a default-constructed value.
914        let empty = json!({"CreatedEvent": {"contractId": "00abc"}});
915        assert!(AppInstall::from_json_created_event(&empty).is_err());
916    }
917    /// A failure inside a container says *which* element, the way a failure
918    /// inside a record says which field.
919    ///
920    /// Without this the path stopped at the field: one bad entry in a list of
921    /// five hundred holdings reported that the list was bad and left the reader
922    /// to find which. `ValueError::at` was always documented as taking "a field
923    /// (or index)"; nothing passed it an index.
924    #[test]
925    fn a_container_element_is_named_in_the_path() {
926        use std::collections::BTreeMap;
927
928        // List: the position.
929        let list = wrong_at_index();
930        let error = <Vec<Party> as FromValue>::from_value(&list).unwrap_err();
931        assert_eq!(error.path(), ["2"], "{error}");
932        assert!(error.to_string().contains("expected Party"), "{error}");
933
934        // Nested in a record, the two paths compose the way a reader reads them.
935        let record = record(vec![("holders", list)]);
936        let error = <AppInstallHolders as FromValue>::from_value(&record).unwrap_err();
937        assert_eq!(error.path(), ["holders", "2"], "{error}");
938        assert!(error.to_string().contains("`holders.2`"), "{error}");
939
940        // TextMap: the key, which locates the entry better than a position.
941        let map = Value {
942            sum: Some(
943                canton_proto::com::daml::ledger::api::v2::value::Sum::TextMap(
944                    canton_proto::com::daml::ledger::api::v2::TextMap {
945                        entries: vec![canton_proto::com::daml::ledger::api::v2::text_map::Entry {
946                            key: "alice".to_string(),
947                            value: Some(42_i64.to_value()),
948                        }],
949                    },
950                ),
951            ),
952        };
953        let error = <BTreeMap<String, Party> as FromValue>::from_value(&map).unwrap_err();
954        assert_eq!(error.path(), ["alice"], "{error}");
955
956        // GenMap: the position, and which half of the entry.
957        let genmap = Value {
958            sum: Some(
959                canton_proto::com::daml::ledger::api::v2::value::Sum::GenMap(
960                    canton_proto::com::daml::ledger::api::v2::GenMap {
961                        entries: vec![canton_proto::com::daml::ledger::api::v2::gen_map::Entry {
962                            key: Some(Party::new("alice::1220ab").to_value()),
963                            value: Some(42_i64.to_value()),
964                        }],
965                    },
966                ),
967            ),
968        };
969        let error = <GenMap<Party, Party> as FromValue>::from_value(&genmap).unwrap_err();
970        assert_eq!(error.path(), ["0", "value"], "{error}");
971    }
972
973    /// A list whose third element is the wrong kind.
974    fn wrong_at_index() -> Value {
975        list(vec![
976            Party::new("a::1220ab").to_value(),
977            Party::new("b::1220ab").to_value(),
978            42_i64.to_value(),
979        ])
980    }
981
982    fn list(elements: Vec<Value>) -> Value {
983        use canton_proto::com::daml::ledger::api::v2 as pb;
984        Value {
985            sum: Some(pb::value::Sum::List(pb::List { elements })),
986        }
987    }
988
989    #[derive(Debug, PartialEq)]
990    struct AppInstallHolders {
991        holders: Vec<Party>,
992    }
993    impl FromValue for AppInstallHolders {
994        fn from_value(value: &Value) -> Result<Self, ValueError> {
995            Ok(Self {
996                holders: FromValue::from_value(required_field(value, 0, "holders")?)
997                    .map_err(|e| e.at("holders"))?,
998            })
999        }
1000    }
1001    /// A party id a caller supplies is checked; one the ledger issued is not.
1002    ///
1003    /// The asymmetry is the same as `Numeric`'s and for the same reason: the
1004    /// participant is the authority on what it issued, so refusing a value it
1005    /// vouched for would break decoding a payload the ledger considers valid.
1006    /// What a caller types has no such authority behind it.
1007    #[test]
1008    fn a_party_id_a_caller_supplies_is_validated() {
1009        // The shape that costs a debugging session: `PARTY=` in the
1010        // environment is `Ok("")`, which reaches the participant, comes back
1011        // `PermissionDenied`, and names nothing.
1012        assert_eq!("".parse::<Party>(), Err(PartyParseError::Empty));
1013
1014        assert_eq!(
1015            "alice\nbob".parse::<Party>(),
1016            Err(PartyParseError::UnexpectedCharacter { character: '\n' })
1017        );
1018        assert!(matches!(
1019            "x".repeat(256).parse::<Party>(),
1020            Err(PartyParseError::TooLong { length: 256 })
1021        ));
1022
1023        // A real party id from a 3.5.7 participant must pass, or the check is
1024        // stricter than the ledger and rejects legitimate input.
1025        let real = "app_provider_quickstart-dimakozoliy-1::\
1026                    12204a2925a78214d3a4c81f3b425f89894a42858b9e8f13a8427c68513232cdf640";
1027        assert!(real.parse::<Party>().is_ok(), "{real}");
1028        assert_eq!(real.parse::<Party>().unwrap().as_str(), real);
1029
1030        // And the wire path stays permissive: whatever the participant sent
1031        // decodes, checked or not.
1032        let odd = Party::new("something\nthe ledger vouched for");
1033        let value = odd.to_value();
1034        assert_eq!(<Party as FromValue>::from_value(&value).unwrap(), odd);
1035
1036        // The error says what is wrong without repeating the party id, which
1037        // names a real participant of the ledger.
1038        let message = PartyParseError::Empty.to_string();
1039        assert!(message.contains("cannot be empty"), "{message}");
1040    }
1041}