Skip to main content

nautilus_model/events/account/
state.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::HashMap, fmt::Display};
17
18use nautilus_core::{Params, UUID4, UnixNanos};
19use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
20
21use crate::{
22    enums::AccountType,
23    identifiers::{AccountId, InstrumentId},
24    types::{AccountBalance, Currency, MarginBalance, balance::WalletAccountBalances},
25};
26
27/// Represents an event which includes information on the state of the account.
28///
29/// The optional `info` bag carries venue-specific account data that does not map
30/// to the typed `balances` and `margins` fields, such as wallet balance,
31/// available balance, or an account summary, so consumers can read the venue
32/// context that accompanied a given snapshot.
33#[repr(C)]
34#[derive(Debug, Clone, Deserialize)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
42)]
43pub struct AccountState {
44    /// The account ID associated with the event.
45    pub account_id: AccountId,
46    /// The type of the account (e.g., margin, spot, etc.).
47    pub account_type: AccountType,
48    /// The base currency for the account, if applicable.
49    pub base_currency: Option<Currency>,
50    /// The balances in the account.
51    pub balances: Vec<AccountBalance>,
52    /// The margin balances in the account.
53    pub margins: Vec<MarginBalance>,
54    /// Indicates if the account state is reported by the exchange
55    /// (as opposed to system-calculated).
56    pub is_reported: bool,
57    /// The unique identifier for the event.
58    pub event_id: UUID4,
59    /// UNIX timestamp (nanoseconds) when the event occurred.
60    pub ts_event: UnixNanos,
61    /// UNIX timestamp (nanoseconds) when the event was initialized.
62    pub ts_init: UnixNanos,
63    /// Additional implementation-specific account information, if any.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub info: Option<Params>,
66}
67
68impl Serialize for AccountState {
69    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
70    where
71        S: Serializer,
72    {
73        let field_count = 9 + usize::from(self.info.is_some());
74        let mut state = serializer.serialize_struct("AccountState", field_count)?;
75        state.serialize_field("account_id", &self.account_id)?;
76        state.serialize_field("account_type", &self.account_type)?;
77        state.serialize_field("base_currency", &self.base_currency)?;
78        if self.account_type == AccountType::Wallet {
79            state.serialize_field("balances", &WalletAccountBalances::new(&self.balances))?;
80        } else {
81            state.serialize_field("balances", &self.balances)?;
82        }
83        state.serialize_field("margins", &self.margins)?;
84        state.serialize_field("is_reported", &self.is_reported)?;
85        state.serialize_field("event_id", &self.event_id)?;
86        state.serialize_field("ts_event", &self.ts_event)?;
87        state.serialize_field("ts_init", &self.ts_init)?;
88        if let Some(info) = &self.info {
89            state.serialize_field("info", info)?;
90        }
91        state.end()
92    }
93}
94
95impl AccountState {
96    /// Creates a new [`AccountState`] instance.
97    #[expect(clippy::too_many_arguments)]
98    #[must_use]
99    pub fn new(
100        account_id: AccountId,
101        account_type: AccountType,
102        balances: Vec<AccountBalance>,
103        margins: Vec<MarginBalance>,
104        is_reported: bool,
105        event_id: UUID4,
106        ts_event: UnixNanos,
107        ts_init: UnixNanos,
108        base_currency: Option<Currency>,
109    ) -> Self {
110        Self {
111            account_id,
112            account_type,
113            base_currency,
114            balances,
115            margins,
116            is_reported,
117            event_id,
118            ts_event,
119            ts_init,
120            info: None,
121        }
122    }
123
124    /// Attaches additional implementation-specific account information to this event.
125    #[must_use]
126    pub fn with_info(mut self, info: Option<Params>) -> Self {
127        self.info = info;
128        self
129    }
130
131    /// Returns `true` if this account state has the same balances and margins as another.
132    ///
133    /// This compares all balances and margins for equality, returning `true` only if
134    /// all balances and margins are equal. If any balance or margin is different or
135    /// missing, returns `false`.
136    ///
137    /// # Note
138    ///
139    /// This method does not compare event IDs, timestamps, or other metadata - only
140    /// the actual balance and margin values.
141    #[must_use]
142    pub fn has_same_balances_and_margins(&self, other: &Self) -> bool {
143        // Quick check - if lengths differ, they can't be equal
144        if self.balances.len() != other.balances.len() || self.margins.len() != other.margins.len()
145        {
146            return false;
147        }
148
149        // Compare balances by currency
150        let self_balances: HashMap<Currency, &AccountBalance> = self
151            .balances
152            .iter()
153            .map(|balance| (balance.currency, balance))
154            .collect();
155
156        let other_balances: HashMap<Currency, &AccountBalance> = other
157            .balances
158            .iter()
159            .map(|balance| (balance.currency, balance))
160            .collect();
161
162        // Check if all balances are equal
163        for (currency, self_balance) in &self_balances {
164            match other_balances.get(currency) {
165                Some(other_balance) => {
166                    if self_balance != other_balance {
167                        return false;
168                    }
169                }
170                None => return false, // Currency missing in other
171            }
172        }
173
174        // Compare margins by (instrument_id, currency) so that account-wide
175        // entries (instrument_id = None) for different collateral currencies
176        // do not collide.
177        let self_margins: HashMap<(Option<InstrumentId>, Currency), &MarginBalance> = self
178            .margins
179            .iter()
180            .map(|margin| ((margin.instrument_id, margin.currency), margin))
181            .collect();
182
183        let other_margins: HashMap<(Option<InstrumentId>, Currency), &MarginBalance> = other
184            .margins
185            .iter()
186            .map(|margin| ((margin.instrument_id, margin.currency), margin))
187            .collect();
188
189        // Check if all margins are equal
190        for (key, self_margin) in &self_margins {
191            match other_margins.get(key) {
192                Some(other_margin) => {
193                    if self_margin != other_margin {
194                        return false;
195                    }
196                }
197                None => return false, // Entry missing in other
198            }
199        }
200
201        true
202    }
203}
204
205impl Display for AccountState {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        write!(
208            f,
209            "{}(account_id={}, account_type={}, base_currency={}, is_reported={}, balances=[{}], margins=[{}], event_id={})",
210            stringify!(AccountState),
211            self.account_id,
212            self.account_type,
213            self.base_currency.map_or_else(
214                || "None".to_string(),
215                |base_currency| format!("{}", base_currency.code)
216            ),
217            self.is_reported,
218            self.balances
219                .iter()
220                .map(|b| format!("{b}"))
221                .collect::<Vec<String>>()
222                .join(", "),
223            self.margins
224                .iter()
225                .map(|m| format!("{m}"))
226                .collect::<Vec<String>>()
227                .join(", "),
228            self.event_id
229        )
230    }
231}
232
233impl PartialEq for AccountState {
234    fn eq(&self, other: &Self) -> bool {
235        self.account_id == other.account_id
236            && self.account_type == other.account_type
237            && self.event_id == other.event_id
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    #[cfg(feature = "defi")]
244    use std::process::Command;
245
246    use indexmap::IndexMap;
247    use nautilus_core::{Params, UUID4, UnixNanos};
248    use rstest::rstest;
249    use serde_json::json;
250
251    use crate::{
252        enums::{AccountType, CurrencyType},
253        events::{
254            AccountState,
255            account::stubs::{cash_account_state, margin_account_state},
256        },
257        identifiers::{AccountId, InstrumentId},
258        types::{AccountBalance, Currency, MarginBalance, Money},
259    };
260
261    #[rstest]
262    fn test_equality() {
263        let cash_account_state_1 = cash_account_state();
264        let cash_account_state_2 = cash_account_state();
265        assert_eq!(cash_account_state_1, cash_account_state_2);
266    }
267
268    #[rstest]
269    fn test_display_cash_account_state(cash_account_state: AccountState) {
270        let display = format!("{cash_account_state}");
271        assert_eq!(
272            display,
273            "AccountState(account_id=SIM-001, account_type=CASH, base_currency=USD, is_reported=true, \
274            balances=[AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)], \
275            margins=[], event_id=16578139-a945-4b65-b46c-bc131a15d8e7)"
276        );
277    }
278
279    #[rstest]
280    fn test_display_margin_account_state(margin_account_state: AccountState) {
281        let display = format!("{margin_account_state}");
282        assert_eq!(
283            display,
284            "AccountState(account_id=SIM-001, account_type=MARGIN, base_currency=USD, is_reported=true, \
285            balances=[AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)], \
286            margins=[MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)], \
287            event_id=16578139-a945-4b65-b46c-bc131a15d8e7)"
288        );
289    }
290
291    #[rstest]
292    fn test_has_same_balances_and_margins_when_identical() {
293        let state1 = cash_account_state();
294        let state2 = cash_account_state();
295        assert!(state1.has_same_balances_and_margins(&state2));
296    }
297
298    #[rstest]
299    fn test_has_same_balances_and_margins_when_different_balance_amounts() {
300        let state1 = cash_account_state();
301        let mut state2 = cash_account_state();
302        // Create a different balance with same currency
303        let usd = Currency::USD();
304        let different_balance = AccountBalance::new(
305            Money::new(2_000_000.0, usd),
306            Money::new(50000.0, usd),
307            Money::new(1_950_000.0, usd),
308        );
309        state2.balances = vec![different_balance];
310        assert!(!state1.has_same_balances_and_margins(&state2));
311    }
312
313    #[rstest]
314    fn test_has_same_balances_and_margins_when_different_balance_currencies() {
315        let state1 = cash_account_state();
316        let mut state2 = cash_account_state();
317        // Create a balance with different currency
318        let eur = Currency::EUR();
319        let different_balance = AccountBalance::new(
320            Money::new(1_525_000.0, eur),
321            Money::new(25000.0, eur),
322            Money::new(1_500_000.0, eur),
323        );
324        state2.balances = vec![different_balance];
325        assert!(!state1.has_same_balances_and_margins(&state2));
326    }
327
328    #[rstest]
329    fn test_has_same_balances_and_margins_when_missing_balance() {
330        let state1 = cash_account_state();
331        let mut state2 = cash_account_state();
332        // Add an additional balance to state2
333        let eur = Currency::EUR();
334        let additional_balance = AccountBalance::new(
335            Money::new(1_000_000.0, eur),
336            Money::new(0.0, eur),
337            Money::new(1_000_000.0, eur),
338        );
339        state2.balances.push(additional_balance);
340        assert!(!state1.has_same_balances_and_margins(&state2));
341    }
342
343    #[rstest]
344    fn test_has_same_balances_and_margins_when_different_margin_amounts() {
345        let state1 = margin_account_state();
346        let mut state2 = margin_account_state();
347        // Create a different margin with same instrument_id
348        let usd = Currency::USD();
349        let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
350        let different_margin = MarginBalance::new(
351            Money::new(10000.0, usd),
352            Money::new(40000.0, usd),
353            Some(instrument_id),
354        );
355        state2.margins = vec![different_margin];
356        assert!(!state1.has_same_balances_and_margins(&state2));
357    }
358
359    #[rstest]
360    fn test_has_same_balances_and_margins_when_different_margin_instruments() {
361        let state1 = margin_account_state();
362        let mut state2 = margin_account_state();
363        // Create a margin with different instrument_id
364        let usd = Currency::USD();
365        let different_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
366        let different_margin = MarginBalance::new(
367            Money::new(5000.0, usd),
368            Money::new(20000.0, usd),
369            Some(different_instrument_id),
370        );
371        state2.margins = vec![different_margin];
372        assert!(!state1.has_same_balances_and_margins(&state2));
373    }
374
375    #[rstest]
376    fn test_has_same_balances_and_margins_when_missing_margin() {
377        let state1 = margin_account_state();
378        let mut state2 = margin_account_state();
379        // Add an additional margin to state2
380        let usd = Currency::USD();
381        let additional_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
382        let additional_margin = MarginBalance::new(
383            Money::new(3000.0, usd),
384            Money::new(15000.0, usd),
385            Some(additional_instrument_id),
386        );
387        state2.margins.push(additional_margin);
388        assert!(!state1.has_same_balances_and_margins(&state2));
389    }
390
391    #[rstest]
392    fn test_has_same_balances_and_margins_with_empty_collections() {
393        let account_id = AccountId::new("TEST-001");
394        let event_id = UUID4::new();
395        let ts_event = UnixNanos::from(1);
396        let ts_init = UnixNanos::from(2);
397
398        let state1 = AccountState::new(
399            account_id,
400            AccountType::Cash,
401            vec![], // Empty balances
402            vec![], // Empty margins
403            true,
404            event_id,
405            ts_event,
406            ts_init,
407            Some(Currency::USD()),
408        );
409
410        let state2 = AccountState::new(
411            account_id,
412            AccountType::Cash,
413            vec![], // Empty balances
414            vec![], // Empty margins
415            true,
416            UUID4::new(),       // Different event_id
417            UnixNanos::from(3), // Different timestamps
418            UnixNanos::from(4),
419            Some(Currency::USD()),
420        );
421
422        assert!(state1.has_same_balances_and_margins(&state2));
423    }
424
425    #[rstest]
426    fn test_has_same_balances_and_margins_with_multiple_balances_and_margins() {
427        let account_id = AccountId::new("TEST-001");
428        let event_id = UUID4::new();
429        let ts_event = UnixNanos::from(1);
430        let ts_init = UnixNanos::from(2);
431
432        let usd = Currency::USD();
433        let eur = Currency::EUR();
434        let btc_instrument = InstrumentId::from("BTCUSDT.COINBASE");
435        let eth_instrument = InstrumentId::from("ETHUSDT.BINANCE");
436
437        let balances = vec![
438            AccountBalance::new(
439                Money::new(1_000_000.0, usd),
440                Money::new(0.0, usd),
441                Money::new(1_000_000.0, usd),
442            ),
443            AccountBalance::new(
444                Money::new(500_000.0, eur),
445                Money::new(10000.0, eur),
446                Money::new(490_000.0, eur),
447            ),
448        ];
449
450        let margins = vec![
451            MarginBalance::new(
452                Money::new(5000.0, usd),
453                Money::new(20000.0, usd),
454                Some(btc_instrument),
455            ),
456            MarginBalance::new(
457                Money::new(3000.0, usd),
458                Money::new(15000.0, usd),
459                Some(eth_instrument),
460            ),
461        ];
462
463        let state1 = AccountState::new(
464            account_id,
465            AccountType::Margin,
466            balances.clone(),
467            margins.clone(),
468            true,
469            event_id,
470            ts_event,
471            ts_init,
472            Some(usd),
473        );
474
475        let state2 = AccountState::new(
476            account_id,
477            AccountType::Margin,
478            balances,
479            margins,
480            true,
481            UUID4::new(),       // Different event_id
482            UnixNanos::from(3), // Different timestamps
483            UnixNanos::from(4),
484            Some(usd),
485        );
486
487        assert!(state1.has_same_balances_and_margins(&state2));
488    }
489
490    fn account_state_with_info() -> AccountState {
491        let mut info = IndexMap::new();
492        info.insert("total_wallet_balance".to_string(), json!(1525.0_f64));
493        info.insert("available_balance".to_string(), json!(1500.0_f64));
494        AccountState::new(
495            AccountId::new("SIM-001"),
496            AccountType::Cash,
497            vec![],
498            vec![],
499            true,
500            UUID4::new(),
501            UnixNanos::default(),
502            UnixNanos::default(),
503            Some(Currency::USD()),
504        )
505        .with_info(Some(Params::from_index_map(info)))
506    }
507
508    #[rstest]
509    fn test_new_defaults_info_to_none() {
510        let state = cash_account_state();
511        assert!(state.info.is_none());
512    }
513
514    #[rstest]
515    fn test_with_info_attaches_params() {
516        let state = account_state_with_info();
517        let info = state.info.expect("info should be set");
518        assert_eq!(info.get_f64("total_wallet_balance"), Some(1525.0));
519        assert_eq!(info.get_f64("available_balance"), Some(1500.0));
520        assert_eq!(info.get_f64("missing"), None);
521    }
522
523    #[rstest]
524    fn test_serde_round_trips_info() {
525        let state = account_state_with_info();
526        let serialized = serde_json::to_string(&state).expect("serialize");
527        let deserialized: AccountState = serde_json::from_str(&serialized).expect("deserialize");
528        let info = deserialized.info.expect("info should round-trip");
529        assert_eq!(info.get_f64("total_wallet_balance"), Some(1525.0));
530        assert_eq!(info.get_f64("available_balance"), Some(1500.0));
531    }
532
533    #[rstest]
534    fn test_serde_back_compatible_without_info() {
535        // Serialized AccountState from before the info field existed must still
536        // deserialize, defaulting info to None. Build the JSON by serializing a
537        // current state and removing the info key so the format is exact.
538        let state = cash_account_state();
539        let mut value = serde_json::to_value(&state)
540            .expect("serialize")
541            .as_object()
542            .cloned()
543            .unwrap();
544        value.remove("info");
545        let deserialized: AccountState =
546            serde_json::from_value(serde_json::Value::Object(value)).expect("deserialize legacy");
547        assert!(deserialized.info.is_none());
548    }
549
550    #[rstest]
551    #[case(AccountType::Cash, false)]
552    #[case(AccountType::Margin, false)]
553    #[case(AccountType::Betting, false)]
554    #[case(AccountType::Wallet, true)]
555    fn test_registered_account_balance_keeps_legacy_fields(
556        #[case] account_type: AccountType,
557        #[case] has_wallet_identity: bool,
558    ) {
559        let currency = Currency::USD();
560        let total = Money::from("10.25 USD");
561        let state = AccountState::new(
562            AccountId::new("SERDE-001"),
563            account_type,
564            vec![AccountBalance::new(total, Money::zero(currency), total)],
565            vec![],
566            true,
567            UUID4::new(),
568            UnixNanos::from(1),
569            UnixNanos::from(2),
570            None,
571        );
572
573        let value = serde_json::to_value(&state).expect("serialize account state");
574        let balance = &value["balances"][0];
575        let restored: AccountState =
576            serde_json::from_value(value.clone()).expect("deserialize account state");
577
578        assert_eq!(balance["currency"], "USD");
579        assert_eq!(balance["total"], "10.25 USD");
580        assert_eq!(balance["locked"], "0.00 USD");
581        assert_eq!(balance["free"], "10.25 USD");
582        assert_eq!(
583            balance.get("currency_identity").is_some(),
584            has_wallet_identity
585        );
586        assert_eq!(restored.balances[0].total.raw, total.raw);
587        assert_currency_identity(restored.balances[0].currency, currency);
588    }
589
590    #[rstest]
591    fn test_wallet_account_state_rejects_unaligned_raw_balance() {
592        let currency = Currency::USD();
593        let total = Money::from_raw(1, currency);
594        let state = AccountState::new(
595            AccountId::new("WALLET-SERDE-INVALID"),
596            AccountType::Wallet,
597            vec![AccountBalance::new(total, Money::zero(currency), total)],
598            vec![],
599            true,
600            UUID4::new(),
601            UnixNanos::from(1),
602            UnixNanos::from(2),
603            None,
604        );
605
606        let error = serde_json::to_string(&state).unwrap_err();
607
608        assert!(
609            error
610                .to_string()
611                .contains("is not aligned to currency precision 2"),
612            "was: {error}"
613        );
614    }
615
616    #[rstest]
617    fn test_wallet_account_state_rejects_inconsistent_exact_balance() {
618        let currency = Currency::USD();
619        let total = Money::from("10.25 USD");
620        let state = AccountState::new(
621            AccountId::new("WALLET-SERDE-CORRUPT"),
622            AccountType::Wallet,
623            vec![AccountBalance::new(total, Money::zero(currency), total)],
624            vec![],
625            true,
626            UUID4::new(),
627            UnixNanos::from(1),
628            UnixNanos::from(2),
629            None,
630        );
631        let mut value = serde_json::to_value(&state).expect("serialize account state");
632        value["balances"][0]["free_minor"] = json!("999");
633
634        let error = serde_json::from_value::<AccountState>(value).unwrap_err();
635
636        assert!(
637            error
638                .to_string()
639                .contains("`total` (10.25 USD) - `locked` (0.00 USD) != `free` (9.99 USD)"),
640            "was: {error}"
641        );
642    }
643
644    #[rstest]
645    fn test_wallet_account_state_rejects_same_code_currency_identity_mismatch() {
646        let balance_currency =
647            Currency::new("ENG729D", 6, 0, "Balance token", CurrencyType::Crypto);
648        let money_currency = Currency::new("ENG729D", 8, 0, "Money token", CurrencyType::Crypto);
649        let total = Money::from_mantissa_exponent(123_456_789, -8, money_currency);
650        let balance = AccountBalance {
651            currency: balance_currency,
652            total,
653            locked: Money::zero(money_currency),
654            free: total,
655        };
656        let state = AccountState::new(
657            AccountId::new("WALLET-SERDE-IDENTITY"),
658            AccountType::Wallet,
659            vec![balance],
660            vec![],
661            true,
662            UUID4::new(),
663            UnixNanos::from(1),
664            UnixNanos::from(2),
665            None,
666        );
667
668        let error = serde_json::to_string(&state).unwrap_err();
669
670        assert!(
671            error
672                .to_string()
673                .contains("Wallet account balance currency identity ENG729D does not match"),
674            "was: {error}"
675        );
676    }
677
678    #[cfg(feature = "defi")]
679    #[rstest]
680    fn test_wallet_account_state_fresh_process_round_trip() {
681        const PAYLOAD_ENV: &str = "NAUTILUS_WALLET_ACCOUNT_STATE_PAYLOAD";
682        const SUCCESS_SENTINEL: &str = "wallet account state fresh-process assertions passed";
683        const TEST_NAME: &str =
684            "events::account::state::tests::test_wallet_account_state_fresh_process_round_trip";
685
686        if let Ok(payload) = std::env::var(PAYLOAD_ENV) {
687            assert!(Currency::try_from_str("ENG729A").is_none());
688            assert!(Currency::try_from_str("ENG729B").is_none());
689
690            let restored: AccountState =
691                serde_json::from_str(&payload).expect("deserialize wallet account state");
692            let expected = wallet_serde_balances();
693
694            assert_eq!(restored.balances.len(), expected.len());
695            for (restored, expected) in restored.balances.iter().zip(&expected) {
696                assert_eq!(restored.total.raw, expected.total.raw);
697                assert_eq!(restored.locked.raw, expected.locked.raw);
698                assert_eq!(restored.free.raw, expected.free.raw);
699                assert_currency_identity(restored.currency, expected.currency);
700                assert_currency_identity(restored.total.currency, expected.total.currency);
701                assert_currency_identity(restored.locked.currency, expected.locked.currency);
702                assert_currency_identity(restored.free.currency, expected.free.currency);
703            }
704            assert!(Currency::try_from_str("ENG729A").is_none());
705            assert!(Currency::try_from_str("ENG729B").is_none());
706            println!("{SUCCESS_SENTINEL}");
707            return;
708        }
709
710        let producer_currency = Currency::new(
711            "ENG729A",
712            6,
713            0,
714            "Producer registered token",
715            CurrencyType::Crypto,
716        );
717        Currency::register(producer_currency, false).expect("register producer-only currency");
718        let state = AccountState::new(
719            AccountId::new("WALLET-SERDE-001"),
720            AccountType::Wallet,
721            wallet_serde_balances(),
722            vec![],
723            true,
724            UUID4::new(),
725            UnixNanos::from(1),
726            UnixNanos::from(2),
727            None,
728        );
729        let payload = serde_json::to_string(&state).expect("serialize wallet account state");
730
731        let output = Command::new(std::env::current_exe().expect("current test executable"))
732            .args(["--exact", TEST_NAME, "--nocapture"])
733            .env(PAYLOAD_ENV, payload)
734            .output()
735            .expect("run fresh-process wallet round-trip");
736
737        assert!(
738            output.status.success(),
739            "fresh-process wallet round-trip failed\nstdout:\n{}\nstderr:\n{}",
740            String::from_utf8_lossy(&output.stdout),
741            String::from_utf8_lossy(&output.stderr)
742        );
743        assert!(
744            String::from_utf8_lossy(&output.stdout).contains(SUCCESS_SENTINEL),
745            "fresh-process wallet round-trip did not run child assertions\nstdout:\n{}\nstderr:\n{}",
746            String::from_utf8_lossy(&output.stdout),
747            String::from_utf8_lossy(&output.stderr)
748        );
749    }
750
751    #[cfg(feature = "defi")]
752    fn wallet_serde_balances() -> Vec<AccountBalance> {
753        let eth = Currency::new("ETH", 18, 0, "Ethereum", CurrencyType::Crypto);
754        let registered = Currency::new(
755            "ENG729A",
756            6,
757            0,
758            "Producer registered token",
759            CurrencyType::Crypto,
760        );
761        let unregistered =
762            Currency::new("ENG729B", 8, 0, "Unregistered token", CurrencyType::Crypto);
763        let eth_total = Money::from_raw(1_234_567_890_123_456_789, eth);
764        let registered_total = Money::from_mantissa_exponent(123_456_789, -6, registered);
765        let unregistered_total = Money::from_mantissa_exponent(98_765_432, -8, unregistered);
766
767        [eth_total, registered_total, unregistered_total]
768            .into_iter()
769            .map(|total| AccountBalance::new(total, Money::zero(total.currency), total))
770            .collect()
771    }
772
773    fn assert_currency_identity(actual: Currency, expected: Currency) {
774        assert_eq!(actual.code, expected.code);
775        assert_eq!(actual.precision, expected.precision);
776        assert_eq!(actual.iso4217, expected.iso4217);
777        assert_eq!(actual.name, expected.name);
778        assert_eq!(actual.currency_type, expected.currency_type);
779    }
780
781    #[rstest]
782    fn test_info_excluded_from_equality() {
783        // Equality keys on account_id, account_type, and event_id only, so a
784        // differing info bag must not affect equality.
785        let base = account_state_with_info();
786        let mut other_info = IndexMap::new();
787        other_info.insert("different".to_string(), json!(1_u64));
788        let other = AccountState {
789            info: Some(Params::from_index_map(other_info)),
790            ..base.clone()
791        };
792        assert_eq!(base, other);
793    }
794}