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