Skip to main content

dpp/state_transition/
proof_result.rs

1use crate::address_funds::PlatformAddress;
2use crate::asset_lock::StoredAssetLockInfo;
3use crate::balances::credits::TokenAmount;
4use crate::data_contract::config::moderation::{
5    ContractDocumentRemoval, ContractModerationListStatuses,
6};
7use crate::data_contract::document_type::action_fees::{ContractFeePot, ContractFeePotLastClaim};
8use crate::data_contract::group::GroupSumPower;
9use crate::data_contract::DataContract;
10use crate::document::Document;
11use crate::fee::Credits;
12use crate::group::group_action_status::GroupActionStatus;
13use crate::identity::{Identity, PartialIdentity};
14use crate::prelude::AddressNonce;
15#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
16use crate::serialization::JsonConvertible;
17#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
18use crate::serialization::ValueConvertible;
19use crate::tokens::info::IdentityTokenInfo;
20use crate::tokens::status::TokenStatus;
21use crate::tokens::token_pricing_schedule::TokenPricingSchedule;
22use crate::voting::votes::Vote;
23use platform_value::Identifier;
24use std::collections::BTreeMap;
25
26#[derive(Debug, PartialEq, strum::Display, derive_more::TryInto)]
27#[cfg_attr(
28    feature = "serde-conversion",
29    derive(serde::Serialize, serde::Deserialize)
30)]
31pub enum StateTransitionProofResult {
32    VerifiedDataContract(DataContract),
33    VerifiedIdentity(Identity),
34    VerifiedTokenBalanceAbsence(Identifier),
35    // `TokenAmount`/`Credits` (u64) live in tuple variants / nested containers
36    // that `#[json_safe_fields]` can't reach; apply the JS-safe helpers directly
37    // so values above `MAX_SAFE_INTEGER` serialize as strings in human-readable
38    // JSON. (`AddressNonce`/`GroupSumPower` are `u32` → already JS-safe; the
39    // `Vec<u8>` nullifiers are arrays of bytes < 256 → no precision concern.)
40    VerifiedTokenBalance(
41        Identifier,
42        #[cfg_attr(
43            feature = "json-conversion",
44            serde(with = "crate::serialization::json_safe_u64")
45        )]
46        TokenAmount,
47    ),
48    VerifiedTokenIdentityInfo(Identifier, IdentityTokenInfo),
49    VerifiedTokenPricingSchedule(Identifier, Option<TokenPricingSchedule>),
50    VerifiedTokenStatus(TokenStatus),
51    VerifiedTokenIdentitiesBalances(
52        #[cfg_attr(
53            feature = "json-conversion",
54            serde(
55                with = "crate::serialization::json::safe_integer_map::json_safe_identifier_u64_map"
56            )
57        )]
58        BTreeMap<Identifier, TokenAmount>,
59    ),
60    VerifiedPartialIdentity(PartialIdentity),
61    VerifiedBalanceTransfer(PartialIdentity, PartialIdentity), //from/to
62    VerifiedDocuments(BTreeMap<Identifier, Option<Document>>),
63    VerifiedTokenActionWithDocument(Document),
64    VerifiedTokenGroupActionWithDocument(GroupSumPower, Option<Document>),
65    VerifiedTokenGroupActionWithTokenBalance(
66        GroupSumPower,
67        GroupActionStatus,
68        #[cfg_attr(
69            feature = "json-conversion",
70            serde(with = "crate::serialization::json_safe_option_u64")
71        )]
72        Option<TokenAmount>,
73    ),
74    VerifiedTokenGroupActionWithTokenIdentityInfo(
75        GroupSumPower,
76        GroupActionStatus,
77        Option<IdentityTokenInfo>,
78    ),
79    VerifiedTokenGroupActionWithTokenPricingSchedule(
80        GroupSumPower,
81        GroupActionStatus,
82        Option<TokenPricingSchedule>,
83    ),
84    VerifiedMasternodeVote(Vote),
85    VerifiedNextDistribution(Vote),
86    VerifiedAddressInfos(
87        #[cfg_attr(
88            feature = "json-conversion",
89            serde(with = "json_safe_address_info_map")
90        )]
91        BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>,
92    ),
93    VerifiedIdentityFullWithAddressInfos(
94        Identity,
95        #[cfg_attr(
96            feature = "json-conversion",
97            serde(with = "json_safe_address_info_map")
98        )]
99        BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>,
100    ),
101    VerifiedIdentityWithAddressInfos(
102        PartialIdentity,
103        #[cfg_attr(
104            feature = "json-conversion",
105            serde(with = "json_safe_address_info_map")
106        )]
107        BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>,
108    ),
109    VerifiedAssetLockConsumed(StoredAssetLockInfo),
110    VerifiedShieldedNullifiers(Vec<(Vec<u8>, bool)>),
111    /// The proven total balance of a token's shielded pool (token id, balance). Returned by the
112    /// pool transitions that only create notes (mint, claim and purchase into the pool).
113    // Kept out of the `TryInto` derive: this shares a field-type list with a variant that
114    // predates it, and `derive_more` writes one `TryFrom` per distinct list. Including it
115    // would make that conversion accept either variant, so a caller using the tuple type to
116    // tell them apart would silently stop doing so. Match on the variant instead.
117    #[try_into(ignore)]
118    VerifiedTokenShieldedPoolBalance(
119        Identifier,
120        #[cfg_attr(
121            feature = "json-conversion",
122            serde(with = "crate::serialization::json_safe_u64")
123        )]
124        TokenAmount,
125    ),
126    VerifiedShieldedNullifiersWithAddressInfos(
127        Vec<(Vec<u8>, bool)>,
128        #[cfg_attr(
129            feature = "json-conversion",
130            serde(with = "json_safe_address_info_map")
131        )]
132        BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>,
133    ),
134    VerifiedShieldedNullifiersWithWithdrawalDocument(
135        Vec<(Vec<u8>, bool)>,
136        BTreeMap<Identifier, Option<Document>>,
137    ),
138    /// Returned by `ShieldFromAssetLock` when a `surplus_output` is set. Carries the consumed
139    /// asset-lock info AND the proven balance of the surplus-output address, so a light/SDK
140    /// client can cryptographically confirm the asset-lock surplus credit landed at the signed
141    /// `surplus_output` address. The plain [`VerifiedAssetLockConsumed`] is still returned when
142    /// no `surplus_output` is set.
143    ///
144    /// [`VerifiedAssetLockConsumed`]: StateTransitionProofResult::VerifiedAssetLockConsumed
145    VerifiedAssetLockConsumedWithAddressInfos(
146        StoredAssetLockInfo,
147        BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>,
148    ),
149    /// Returned by `IdentityCreateFromShieldedPool`. Carries the newly-created [`Identity`] AND the
150    /// presence of each spent nullifier (`(nullifier_bytes, present)`), proven together in a single
151    /// STRICT merged multi-root GroveDB proof. A light/SDK client can cryptographically confirm both
152    /// that the identity was created and that the funding nullifiers were consumed.
153    VerifiedIdentityWithShieldedNullifiers(Identity, Vec<(Vec<u8>, bool)>),
154    /// Returned by `ContractUserModeration`: the target identity's status on the lists the
155    /// moderation touched (contract id, identity id, one status per list proved) after the
156    /// moderation. A ban proves both lists the contract keeps, since it also removes a
157    /// suspension; an unban, a suspend and an unsuspend prove the one list they edit, and say
158    /// nothing about the other.
159    VerifiedContractModerationListStatuses(Identifier, Identifier, ContractModerationListStatuses),
160    /// A contract fee claim's execution proof shows the pot it paid out (contract id, pot, the
161    /// pot's last claim, the credits left in it) and the balance of every identity it paid,
162    /// after the claim. A pot is paid out at most once per epoch, so within its epoch the last
163    /// claim is this claim, and its claimant and block time say so.
164    VerifiedContractFeeClaim(
165        Identifier,
166        ContractFeePot,
167        ContractFeePotLastClaim,
168        Credits,
169        #[cfg_attr(
170            feature = "json-conversion",
171            serde(
172                with = "crate::serialization::json::safe_integer_map::json_safe_identifier_u64_map"
173            )
174        )]
175        BTreeMap<Identifier, Credits>,
176    ),
177    /// Returned by a `ContractUserModeration` that deletes a document: the record the removal
178    /// left under the contract (contract id, document type name, document id, record). The
179    /// proof shows the record, and the verifier checks that it names the transition's signer
180    /// and carries the transition's reason. A document id is produced at most once, so the
181    /// record is of this document and of no other.
182    VerifiedContractDocumentRemoval(Identifier, String, Identifier, ContractDocumentRemoval),
183    /// Returned by a `MintToPool` submitted as a group action: the signer's recorded power, the
184    /// action's status, and the token shielded pool's total balance. A group action writes into
185    /// the pool only once the last required signature arrives, so while the action is active the
186    /// balance is the one the pool already held, and is absent for a pool that has never held a
187    /// note. A closed action has minted, so its balance is present.
188    // Kept out of the `TryInto` derive: this shares a field-type list with a variant that
189    // predates it, and `derive_more` writes one `TryFrom` per distinct list. Including it
190    // would make that conversion accept either variant, so a caller using the tuple type to
191    // tell them apart would silently stop doing so. Match on the variant instead.
192    #[try_into(ignore)]
193    VerifiedTokenGroupActionWithShieldedPoolBalance(
194        GroupSumPower,
195        GroupActionStatus,
196        #[cfg_attr(
197            feature = "json-conversion",
198            serde(with = "crate::serialization::json_safe_option_u64")
199        )]
200        Option<TokenAmount>,
201    ),
202    /// Returned by a `BurnFromPool` submitted as a group action: the signer's recorded power, the
203    /// action's status, and the spend status of every nullifier the burn names
204    /// (`(nullifier_bytes, spent)`). A group action spends nothing until the last required
205    /// signature arrives, so while the action is active every nullifier reads unspent; a closed
206    /// action has spent them all.
207    VerifiedTokenGroupActionWithShieldedNullifiers(
208        GroupSumPower,
209        GroupActionStatus,
210        Vec<(Vec<u8>, bool)>,
211    ),
212    /// Returned by a `ContractUserModeration` that proposes the deletion of a settled document
213    /// or approves a team action: the contract, the team action and whether it is still active
214    /// or closed. The proof shows the signer's approval among the action's, active or closed,
215    /// the action's id computed from the transition for a proposal. Closed means the approvals
216    /// met the rule and the action ran, by this approval or a later one: the document is
217    /// deleted. An approval stays where it is until its action closes, and then moves with it,
218    /// so the proof holds while the approval stands: one deleted because its member left the
219    /// team no longer proves.
220    VerifiedContractTeamActionSignature(Identifier, Identifier, GroupActionStatus),
221}
222
223/// The guarantee a verified state-transition proof establishes.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
225#[cfg_attr(
226    feature = "serde-conversion",
227    derive(serde::Serialize, serde::Deserialize)
228)]
229pub enum StateTransitionProofGuarantee {
230    /// The proof binds the execution of this specific state transition:
231    /// the verified values could only exist if the transition was applied.
232    ExecutionProved,
233    /// The proof authenticates a snapshot of the state the transition
234    /// affects — keys derived from the transition, values as of the proof's
235    /// block — but cannot bind them to the execution of this transition.
236    /// Treat as a height-pinned snapshot, not as evidence of execution.
237    AffectedState,
238}
239
240/// A verified state-transition proof: the result, the guarantee the proof
241/// establishes for it, and, when the proof carries it, the credit balance of
242/// the identity that owns the transition after it executed.
243///
244/// Some transition families (balance top-ups, credit transfers and
245/// withdrawals, address funds movements, shields, no-history token
246/// operations) produce proofs whose values cannot be bound to the execution
247/// of one specific transition: the proof only authenticates the affected
248/// keys' state at the committed block. The guarantee makes that distinction
249/// part of the type so a snapshot cannot be mistaken for execution evidence.
250///
251/// From protocol version 14 the proof of an owned, fee-paying transition
252/// (document and token batches, contract creates and updates, identity
253/// updates and key limit updates, contract moderation) also carries the
254/// owner's credit balance, read from the same state as the result. It is a
255/// snapshot at the proof's block whatever the guarantee, and `None` for a
256/// proof made at an earlier version or for a transition without an owner.
257#[derive(Debug, PartialEq)]
258#[cfg_attr(
259    feature = "serde-conversion",
260    derive(serde::Serialize, serde::Deserialize)
261)]
262pub struct StateTransitionProofOutcome {
263    guarantee: StateTransitionProofGuarantee,
264    result: StateTransitionProofResult,
265    #[cfg_attr(
266        feature = "json-conversion",
267        serde(with = "crate::serialization::json_safe_option_u64")
268    )]
269    owner_balance: Option<Credits>,
270}
271
272impl StateTransitionProofOutcome {
273    /// An outcome whose proof binds the execution of the transition.
274    pub fn execution_proved(result: StateTransitionProofResult) -> Self {
275        Self {
276            guarantee: StateTransitionProofGuarantee::ExecutionProved,
277            result,
278            owner_balance: None,
279        }
280    }
281
282    /// An outcome whose proof only authenticates the affected state.
283    pub fn affected_state(result: StateTransitionProofResult) -> Self {
284        Self {
285            guarantee: StateTransitionProofGuarantee::AffectedState,
286            result,
287            owner_balance: None,
288        }
289    }
290
291    /// The same outcome carrying the owner's credit balance the proof showed.
292    pub fn with_owner_balance(mut self, owner_balance: Option<Credits>) -> Self {
293        self.owner_balance = owner_balance;
294        self
295    }
296
297    /// The guarantee the proof establishes.
298    pub fn guarantee(&self) -> StateTransitionProofGuarantee {
299        self.guarantee
300    }
301
302    /// Whether the proof established that this specific transition executed.
303    pub fn is_execution_proved(&self) -> bool {
304        self.guarantee == StateTransitionProofGuarantee::ExecutionProved
305    }
306
307    /// The verified result, regardless of the guarantee.
308    pub fn result(&self) -> &StateTransitionProofResult {
309        &self.result
310    }
311
312    /// Consume the outcome, discarding the guarantee and the balance.
313    pub fn into_result(self) -> StateTransitionProofResult {
314        self.result
315    }
316
317    /// The credit balance of the transition's owner after it executed, when
318    /// the proof carried it: a snapshot at the proof's block.
319    pub fn owner_balance(&self) -> Option<Credits> {
320        self.owner_balance
321    }
322
323    /// Consume the outcome into its guarantee, result and owner balance.
324    pub fn into_parts(
325        self,
326    ) -> (
327        StateTransitionProofGuarantee,
328        StateTransitionProofResult,
329        Option<Credits>,
330    ) {
331        (self.guarantee, self.result, self.owner_balance)
332    }
333}
334
335/// Serde `with` module for `BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>`.
336///
337/// `AddressNonce` is `u32` (JS-safe); `Credits` is `u64` and must serialize as a
338/// string in human-readable JSON above `MAX_SAFE_INTEGER`. A small wrapper tuple
339/// carries `#[serde(with = "json_safe_u64")]` on the credits, preserving the
340/// `[nonce, credits]` array wire-shape while making the value JS-safe. Binary /
341/// `Value` paths stay native (the helper checks `is_human_readable`).
342#[cfg(feature = "json-conversion")]
343mod json_safe_address_info_map {
344    use super::{AddressNonce, Credits, PlatformAddress};
345    use serde::de::Deserializer;
346    use serde::ser::{SerializeMap, Serializer};
347    use serde::{Deserialize, Serialize};
348    use std::collections::BTreeMap;
349
350    /// The address-info map shape shared by several `StateTransitionProofResult`
351    /// variants. Aliased to keep the helper signatures below under clippy's
352    /// `type_complexity` threshold.
353    type AddressInfoMap = BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>>;
354
355    #[derive(Serialize, Deserialize)]
356    struct Entry(
357        AddressNonce,
358        #[serde(with = "crate::serialization::json_safe_u64")] Credits,
359    );
360
361    pub fn serialize<S: Serializer>(
362        map: &AddressInfoMap,
363        serializer: S,
364    ) -> Result<S::Ok, S::Error> {
365        let mut s = serializer.serialize_map(Some(map.len()))?;
366        for (k, v) in map {
367            let wrapped = v.map(|(nonce, credits)| Entry(nonce, credits));
368            s.serialize_entry(k, &wrapped)?;
369        }
370        s.end()
371    }
372
373    pub fn deserialize<'de, D: Deserializer<'de>>(
374        deserializer: D,
375    ) -> Result<AddressInfoMap, D::Error> {
376        let raw: BTreeMap<PlatformAddress, Option<Entry>> = BTreeMap::deserialize(deserializer)?;
377        Ok(raw
378            .into_iter()
379            .map(|(k, v)| (k, v.map(|Entry(nonce, credits)| (nonce, credits))))
380            .collect())
381    }
382}
383
384// --- canonical conversion trait impls (unification pass 1) ---
385#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
386impl JsonConvertible for StateTransitionProofResult {}
387
388#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
389impl ValueConvertible for StateTransitionProofResult {}
390
391#[cfg(all(
392    test,
393    feature = "json-conversion",
394    feature = "value-conversion",
395    feature = "serde-conversion"
396))]
397mod json_convertible_tests {
398    use super::*;
399    use platform_value::{Identifier, Value};
400    use serde_json::json;
401
402    /// Non-default variant `VerifiedTokenBalance(id, amount)` with both
403    /// tuple fields set so the wire-shape assertion catches silent variant
404    /// flip / inner-zero on round-trip.
405    fn fixture() -> StateTransitionProofResult {
406        StateTransitionProofResult::VerifiedTokenBalance(
407            Identifier::new([0xab; 32]),
408            123_456_789u64,
409        )
410    }
411
412    #[test]
413    fn json_round_trip_with_full_wire_shape() {
414        use crate::serialization::JsonConvertible;
415        let original = fixture();
416        let json = original.to_json().expect("to_json");
417        // `StateTransitionProofResult` uses serde external tagging (default,
418        // no `#[serde(tag = ...)]`). Tuple variants serialize as
419        // `{ "VariantName": [field0, field1, ...] }`. `Identifier` -> base58
420        // string in JSON; `TokenAmount` is `u64` and JSON erases the size —
421        // see the value-path assertion which uses `123_456_789u64`.
422        assert_eq!(
423            json,
424            json!({
425                "VerifiedTokenBalance": [
426                    "CZ8YUVdk7znjrUmnb5n7kgySk9yRAsQDYmyCxzfSky9t",
427                    123_456_789u64,
428                ],
429            })
430        );
431        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
432        assert_eq!(original, recovered);
433    }
434
435    #[test]
436    fn value_round_trip_with_full_wire_shape() {
437        use crate::serialization::ValueConvertible;
438        let original = fixture();
439        let value = original.to_object().expect("to_object");
440        // platform_value preserves typed `Identifier` and `U64` variants. We
441        // construct the expected `Value::Map` by hand: `platform_value!{...}`
442        // would convert the `Identifier` interpolation through Serialize
443        // (correct) but the outer shape has only one (Text-keyed) entry whose
444        // value is an Array of mixed-typed Values, so it's clearer to write
445        // the literal Map.
446        let expected = Value::Map(vec![(
447            Value::Text("VerifiedTokenBalance".to_string()),
448            Value::Array(vec![Value::Identifier([0xab; 32]), Value::U64(123_456_789)]),
449        )]);
450        assert_eq!(value, expected);
451        let recovered = StateTransitionProofResult::from_object(value).expect("from_object");
452        assert_eq!(original, recovered);
453    }
454
455    #[test]
456    fn verified_token_balance_large_amount_serializes_as_string() {
457        use crate::serialization::JsonConvertible;
458        // `TokenAmount` above `Number.MAX_SAFE_INTEGER` must serialize as a JSON
459        // string (it sits in a tuple variant the macro can't reach).
460        let original = StateTransitionProofResult::VerifiedTokenBalance(
461            Identifier::new([0xab; 32]),
462            9_007_199_254_740_993, // 2^53 + 1
463        );
464        let json = original.to_json().expect("to_json");
465        assert_eq!(json["VerifiedTokenBalance"][1], json!("9007199254740993"));
466        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
467        assert_eq!(original, recovered);
468    }
469
470    /// A token shielded pool's balance is a `u64` in a tuple variant that `#[json_safe_fields]`
471    /// cannot reach, so the JS-safe helper has to sit on the field itself. Past
472    /// `Number.MAX_SAFE_INTEGER` it must serialize as a JSON string, or a JS consumer reads a
473    /// silently rounded pool balance.
474    #[test]
475    fn verified_token_shielded_pool_balance_large_amount_serializes_as_string() {
476        use crate::serialization::JsonConvertible;
477        let original = StateTransitionProofResult::VerifiedTokenShieldedPoolBalance(
478            Identifier::new([0xcd; 32]),
479            9_007_199_254_740_993, // 2^53 + 1
480        );
481        let json = original.to_json().expect("to_json");
482        assert_eq!(
483            json["VerifiedTokenShieldedPoolBalance"][1],
484            json!("9007199254740993")
485        );
486        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
487        assert_eq!(original, recovered);
488    }
489
490    /// The pool balance a group action's proof carries is an optional `u64`, equally out of the
491    /// macro's reach, and equally lossy in JS without the helper.
492    #[test]
493    fn verified_token_group_action_with_shielded_pool_balance_large_amount_serializes_as_string() {
494        use crate::serialization::JsonConvertible;
495        let original = StateTransitionProofResult::VerifiedTokenGroupActionWithShieldedPoolBalance(
496            1,
497            GroupActionStatus::ActionActive,
498            Some(9_007_199_254_740_993), // 2^53 + 1
499        );
500        let json = original.to_json().expect("to_json");
501        assert_eq!(
502            json["VerifiedTokenGroupActionWithShieldedPoolBalance"][2],
503            json!("9007199254740993")
504        );
505        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
506        assert_eq!(original, recovered);
507    }
508
509    /// A group action burning out of a pool reports each named nullifier's spend status. An
510    /// action still gathering signatures has spent nothing, and the round trip has to preserve
511    /// those `false` flags rather than dropping them — they are what tells a client the burn has
512    /// not run yet.
513    #[test]
514    fn verified_token_group_action_with_shielded_nullifiers_round_trips_unspent_flags() {
515        use crate::serialization::JsonConvertible;
516        let original = StateTransitionProofResult::VerifiedTokenGroupActionWithShieldedNullifiers(
517            1,
518            GroupActionStatus::ActionActive,
519            vec![(vec![1u8, 2, 3], false), (vec![4u8, 5, 6], false)],
520        );
521        let json = original.to_json().expect("to_json");
522        // Each nullifier is a `[bytes, spent]` pair and the `false` is on the wire. Asserted on
523        // the wire rather than across a round trip because the round trip cannot see it: a
524        // serializer that wrote the nullifier alone and a deserializer that filled the flag back
525        // in as `false` would agree with each other and hand a client a burn with no spend
526        // status at all.
527        assert_eq!(
528            json,
529            json!({
530                "VerifiedTokenGroupActionWithShieldedNullifiers": [
531                    1,
532                    "actionActive",
533                    [[[1, 2, 3], false], [[4, 5, 6], false]],
534                ],
535            })
536        );
537        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
538        assert_eq!(original, recovered);
539    }
540
541    /// The outcome carries the owner's balance next to the result; past
542    /// `Number.MAX_SAFE_INTEGER` it serializes as a string, and a proof made
543    /// before protocol version 14 carries none.
544    #[test]
545    fn outcome_owner_balance_serializes_as_string_past_safe_integer() {
546        let outcome = StateTransitionProofOutcome::execution_proved(fixture())
547            .with_owner_balance(Some(9_007_199_254_740_993));
548        let json = serde_json::to_value(&outcome).expect("to json");
549        assert_eq!(json["owner_balance"], json!("9007199254740993"));
550        assert_eq!(json["guarantee"], json!("ExecutionProved"));
551        let recovered: StateTransitionProofOutcome =
552            serde_json::from_value(json).expect("from json");
553        assert_eq!(outcome, recovered);
554
555        let without_balance = StateTransitionProofOutcome::affected_state(fixture());
556        let json = serde_json::to_value(&without_balance).expect("to json");
557        assert_eq!(json["owner_balance"], serde_json::Value::Null);
558        let recovered: StateTransitionProofOutcome =
559            serde_json::from_value(json).expect("from json");
560        assert_eq!(without_balance, recovered);
561    }
562
563    #[test]
564    fn verified_address_infos_large_credits_serialize_as_string() {
565        use crate::serialization::{JsonConvertible, ValueConvertible};
566        use std::collections::BTreeMap;
567        // `Credits` (u64) nested in `BTreeMap<PlatformAddress, Option<(AddressNonce,
568        // Credits)>>` must be JS-safe via the bespoke `json_safe_address_info_map`
569        // helper, preserving the `[nonce, credits]` array wire-shape.
570        let big_credits: Credits = 9_007_199_254_740_993; // 2^53 + 1
571        let mut infos: BTreeMap<PlatformAddress, Option<(AddressNonce, Credits)>> = BTreeMap::new();
572        infos.insert(PlatformAddress::P2pkh([0x11; 20]), Some((7, big_credits)));
573        infos.insert(PlatformAddress::P2sh([0x22; 20]), None);
574        let original = StateTransitionProofResult::VerifiedAddressInfos(infos);
575
576        // Human-readable JSON: credits string, nonce number, null preserved.
577        let json = original.to_json().expect("to_json");
578        let map = json["VerifiedAddressInfos"].as_object().expect("object");
579        let non_null = map
580            .values()
581            .find(|v| !v.is_null())
582            .expect("one populated entry");
583        assert_eq!(non_null[0], json!(7));
584        assert_eq!(non_null[1], json!("9007199254740993"));
585        assert!(
586            map.values().any(|v| v.is_null()),
587            "the None entry must survive"
588        );
589        let recovered = StateTransitionProofResult::from_json(json).expect("from_json");
590        assert_eq!(original, recovered);
591
592        // Non-human-readable (platform_value): native u64, round-trips intact.
593        let value = original.to_object().expect("to_object");
594        let recovered = StateTransitionProofResult::from_object(value).expect("from_object");
595        assert_eq!(original, recovered);
596    }
597    /// A pool balance must not convert through a pre-existing variant's `TryInto` impl.
598    ///
599    /// `derive_more::TryInto` writes one `TryFrom` per distinct field-type list and ORs every
600    /// matching variant into it. `TokenAmount` and `Credits` are both `u64`, so a pool balance's
601    /// list is identical to `VerifiedTokenBalance`'s, and including it would turn a conversion
602    /// that used to identify one variant into one that accepts either. The pool variants are
603    /// therefore excluded from the derive, and this pins that: the pre-existing conversion still
604    /// works and still means what it meant, and the pool variant is refused.
605    #[test]
606    fn should_refuse_to_convert_a_pool_balance_through_a_pre_existing_variants_impl() {
607        let id = Identifier::from([7u8; 32]);
608
609        let balance = StateTransitionProofResult::VerifiedTokenBalance(id, 42);
610        let converted: (Identifier, u64) = balance
611            .try_into()
612            .expect("the pre-existing conversion still identifies a token balance");
613        assert_eq!(converted, (id, 42));
614
615        let pool = StateTransitionProofResult::VerifiedTokenShieldedPoolBalance(id, 42);
616        let refused: Result<(Identifier, u64), _> = pool.try_into();
617        assert!(
618            refused.is_err(),
619            "a pool balance must not pass for a token balance"
620        );
621    }
622}