Skip to main content

dig_events_protocol/
value.rs

1//! The payload newtypes carried inside events (SPEC §3).
2//!
3//! These are the leaf value types the engine and apps agree on: which wallet an event concerns
4//! ([`WalletId`]), how much value moved ([`Amount`], in the smallest on-chain unit), and which
5//! asset ([`AssetId`], the CAT tail hex; `None` on an event means native XCH). They are thin
6//! newtypes so the wire shape is a bare number/string, not a tagged object.
7
8use serde::{Deserialize, Serialize};
9
10/// The stable identifier of a wallet within an engine instance.
11///
12/// Serializes as a bare `u32` on the wire.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14pub struct WalletId(pub u32);
15
16impl std::fmt::Display for WalletId {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "{}", self.0)
19    }
20}
21
22/// An on-chain value amount, in the smallest indivisible unit (mojos).
23///
24/// Serializes ALWAYS as a decimal string on the wire — every value, small or large — so the
25/// JavaScript/TypeScript binding maps it to a single `bigint` via `BigInt(str)`: one code path,
26/// no `typeof` branch, and never a silent precision loss past `Number.MAX_SAFE_INTEGER` (SPEC §3).
27/// The Rust representation stays `u64` because a Chia mojo amount always fits in 64 bits.
28///
29/// Deserialization accepts the canonical decimal string and — for leniency towards hand-written
30/// or legacy JSON — a bare JSON number, but serialization ALWAYS emits a string.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct Amount(pub u64);
33
34impl Amount {
35    /// The raw value in the smallest unit (mojos).
36    pub fn mojos(self) -> u64 {
37        self.0
38    }
39}
40
41impl Serialize for Amount {
42    /// Always emit the value as a decimal string so a JS consumer reads it as one `bigint`.
43    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
44        serializer.serialize_str(&self.0.to_string())
45    }
46}
47
48impl<'de> Deserialize<'de> for Amount {
49    /// Accept the canonical decimal string, and — leniently — a bare JSON number.
50    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
51        /// A decimal-string amount (canonical) or, for leniency, a bare JSON number.
52        #[derive(Deserialize)]
53        #[serde(untagged)]
54        enum StringOrNumber {
55            Text(String),
56            Number(u64),
57        }
58        match StringOrNumber::deserialize(deserializer)? {
59            StringOrNumber::Text(s) => s.parse().map(Amount).map_err(serde::de::Error::custom),
60            StringOrNumber::Number(n) => Ok(Amount(n)),
61        }
62    }
63}
64
65impl std::fmt::Display for Amount {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{}", self.0)
68    }
69}
70
71/// A CAT asset identifier — the asset's TAIL hash, hex-encoded.
72///
73/// On an event's `asset` field, `Some(AssetId(..))` is a CAT and `None` is native XCH.
74/// Serializes as a bare string on the wire.
75#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
76pub struct AssetId(pub String);
77
78impl std::fmt::Display for AssetId {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "{}", self.0)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    /// The largest integer a JavaScript `number` (IEEE-754 double) holds exactly: 2^53 − 1.
89    /// The always-string wire form means values above it survive the JS boundary via `BigInt`.
90    const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
91
92    #[test]
93    fn small_amount_serializes_as_string() {
94        assert_eq!(serde_json::to_string(&Amount(5)).unwrap(), "\"5\"");
95        assert_eq!(serde_json::to_string(&Amount(0)).unwrap(), "\"0\"");
96    }
97
98    #[test]
99    fn large_amount_serializes_as_string() {
100        let big = Amount(MAX_JS_SAFE_INTEGER + 1);
101        assert_eq!(serde_json::to_string(&big).unwrap(), "\"9007199254740992\"");
102    }
103
104    /// KAT proving a value past the JS-safe threshold serializes to the SAME decimal string
105    /// that dig-wallet-backend's original `Amount` (the extraction source) emits — byte-identical
106    /// across the two crates for the large-value case (#1112).
107    #[test]
108    fn large_amount_matches_extraction_source_byte_for_byte() {
109        // dig-wallet-backend's original test asserts Amount(2^53) -> "\"9007199254740992\"".
110        let value = MAX_JS_SAFE_INTEGER + 1;
111        assert_eq!(
112            serde_json::to_string(&Amount(value)).unwrap(),
113            "\"9007199254740992\"",
114        );
115        // And at the far end of the u64 range.
116        assert_eq!(
117            serde_json::to_string(&Amount(u64::MAX)).unwrap(),
118            "\"18446744073709551615\"",
119        );
120    }
121
122    #[test]
123    fn amount_deserializes_from_string() {
124        let from_str: Amount = serde_json::from_str("\"9007199254740992\"").unwrap();
125        assert_eq!(from_str, Amount(9_007_199_254_740_992));
126    }
127
128    #[test]
129    fn amount_leniently_deserializes_from_bare_number() {
130        let from_num: Amount = serde_json::from_str("42").unwrap();
131        assert_eq!(from_num, Amount(42));
132    }
133
134    #[test]
135    fn amount_round_trips_across_the_threshold() {
136        for value in [
137            0u64,
138            1,
139            MAX_JS_SAFE_INTEGER,
140            MAX_JS_SAFE_INTEGER + 1,
141            u64::MAX,
142        ] {
143            let json = serde_json::to_string(&Amount(value)).unwrap();
144            let back: Amount = serde_json::from_str(&json).unwrap();
145            assert_eq!(back, Amount(value), "round-trip failed for {value}");
146        }
147    }
148
149    #[test]
150    fn amount_bad_string_is_an_error() {
151        assert!(serde_json::from_str::<Amount>("\"not-a-number\"").is_err());
152    }
153
154    #[test]
155    fn mojos_accessor_returns_raw() {
156        assert_eq!(Amount(555).mojos(), 555);
157    }
158}