Skip to main content

dpp/tokens/
token_event.rs

1use crate::balances::credits::TokenAmount;
2use crate::block::block_info::BlockInfo;
3use crate::data_contract::accessors::v0::DataContractV0Getters;
4use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem;
5use crate::data_contract::associated_token::token_distribution_key::TokenDistributionTypeWithResolvedRecipient;
6use crate::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionResolvedRecipient;
7use crate::data_contract::document_type::DocumentTypeRef;
8use crate::document::{Document, DocumentV0};
9use crate::fee::Credits;
10use crate::prelude::{
11    DataContract, DerivationEncryptionKeyIndex, IdentityNonce, RootEncryptionKeyIndex,
12};
13#[cfg(feature = "json-conversion")]
14use crate::serialization::JsonConvertible;
15#[cfg(feature = "value-conversion")]
16use crate::serialization::ValueConvertible;
17use bincode::{Decode, Encode};
18use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize};
19use platform_value::Identifier;
20use platform_version::version::PlatformVersion;
21use std::collections::BTreeMap;
22use std::fmt;
23
24pub type TokenEventPublicNote = Option<String>;
25pub type TokenEventSharedEncryptedNote = Option<SharedEncryptedNote>;
26pub type TokenEventPersonalEncryptedNote = Option<(
27    RootEncryptionKeyIndex,
28    DerivationEncryptionKeyIndex,
29    Vec<u8>,
30)>;
31use crate::serialization::PlatformSerializableWithPlatformVersion;
32use crate::tokens::emergency_action::TokenEmergencyAction;
33use crate::tokens::token_pricing_schedule::TokenPricingSchedule;
34use crate::tokens::SharedEncryptedNote;
35use crate::ProtocolError;
36
37/// Alias representing the identity that will receive tokens or other effects from a token operation.
38pub type RecipientIdentifier = Identifier;
39
40/// Alias representing the identity that will have tokens burned from their account.
41pub type BurnFromIdentifier = Identifier;
42
43/// Alias representing the identity performing a token purchase.
44pub type PurchaserIdentifier = Identifier;
45
46/// Alias representing the identity whose tokens are subject to freezing or unfreezing.
47pub type FrozenIdentifier = Identifier;
48
49/// Represents a recorded token-related operation for use in historical documents and group actions.
50///
51/// `TokenEvent` is designed to encapsulate a single logical token operation,
52/// such as minting, burning, transferring, or freezing tokens. These events are typically:
53///
54/// - **Persisted as historical records** of state transitions, enabling auditability and tracking.
55/// - **Used in group (multisig) actions**, where multiple identities collaborate to authorize complex transitions.
56///
57/// This enum includes rich metadata for each type of operation, such as optional notes (plaintext or encrypted),
58/// involved identities, and amounts. It is **externally versioned** and marked as `unversioned` in platform serialization,
59/// meaning each variant is self-contained without requiring version dispatching logic.
60#[derive(
61    Debug, PartialEq, PartialOrd, Clone, Eq, Encode, Decode, PlatformDeserialize, PlatformSerialize,
62)]
63// Custom `Serialize` / `Deserialize` below — `TokenEvent` is a flat enum
64// with all-tuple variants. Internal tagging requires struct variants or
65// newtype-of-named-struct, which doesn't apply to tuple shapes. The custom
66// impl maps positional tuple fields to named JSON keys per variant, emits
67// an internal `$type` discriminator (no `data` wrapper), and uses the
68// `json_safe_u64`
69// / `json_safe_option_encrypted_note` helpers for u64 + encrypted-note
70// fields. Bincode `Encode` / `Decode` derives above are untouched —
71// consensus binary path is unaffected.
72#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))]
73#[platform_serialize(unversioned)]
74pub enum TokenEvent {
75    /// Event representing the minting of tokens to a recipient.
76    ///
77    /// - `TokenAmount`: The amount of tokens minted.
78    /// - `RecipientIdentifier`: The identity receiving the minted tokens.
79    /// - `TokenEventPublicNote`: Optional note associated with the event.
80    Mint(TokenAmount, RecipientIdentifier, TokenEventPublicNote),
81
82    /// Event representing the burning of tokens, removing them from circulation.
83    ///
84    /// - `TokenAmount`: The amount of tokens burned.
85    /// - `BurnFromIdentifier`: The account to burn from.
86    /// - `TokenEventPublicNote`: Optional note associated with the event.
87    Burn(TokenAmount, BurnFromIdentifier, TokenEventPublicNote),
88
89    /// Event representing freezing of tokens for a specific identity.
90    ///
91    /// - `FrozenIdentifier`: The identity whose tokens are frozen.
92    /// - `TokenEventPublicNote`: Optional note associated with the event.
93    Freeze(FrozenIdentifier, TokenEventPublicNote),
94
95    /// Event representing unfreezing of tokens for a specific identity.
96    ///
97    /// - `FrozenIdentifier`: The identity whose tokens are unfrozen.
98    /// - `TokenEventPublicNote`: Optional note associated with the event.
99    Unfreeze(FrozenIdentifier, TokenEventPublicNote),
100
101    /// Event representing destruction of tokens that were previously frozen.
102    ///
103    /// - `FrozenIdentifier`: The identity whose frozen tokens are destroyed.
104    /// - `TokenAmount`: The amount of frozen tokens destroyed.
105    /// - `TokenEventPublicNote`: Optional note associated with the event.
106    DestroyFrozenFunds(FrozenIdentifier, TokenAmount, TokenEventPublicNote),
107
108    /// Event representing a transfer of tokens from one identity to another.
109    ///
110    /// - `RecipientIdentifier`: The recipient of the tokens.
111    /// - `TokenEventPublicNote`: Optional plaintext note.
112    /// - `TokenEventSharedEncryptedNote`: Optional shared encrypted metadata (multi-party).
113    /// - `TokenEventPersonalEncryptedNote`: Optional private encrypted metadata (recipient-only).
114    /// - `TokenAmount`: The amount of tokens transferred.
115    Transfer(
116        RecipientIdentifier,
117        TokenEventPublicNote,
118        TokenEventSharedEncryptedNote,
119        TokenEventPersonalEncryptedNote,
120        TokenAmount,
121    ),
122
123    /// Event representing a claim of tokens from a distribution pool or source.
124    ///
125    /// - `TokenDistributionTypeWithResolvedRecipient`: Type and resolved recipient of the claim.
126    /// - `TokenAmount`: The amount of tokens claimed.
127    /// - `TokenEventPublicNote`: Optional note associated with the event.
128    Claim(
129        TokenDistributionTypeWithResolvedRecipient,
130        TokenAmount,
131        TokenEventPublicNote,
132    ),
133
134    /// Event representing an emergency action taken on a token or identity.
135    ///
136    /// - `TokenEmergencyAction`: The type of emergency action performed.
137    /// - `TokenEventPublicNote`: Optional note associated with the event.
138    EmergencyAction(TokenEmergencyAction, TokenEventPublicNote),
139
140    /// Event representing an update to the configuration of a token.
141    ///
142    /// - `TokenConfigurationChangeItem`: The configuration change that was applied.
143    /// - `TokenEventPublicNote`: Optional note associated with the event.
144    ConfigUpdate(TokenConfigurationChangeItem, TokenEventPublicNote),
145
146    /// Event representing a change in the direct purchase price of a token.
147    ///
148    /// - `Option<TokenPricingSchedule>`: The new pricing schedule. `None` disables direct purchase.
149    /// - `TokenEventPublicNote`: Optional note associated with the event.
150    ChangePriceForDirectPurchase(Option<TokenPricingSchedule>, TokenEventPublicNote),
151
152    /// Event representing the direct purchase of tokens by a user.
153    ///
154    /// - `TokenAmount`: The amount of tokens purchased.
155    /// - `Credits`: The number of credits paid.
156    DirectPurchase(TokenAmount, Credits),
157}
158
159// Manual impl because TokenEvent is a flat enum with u64-alias tuple variants
160// (TokenAmount, Credits). `#[derive(JsonConvertible)]` would fail: it asserts inner
161// variant types implement `JsonSafeFields`, but TokenAmount/Credits are u64 aliases
162// which intentionally don't. The `#[json_safe_fields]` macro can't annotate tuple
163// variant fields either. Safety is ensured by manual `impl JsonSafeFields` in
164// safe_fields.rs — the developer takes responsibility for these fields.
165#[cfg(feature = "json-conversion")]
166impl JsonConvertible for TokenEvent {}
167
168#[cfg(feature = "serde-conversion")]
169impl serde::Serialize for TokenEvent {
170    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
171        use serde::ser::SerializeMap;
172
173        // Wrappers that route through `json_safe_u64` and the encrypted-note
174        // helper so large u64s stringify in JSON HR and Vec<u8> inside the
175        // tuple becomes base64.
176        struct SafeU64<'a>(&'a u64);
177        impl<'a> serde::Serialize for SafeU64<'a> {
178            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
179                crate::serialization::json::safe_integer::json_safe_u64::serialize(self.0, s)
180            }
181        }
182        struct SafeOptEncNote<'a>(&'a Option<(u32, u32, Vec<u8>)>);
183        impl<'a> serde::Serialize for SafeOptEncNote<'a> {
184            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
185                crate::serialization::json::safe_integer::json_safe_option_encrypted_note::serialize(
186                    self.0, s,
187                )
188            }
189        }
190
191        match self {
192            TokenEvent::Mint(amount, recipient, note) => {
193                let mut m = serializer.serialize_map(Some(4))?;
194                m.serialize_entry("$type", "mint")?;
195                m.serialize_entry("amount", &SafeU64(amount))?;
196                m.serialize_entry("recipient", recipient)?;
197                m.serialize_entry("publicNote", note)?;
198                m.end()
199            }
200            TokenEvent::Burn(amount, from, note) => {
201                let mut m = serializer.serialize_map(Some(4))?;
202                m.serialize_entry("$type", "burn")?;
203                m.serialize_entry("amount", &SafeU64(amount))?;
204                m.serialize_entry("burnFromIdentifier", from)?;
205                m.serialize_entry("publicNote", note)?;
206                m.end()
207            }
208            TokenEvent::Freeze(frozen, note) => {
209                let mut m = serializer.serialize_map(Some(3))?;
210                m.serialize_entry("$type", "freeze")?;
211                m.serialize_entry("frozenIdentifier", frozen)?;
212                m.serialize_entry("publicNote", note)?;
213                m.end()
214            }
215            TokenEvent::Unfreeze(frozen, note) => {
216                let mut m = serializer.serialize_map(Some(3))?;
217                m.serialize_entry("$type", "unfreeze")?;
218                m.serialize_entry("frozenIdentifier", frozen)?;
219                m.serialize_entry("publicNote", note)?;
220                m.end()
221            }
222            TokenEvent::DestroyFrozenFunds(frozen, amount, note) => {
223                let mut m = serializer.serialize_map(Some(4))?;
224                m.serialize_entry("$type", "destroyFrozenFunds")?;
225                m.serialize_entry("frozenIdentifier", frozen)?;
226                m.serialize_entry("amount", &SafeU64(amount))?;
227                m.serialize_entry("publicNote", note)?;
228                m.end()
229            }
230            TokenEvent::Transfer(recipient, note, shared, private, amount) => {
231                let mut m = serializer.serialize_map(Some(6))?;
232                m.serialize_entry("$type", "transfer")?;
233                m.serialize_entry("recipient", recipient)?;
234                m.serialize_entry("publicNote", note)?;
235                m.serialize_entry("sharedEncryptedNote", &SafeOptEncNote(shared))?;
236                m.serialize_entry("privateEncryptedNote", &SafeOptEncNote(private))?;
237                m.serialize_entry("amount", &SafeU64(amount))?;
238                m.end()
239            }
240            TokenEvent::Claim(distribution_type, amount, note) => {
241                let mut m = serializer.serialize_map(Some(4))?;
242                m.serialize_entry("$type", "claim")?;
243                m.serialize_entry("distributionType", distribution_type)?;
244                m.serialize_entry("amount", &SafeU64(amount))?;
245                m.serialize_entry("publicNote", note)?;
246                m.end()
247            }
248            TokenEvent::EmergencyAction(action, note) => {
249                let mut m = serializer.serialize_map(Some(3))?;
250                m.serialize_entry("$type", "emergencyAction")?;
251                m.serialize_entry("action", action)?;
252                m.serialize_entry("publicNote", note)?;
253                m.end()
254            }
255            TokenEvent::ConfigUpdate(change, note) => {
256                let mut m = serializer.serialize_map(Some(3))?;
257                m.serialize_entry("$type", "configUpdate")?;
258                m.serialize_entry("configurationChange", change)?;
259                m.serialize_entry("publicNote", note)?;
260                m.end()
261            }
262            TokenEvent::ChangePriceForDirectPurchase(schedule, note) => {
263                let mut m = serializer.serialize_map(Some(3))?;
264                m.serialize_entry("$type", "changePriceForDirectPurchase")?;
265                m.serialize_entry("pricingSchedule", schedule)?;
266                m.serialize_entry("publicNote", note)?;
267                m.end()
268            }
269            TokenEvent::DirectPurchase(amount, credits) => {
270                let mut m = serializer.serialize_map(Some(3))?;
271                m.serialize_entry("$type", "directPurchase")?;
272                m.serialize_entry("amount", &SafeU64(amount))?;
273                m.serialize_entry("credits", &SafeU64(credits))?;
274                m.end()
275            }
276        }
277    }
278}
279
280#[cfg(feature = "serde-conversion")]
281impl<'de> serde::Deserialize<'de> for TokenEvent {
282    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
283        use serde::de::{Error, IgnoredAny, MapAccess, Visitor};
284
285        // Newtype wrappers that route u64 / encrypted-note deserialization
286        // through the json_safe helpers (accept both numeric and string forms
287        // for u64; accept either tuple-with-base64 or tuple-with-bytes).
288        #[derive(serde::Deserialize)]
289        #[serde(transparent)]
290        struct U64Safe(
291            #[serde(with = "crate::serialization::json::safe_integer::json_safe_u64")] u64,
292        );
293        #[derive(serde::Deserialize)]
294        #[serde(transparent)]
295        struct OptEncNote(
296            #[serde(
297                with = "crate::serialization::json::safe_integer::json_safe_option_encrypted_note"
298            )]
299            Option<(u32, u32, Vec<u8>)>,
300        );
301
302        struct V;
303
304        impl<'de> Visitor<'de> for V {
305            type Value = TokenEvent;
306
307            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
308                f.write_str("TokenEvent as a map with `$type` discriminator + variant fields")
309            }
310
311            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<TokenEvent, A::Error> {
312                let mut ty: Option<String> = None;
313                let mut amount: Option<u64> = None;
314                let mut credits: Option<u64> = None;
315                let mut recipient: Option<Identifier> = None;
316                let mut burn_from: Option<Identifier> = None;
317                let mut frozen: Option<Identifier> = None;
318                let mut public_note: Option<String> = None;
319                let mut shared_note: Option<(u32, u32, Vec<u8>)> = None;
320                let mut private_note: Option<(u32, u32, Vec<u8>)> = None;
321                let mut distribution_type: Option<TokenDistributionTypeWithResolvedRecipient> =
322                    None;
323                let mut action: Option<TokenEmergencyAction> = None;
324                let mut configuration_change: Option<TokenConfigurationChangeItem> = None;
325                let mut pricing_schedule: Option<TokenPricingSchedule> = None;
326
327                while let Some(key) = map.next_key::<String>()? {
328                    match key.as_str() {
329                        "$type" => ty = Some(map.next_value()?),
330                        "amount" => amount = Some(map.next_value::<U64Safe>()?.0),
331                        "credits" => credits = Some(map.next_value::<U64Safe>()?.0),
332                        "recipient" => recipient = Some(map.next_value()?),
333                        "burnFromIdentifier" => burn_from = Some(map.next_value()?),
334                        "frozenIdentifier" => frozen = Some(map.next_value()?),
335                        "publicNote" => public_note = map.next_value()?,
336                        "sharedEncryptedNote" => {
337                            shared_note = map.next_value::<OptEncNote>()?.0;
338                        }
339                        "privateEncryptedNote" => {
340                            private_note = map.next_value::<OptEncNote>()?.0;
341                        }
342                        "distributionType" => distribution_type = Some(map.next_value()?),
343                        "action" => action = Some(map.next_value()?),
344                        "configurationChange" => configuration_change = Some(map.next_value()?),
345                        "pricingSchedule" => pricing_schedule = map.next_value()?,
346                        _ => {
347                            let _: IgnoredAny = map.next_value()?;
348                        }
349                    }
350                }
351
352                let ty = ty.ok_or_else(|| A::Error::missing_field("$type"))?;
353                match ty.as_str() {
354                    "mint" => Ok(TokenEvent::Mint(
355                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
356                        recipient.ok_or_else(|| A::Error::missing_field("recipient"))?,
357                        public_note,
358                    )),
359                    "burn" => Ok(TokenEvent::Burn(
360                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
361                        burn_from.ok_or_else(|| A::Error::missing_field("burnFromIdentifier"))?,
362                        public_note,
363                    )),
364                    "freeze" => Ok(TokenEvent::Freeze(
365                        frozen.ok_or_else(|| A::Error::missing_field("frozenIdentifier"))?,
366                        public_note,
367                    )),
368                    "unfreeze" => Ok(TokenEvent::Unfreeze(
369                        frozen.ok_or_else(|| A::Error::missing_field("frozenIdentifier"))?,
370                        public_note,
371                    )),
372                    "destroyFrozenFunds" => Ok(TokenEvent::DestroyFrozenFunds(
373                        frozen.ok_or_else(|| A::Error::missing_field("frozenIdentifier"))?,
374                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
375                        public_note,
376                    )),
377                    "transfer" => Ok(TokenEvent::Transfer(
378                        recipient.ok_or_else(|| A::Error::missing_field("recipient"))?,
379                        public_note,
380                        shared_note,
381                        private_note,
382                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
383                    )),
384                    "claim" => Ok(TokenEvent::Claim(
385                        distribution_type
386                            .ok_or_else(|| A::Error::missing_field("distributionType"))?,
387                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
388                        public_note,
389                    )),
390                    "emergencyAction" => Ok(TokenEvent::EmergencyAction(
391                        action.ok_or_else(|| A::Error::missing_field("action"))?,
392                        public_note,
393                    )),
394                    "configUpdate" => Ok(TokenEvent::ConfigUpdate(
395                        configuration_change
396                            .ok_or_else(|| A::Error::missing_field("configurationChange"))?,
397                        public_note,
398                    )),
399                    "changePriceForDirectPurchase" => Ok(TokenEvent::ChangePriceForDirectPurchase(
400                        pricing_schedule,
401                        public_note,
402                    )),
403                    "directPurchase" => Ok(TokenEvent::DirectPurchase(
404                        amount.ok_or_else(|| A::Error::missing_field("amount"))?,
405                        credits.ok_or_else(|| A::Error::missing_field("credits"))?,
406                    )),
407                    other => Err(A::Error::unknown_variant(
408                        other,
409                        &[
410                            "mint",
411                            "burn",
412                            "freeze",
413                            "unfreeze",
414                            "destroyFrozenFunds",
415                            "transfer",
416                            "claim",
417                            "emergencyAction",
418                            "configUpdate",
419                            "changePriceForDirectPurchase",
420                            "directPurchase",
421                        ],
422                    )),
423                }
424            }
425        }
426
427        deserializer.deserialize_map(V)
428    }
429}
430
431#[cfg(all(
432    test,
433    feature = "json-conversion",
434    feature = "value-conversion",
435    feature = "serde-conversion"
436))]
437pub(crate) mod json_convertible_tests {
438    use super::*;
439    use platform_value::platform_value;
440    use serde_json::json;
441
442    // `TokenEvent` has a custom `Serialize` / `Deserialize` impl emitting an
443    // internally-tagged flat shape: each variant maps positional tuple fields
444    // to named JSON keys (`amount` / `recipient` / `publicNote` / etc.).
445    // Round-trip covers a representative sample: `Mint` (3-tuple), `Freeze`
446    // (2-tuple including null note), `DirectPurchase` (2-tuple of u64 aliases).
447
448    pub(crate) fn mint_fixture() -> TokenEvent {
449        TokenEvent::Mint(
450            5_000,
451            Identifier::new([0xa1; 32]),
452            Some("genesis mint".to_string()),
453        )
454    }
455
456    #[test]
457    fn json_round_trip_mint() {
458        use crate::serialization::JsonConvertible;
459        let original = mint_fixture();
460        let json = original.to_json().expect("to_json");
461        // `TokenAmount` (u64) → `json_safe_u64` (number for small values,
462        // string above MAX_SAFE_INTEGER). `Identifier` → base58 string in HR.
463        assert_eq!(
464            json,
465            json!({
466                "$type": "mint",
467                "amount": 5_000,
468                "recipient": "Bswb3UyeD1pUTaGiE6WvqwFpJZsQSEY1xhJePCDTHdvp",
469                "publicNote": "genesis mint",
470            })
471        );
472        let recovered = TokenEvent::from_json(json).expect("from_json");
473        assert_eq!(original, recovered);
474    }
475
476    #[test]
477    fn json_round_trip_freeze_no_note() {
478        use crate::serialization::JsonConvertible;
479        let original = TokenEvent::Freeze(Identifier::new([0xb2; 32]), None);
480        let json = original.to_json().expect("to_json");
481        assert_eq!(
482            json,
483            json!({
484                "$type": "freeze",
485                "frozenIdentifier": "D2ZcUbtpG5sKq7XLeB4YnpNnTGSptKCxTddoNeydzJQq",
486                "publicNote": null,
487            })
488        );
489        let recovered = TokenEvent::from_json(json).expect("from_json");
490        assert_eq!(original, recovered);
491    }
492
493    #[test]
494    fn json_round_trip_direct_purchase() {
495        use crate::serialization::JsonConvertible;
496        let original = TokenEvent::DirectPurchase(100, 5_000);
497        let json = original.to_json().expect("to_json");
498        assert_eq!(
499            json,
500            json!({
501                "$type": "directPurchase",
502                "amount": 100,
503                "credits": 5_000,
504            })
505        );
506        let recovered = TokenEvent::from_json(json).expect("from_json");
507        assert_eq!(original, recovered);
508    }
509
510    #[test]
511    fn value_round_trip_mint() {
512        use crate::serialization::ValueConvertible;
513        let original = mint_fixture();
514        let value = original.to_object().expect("to_object");
515        // `TokenAmount` is `u64` → `Value::U64`. Identifier → `Value::Identifier`.
516        assert_eq!(
517            value,
518            platform_value!({
519                "$type": "mint",
520                "amount": 5_000u64,
521                "recipient": Identifier::new([0xa1; 32]),
522                "publicNote": "genesis mint",
523            })
524        );
525        let recovered = TokenEvent::from_object(value).expect("from_object");
526        assert_eq!(original, recovered);
527    }
528
529    #[test]
530    fn value_round_trip_freeze_no_note() {
531        use crate::serialization::ValueConvertible;
532        let original = TokenEvent::Freeze(Identifier::new([0xb2; 32]), None);
533        let value = original.to_object().expect("to_object");
534        assert_eq!(
535            value,
536            platform_value!({
537                "$type": "freeze",
538                "frozenIdentifier": Identifier::new([0xb2; 32]),
539                "publicNote": null,
540            })
541        );
542        let recovered = TokenEvent::from_object(value).expect("from_object");
543        assert_eq!(original, recovered);
544    }
545
546    #[test]
547    fn value_round_trip_direct_purchase() {
548        use crate::serialization::ValueConvertible;
549        let original = TokenEvent::DirectPurchase(100, 5_000);
550        let value = original.to_object().expect("to_object");
551        // `TokenAmount` and `Credits` are both `u64`.
552        assert_eq!(
553            value,
554            platform_value!({
555                "$type": "directPurchase",
556                "amount": 100u64,
557                "credits": 5_000u64,
558            })
559        );
560        let recovered = TokenEvent::from_object(value).expect("from_object");
561        assert_eq!(original, recovered);
562    }
563}
564
565impl fmt::Display for TokenEvent {
566    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
567        match self {
568            TokenEvent::Mint(amount, recipient, note) => {
569                write!(f, "Mint {} to {}{}", amount, recipient, format_note(note))
570            }
571            TokenEvent::Burn(amount, burn_from_identifier, note) => {
572                write!(
573                    f,
574                    "Burn {} from {}{}",
575                    amount,
576                    burn_from_identifier,
577                    format_note(note)
578                )
579            }
580            TokenEvent::Freeze(identity, note) => {
581                write!(f, "Freeze {}{}", identity, format_note(note))
582            }
583            TokenEvent::Unfreeze(identity, note) => {
584                write!(f, "Unfreeze {}{}", identity, format_note(note))
585            }
586            TokenEvent::DestroyFrozenFunds(identity, amount, note) => {
587                write!(
588                    f,
589                    "Destroy {} frozen from {}{}",
590                    amount,
591                    identity,
592                    format_note(note)
593                )
594            }
595            TokenEvent::Transfer(to, note, _, _, amount) => {
596                write!(f, "Transfer {} to {}{}", amount, to, format_note(note))
597            }
598            TokenEvent::Claim(recipient, amount, note) => {
599                write!(
600                    f,
601                    "Claim {} by {:?}{}",
602                    amount,
603                    recipient,
604                    format_note(note)
605                )
606            }
607            TokenEvent::EmergencyAction(action, note) => {
608                write!(f, "Emergency action {:?}{}", action, format_note(note))
609            }
610            TokenEvent::ConfigUpdate(change, note) => {
611                write!(f, "Configuration update {:?}{}", change, format_note(note))
612            }
613            TokenEvent::ChangePriceForDirectPurchase(schedule, note) => match schedule {
614                Some(s) => write!(f, "Change price schedule to {:?}{}", s, format_note(note)),
615                None => write!(f, "Disable direct purchase{}", format_note(note)),
616            },
617            TokenEvent::DirectPurchase(amount, credits) => {
618                write!(f, "Direct purchase of {} for {} credits", amount, credits)
619            }
620        }
621    }
622}
623
624fn format_note(note: &Option<String>) -> String {
625    match note {
626        Some(n) => format!(" (note: {})", n),
627        None => String::new(),
628    }
629}
630
631impl TokenEvent {
632    pub fn associated_document_type_name(&self) -> &str {
633        match self {
634            TokenEvent::Mint(..) => "mint",
635            TokenEvent::Burn(..) => "burn",
636            TokenEvent::Freeze(..) => "freeze",
637            TokenEvent::Unfreeze(..) => "unfreeze",
638            TokenEvent::DestroyFrozenFunds(..) => "destroyFrozenFunds",
639            TokenEvent::Transfer(..) => "transfer",
640            TokenEvent::Claim(..) => "claim",
641            TokenEvent::EmergencyAction(..) => "emergencyAction",
642            TokenEvent::ConfigUpdate(..) => "configUpdate",
643            TokenEvent::DirectPurchase(..) => "directPurchase",
644            TokenEvent::ChangePriceForDirectPurchase(..) => "directPricing",
645        }
646    }
647
648    /// Returns a reference to the public note if the variant includes one.
649    pub fn public_note(&self) -> Option<&str> {
650        match self {
651            TokenEvent::Mint(_, _, Some(note))
652            | TokenEvent::Burn(_, _, Some(note))
653            | TokenEvent::Freeze(_, Some(note))
654            | TokenEvent::Unfreeze(_, Some(note))
655            | TokenEvent::DestroyFrozenFunds(_, _, Some(note))
656            | TokenEvent::Transfer(_, Some(note), _, _, _)
657            | TokenEvent::Claim(_, _, Some(note))
658            | TokenEvent::EmergencyAction(_, Some(note))
659            | TokenEvent::ConfigUpdate(_, Some(note))
660            | TokenEvent::ChangePriceForDirectPurchase(_, Some(note)) => Some(note),
661            _ => None,
662        }
663    }
664
665    pub fn associated_document_type<'a>(
666        &self,
667        token_history_contract: &'a DataContract,
668    ) -> Result<DocumentTypeRef<'a>, ProtocolError> {
669        Ok(token_history_contract.document_type_for_name(self.associated_document_type_name())?)
670    }
671
672    pub fn build_historical_document_owned(
673        self,
674        token_id: Identifier,
675        owner_id: Identifier,
676        owner_nonce: IdentityNonce,
677        block_info: &BlockInfo,
678        platform_version: &PlatformVersion,
679    ) -> Result<Document, ProtocolError> {
680        let document_id = Document::generate_document_id_v0(
681            &token_id,
682            &owner_id,
683            format!("history_{}", self.associated_document_type_name()).as_str(),
684            owner_nonce.to_be_bytes().as_slice(),
685        );
686
687        let properties = match self {
688            TokenEvent::Mint(mint_amount, recipient_id, public_note) => {
689                let mut properties = BTreeMap::from([
690                    ("tokenId".to_string(), token_id.into()),
691                    ("recipientId".to_string(), recipient_id.into()),
692                    ("amount".to_string(), mint_amount.into()),
693                ]);
694                if let Some(note) = public_note {
695                    properties.insert("note".to_string(), note.into());
696                }
697                properties
698            }
699            TokenEvent::Burn(burn_amount, burn_from_identifier, public_note) => {
700                let mut properties = BTreeMap::from([
701                    ("tokenId".to_string(), token_id.into()),
702                    ("burnFromId".to_string(), burn_from_identifier.into()),
703                    ("amount".to_string(), burn_amount.into()),
704                ]);
705                if let Some(note) = public_note {
706                    properties.insert("note".to_string(), note.into());
707                }
708                properties
709            }
710            TokenEvent::Transfer(
711                to,
712                public_note,
713                token_event_shared_encrypted_note,
714                token_event_personal_encrypted_note,
715                amount,
716            ) => {
717                let mut properties = BTreeMap::from([
718                    ("tokenId".to_string(), token_id.into()),
719                    ("amount".to_string(), amount.into()),
720                    ("toIdentityId".to_string(), to.into()),
721                ]);
722                if let Some(note) = public_note {
723                    properties.insert("publicNote".to_string(), note.into());
724                }
725                if let Some((sender_key_index, recipient_key_index, note)) =
726                    token_event_shared_encrypted_note
727                {
728                    properties.insert("encryptedSharedNote".to_string(), note.into());
729                    properties.insert("senderKeyIndex".to_string(), sender_key_index.into());
730                    properties.insert("recipientKeyIndex".to_string(), recipient_key_index.into());
731                }
732
733                if let Some((root_encryption_key_index, derivation_encryption_key_index, note)) =
734                    token_event_personal_encrypted_note
735                {
736                    properties.insert("encryptedPersonalNote".to_string(), note.into());
737                    properties.insert(
738                        "rootEncryptionKeyIndex".to_string(),
739                        root_encryption_key_index.into(),
740                    );
741                    properties.insert(
742                        "derivationEncryptionKeyIndex".to_string(),
743                        derivation_encryption_key_index.into(),
744                    );
745                }
746                properties
747            }
748            TokenEvent::Freeze(frozen_identity_id, public_note) => {
749                let mut properties = BTreeMap::from([
750                    ("tokenId".to_string(), token_id.into()),
751                    ("frozenIdentityId".to_string(), frozen_identity_id.into()),
752                ]);
753                if let Some(note) = public_note {
754                    properties.insert("note".to_string(), note.into());
755                }
756                properties
757            }
758            TokenEvent::Unfreeze(frozen_identity_id, public_note) => {
759                let mut properties = BTreeMap::from([
760                    ("tokenId".to_string(), token_id.into()),
761                    ("frozenIdentityId".to_string(), frozen_identity_id.into()),
762                ]);
763                if let Some(note) = public_note {
764                    properties.insert("note".to_string(), note.into());
765                }
766                properties
767            }
768            TokenEvent::DestroyFrozenFunds(frozen_identity_id, amount, public_note) => {
769                let mut properties = BTreeMap::from([
770                    ("tokenId".to_string(), token_id.into()),
771                    ("frozenIdentityId".to_string(), frozen_identity_id.into()),
772                    ("destroyedAmount".to_string(), amount.into()),
773                ]);
774                if let Some(note) = public_note {
775                    properties.insert("note".to_string(), note.into());
776                }
777                properties
778            }
779            TokenEvent::EmergencyAction(action, public_note) => {
780                let mut properties = BTreeMap::from([
781                    ("tokenId".to_string(), token_id.into()),
782                    ("action".to_string(), (action as u8).into()),
783                ]);
784                if let Some(note) = public_note {
785                    properties.insert("note".to_string(), note.into());
786                }
787                properties
788            }
789            TokenEvent::ConfigUpdate(configuration_change_item, public_note) => {
790                let mut properties = BTreeMap::from([
791                    ("tokenId".to_string(), token_id.into()),
792                    (
793                        "changeItemType".to_string(),
794                        configuration_change_item.u8_item_index().into(),
795                    ),
796                    (
797                        "changeItem".to_string(),
798                        configuration_change_item
799                            .serialize_consume_to_bytes_with_platform_version(platform_version)?
800                            .into(),
801                    ),
802                ]);
803                if let Some(note) = public_note {
804                    properties.insert("note".to_string(), note.into());
805                }
806                properties
807            }
808            TokenEvent::Claim(recipient, amount, public_note) => {
809                let (recipient_type, recipient_id, distribution_type) = match recipient {
810                    TokenDistributionTypeWithResolvedRecipient::PreProgrammed(identifier) => {
811                        (1u8, identifier, 0u8)
812                    }
813                    TokenDistributionTypeWithResolvedRecipient::Perpetual(
814                        TokenDistributionResolvedRecipient::ContractOwnerIdentity(identifier),
815                    ) => (0, identifier, 1),
816                    TokenDistributionTypeWithResolvedRecipient::Perpetual(
817                        TokenDistributionResolvedRecipient::Identity(identifier),
818                    ) => (1, identifier, 1),
819                    TokenDistributionTypeWithResolvedRecipient::Perpetual(
820                        TokenDistributionResolvedRecipient::Evonode(identifier),
821                    ) => (2, identifier, 1),
822                };
823
824                let mut properties = BTreeMap::from([
825                    ("tokenId".to_string(), token_id.into()),
826                    ("recipientType".to_string(), recipient_type.into()),
827                    ("recipientId".to_string(), recipient_id.into()),
828                    ("distributionType".to_string(), distribution_type.into()),
829                    ("amount".to_string(), amount.into()),
830                ]);
831
832                if let Some(note) = public_note {
833                    properties.insert("note".to_string(), note.into());
834                }
835                properties
836            }
837            TokenEvent::ChangePriceForDirectPurchase(price, note) => {
838                let mut properties = BTreeMap::from([("tokenId".to_string(), token_id.into())]);
839
840                if let Some(price_schedule) = price {
841                    properties.insert(
842                        "priceSchedule".to_string(),
843                        price_schedule
844                            .serialize_consume_to_bytes_with_platform_version(platform_version)?
845                            .into(),
846                    );
847                }
848
849                if let Some(note) = note {
850                    properties.insert("note".to_string(), note.into());
851                }
852
853                properties
854            }
855            TokenEvent::DirectPurchase(amount, total_cost) => BTreeMap::from([
856                ("tokenId".to_string(), token_id.into()),
857                ("tokenAmount".to_string(), amount.into()),
858                ("purchaseCost".to_string(), total_cost.into()),
859            ]),
860        };
861
862        let document: Document = DocumentV0 {
863            id: document_id,
864            owner_id,
865            properties,
866            revision: None,
867            created_at: Some(block_info.time_ms),
868            updated_at: None,
869            transferred_at: None,
870            created_at_block_height: Some(block_info.height),
871            updated_at_block_height: None,
872            transferred_at_block_height: None,
873            created_at_core_block_height: None,
874            updated_at_core_block_height: None,
875            transferred_at_core_block_height: None,
876            creator_id: None,
877        }
878        .into();
879
880        Ok(document)
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    fn test_id() -> Identifier {
889        Identifier::from([1u8; 32])
890    }
891
892    fn test_id_2() -> Identifier {
893        Identifier::from([2u8; 32])
894    }
895
896    // ---- associated_document_type_name tests ----
897
898    #[test]
899    fn associated_name_mint() {
900        let event = TokenEvent::Mint(0, test_id(), None);
901        assert_eq!(event.associated_document_type_name(), "mint");
902    }
903
904    #[test]
905    fn associated_name_burn() {
906        let event = TokenEvent::Burn(0, test_id(), None);
907        assert_eq!(event.associated_document_type_name(), "burn");
908    }
909
910    #[test]
911    fn associated_name_freeze() {
912        let event = TokenEvent::Freeze(test_id(), None);
913        assert_eq!(event.associated_document_type_name(), "freeze");
914    }
915
916    #[test]
917    fn associated_name_unfreeze() {
918        let event = TokenEvent::Unfreeze(test_id(), None);
919        assert_eq!(event.associated_document_type_name(), "unfreeze");
920    }
921
922    #[test]
923    fn associated_name_destroy_frozen_funds() {
924        let event = TokenEvent::DestroyFrozenFunds(test_id(), 0, None);
925        assert_eq!(event.associated_document_type_name(), "destroyFrozenFunds");
926    }
927
928    #[test]
929    fn associated_name_transfer() {
930        let event = TokenEvent::Transfer(test_id(), None, None, None, 0);
931        assert_eq!(event.associated_document_type_name(), "transfer");
932    }
933
934    #[test]
935    fn associated_name_claim() {
936        let recipient = TokenDistributionTypeWithResolvedRecipient::PreProgrammed(test_id());
937        let event = TokenEvent::Claim(recipient, 0, None);
938        assert_eq!(event.associated_document_type_name(), "claim");
939    }
940
941    #[test]
942    fn associated_name_emergency_action() {
943        let event = TokenEvent::EmergencyAction(TokenEmergencyAction::Pause, None);
944        assert_eq!(event.associated_document_type_name(), "emergencyAction");
945    }
946
947    #[test]
948    fn associated_name_config_update() {
949        let event = TokenEvent::ConfigUpdate(
950            TokenConfigurationChangeItem::TokenConfigurationNoChange,
951            None,
952        );
953        assert_eq!(event.associated_document_type_name(), "configUpdate");
954    }
955
956    #[test]
957    fn associated_name_direct_purchase() {
958        let event = TokenEvent::DirectPurchase(0, 0);
959        assert_eq!(event.associated_document_type_name(), "directPurchase");
960    }
961
962    #[test]
963    fn associated_name_change_price() {
964        let event = TokenEvent::ChangePriceForDirectPurchase(None, None);
965        assert_eq!(event.associated_document_type_name(), "directPricing");
966    }
967
968    // ---- all associated_document_type_name values are distinct ----
969
970    #[test]
971    fn all_document_type_names_are_unique() {
972        let recipient = TokenDistributionTypeWithResolvedRecipient::PreProgrammed(test_id());
973        let events: Vec<TokenEvent> = vec![
974            TokenEvent::Mint(0, test_id(), None),
975            TokenEvent::Burn(0, test_id(), None),
976            TokenEvent::Freeze(test_id(), None),
977            TokenEvent::Unfreeze(test_id(), None),
978            TokenEvent::DestroyFrozenFunds(test_id(), 0, None),
979            TokenEvent::Transfer(test_id(), None, None, None, 0),
980            TokenEvent::Claim(recipient, 0, None),
981            TokenEvent::EmergencyAction(TokenEmergencyAction::Pause, None),
982            TokenEvent::ConfigUpdate(
983                TokenConfigurationChangeItem::TokenConfigurationNoChange,
984                None,
985            ),
986            TokenEvent::DirectPurchase(0, 0),
987            TokenEvent::ChangePriceForDirectPurchase(None, None),
988        ];
989        let names: Vec<&str> = events
990            .iter()
991            .map(|e| e.associated_document_type_name())
992            .collect();
993        let mut unique = names.clone();
994        unique.sort();
995        unique.dedup();
996        assert_eq!(
997            names.len(),
998            unique.len(),
999            "Duplicate document type names found"
1000        );
1001    }
1002
1003    // ---- format_note helper ----
1004
1005    #[test]
1006    fn format_note_none_returns_empty() {
1007        assert_eq!(format_note(&None), "");
1008    }
1009
1010    #[test]
1011    fn format_note_some_returns_formatted() {
1012        assert_eq!(format_note(&Some("hello".to_string())), " (note: hello)");
1013    }
1014}