Skip to main content

miden_node_store/genesis/config/
mod.rs

1//! Describe a subset of the genesis manifest in easily human readable format
2
3use std::cmp::Ordering;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use indexmap::IndexMap;
8use miden_node_utils::crypto::get_random_coin;
9use miden_protocol::account::auth::{AuthScheme, AuthSecretKey};
10use miden_protocol::account::{
11    Account,
12    AccountBuilder,
13    AccountDelta,
14    AccountFile,
15    AccountId,
16    AccountStorageDelta,
17    AccountType,
18    AccountVaultDelta,
19    FungibleAssetDelta,
20    NonFungibleAssetDelta,
21};
22use miden_protocol::asset::{AssetAmount, FungibleAsset, TokenSymbol};
23use miden_protocol::block::FeeParameters;
24use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey;
25use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey as RpoSecretKey;
26use miden_protocol::errors::TokenSymbolError;
27use miden_protocol::{Felt, ONE};
28use miden_standards::AuthMethod;
29use miden_standards::account::auth::AuthSingleSig;
30use miden_standards::account::faucets::{FungibleFaucet, TokenName};
31use miden_standards::account::policies::{
32    BurnPolicyConfig,
33    MintPolicyConfig,
34    PolicyRegistration,
35    TokenPolicyManager,
36};
37use miden_standards::account::wallets::create_basic_wallet;
38use rand::distr::weighted::Weight;
39use rand::{Rng, SeedableRng};
40use rand_chacha::ChaCha20Rng;
41use serde::{Deserialize, Serialize};
42
43use crate::GenesisState;
44
45mod errors;
46use self::errors::GenesisConfigError;
47
48#[cfg(test)]
49mod tests;
50
51const DEFAULT_NATIVE_FAUCET_SYMBOL: &str = "MIDEN";
52const DEFAULT_NATIVE_FAUCET_DECIMALS: u8 = 6;
53const DEFAULT_NATIVE_FAUCET_MAX_SUPPLY: u64 = 100_000_000_000_000_000;
54
55// GENESIS CONFIG
56// ================================================================================================
57
58/// An account loaded from a `.mac` file (path relative to genesis config directory).
59///
60/// Notice: Generic accounts are not validated (e.g. that their vault assets reference known
61/// faucets), leaving the responsibility of ensuring valid genesis state to the operator.
62#[derive(Debug, Clone, serde::Deserialize)]
63#[serde(deny_unknown_fields)]
64struct GenericAccountConfig {
65    path: PathBuf,
66}
67
68/// Specify a set of faucets and wallets with assets for easier test deployments.
69///
70/// Notice: Any faucet must be declared _before_ it's use in a wallet/regular account.
71#[derive(Debug, Clone, serde::Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct GenesisConfig {
74    version: u32,
75    timestamp: u32,
76    /// Override the native faucet with a custom faucet account.
77    ///
78    /// If unspecified, a default native faucet will be used with:
79    ///
80    /// ```toml
81    /// symbol     = "MIDEN"
82    /// decimals   = 6
83    /// max_supply = 100_000_000_000_000_000
84    /// ```
85    #[serde(default)]
86    native_faucet: Option<PathBuf>,
87    fee_parameters: FeeParameterConfig,
88    #[serde(default)]
89    wallet: Vec<WalletConfig>,
90    #[serde(default)]
91    fungible_faucet: Vec<FungibleFaucetConfig>,
92    #[serde(default)]
93    account: Vec<GenericAccountConfig>,
94    #[serde(skip)]
95    config_dir: PathBuf,
96}
97
98impl Default for GenesisConfig {
99    fn default() -> Self {
100        Self {
101            version: 1_u32,
102            timestamp: u32::try_from(
103                std::time::SystemTime::now()
104                    .duration_since(std::time::UNIX_EPOCH)
105                    .expect("Time does not go backwards")
106                    .as_secs(),
107            )
108            .expect("Timestamp should fit into u32"),
109            wallet: vec![],
110            native_faucet: None,
111            fee_parameters: FeeParameterConfig { verification_base_fee: 0 },
112            fungible_faucet: vec![],
113            account: vec![],
114            config_dir: PathBuf::from("."),
115        }
116    }
117}
118
119impl GenesisConfig {
120    /// Read the genesis config from a TOML file.
121    ///
122    /// The parent directory of `path` is used to resolve relative paths for account files
123    /// referenced in the configuration (e.g., `[[account]]` entries with `path` fields).
124    ///
125    /// Notice: It will generate the specified case during [`fn into_state`].
126    pub fn read_toml_file(path: &Path) -> Result<Self, GenesisConfigError> {
127        let toml_str = fs_err::read_to_string(path)
128            .map_err(|e| GenesisConfigError::ConfigFileRead(e, path.to_path_buf()))?;
129        let config_dir = path.parent().expect("config file path must have a parent directory");
130        Self::read_toml(&toml_str, config_dir)
131    }
132
133    /// Parse a genesis config from a TOML formatted string.
134    ///
135    /// The `config_dir` parameter is stored so that relative paths for account files
136    /// (e.g., `[[account]]` entries with `path` fields, or native faucet file references)
137    /// can be resolved later during [`Self::into_state`].
138    fn read_toml(toml_str: &str, config_dir: &Path) -> Result<Self, GenesisConfigError> {
139        let mut config: Self = toml::from_str(toml_str)?;
140        config.config_dir = config_dir.to_path_buf();
141        Ok(config)
142    }
143
144    /// Convert the in memory representation into the new genesis state
145    ///
146    /// Also returns the set of secrets for the generated accounts.
147    #[expect(clippy::too_many_lines)]
148    pub fn into_state(
149        self,
150        validator_key: PublicKey,
151    ) -> Result<(GenesisState, AccountSecrets), GenesisConfigError> {
152        let GenesisConfig {
153            version,
154            timestamp,
155            native_faucet,
156            fee_parameters,
157            fungible_faucet: fungible_faucet_configs,
158            wallet: wallet_configs,
159            account: account_entries,
160            config_dir,
161        } = self;
162
163        // Load account files from disk
164        let file_loaded_accounts = account_entries
165            .into_iter()
166            .map(|acc| {
167                let full_path = config_dir.join(&acc.path);
168                let account_file = AccountFile::read(&full_path)
169                    .map_err(|e| GenesisConfigError::AccountFileRead(e, full_path.clone()))?;
170                Ok(account_file.account)
171            })
172            .collect::<Result<Vec<_>, GenesisConfigError>>()?;
173
174        let mut wallet_accounts = Vec::<Account>::new();
175        // Every asset sitting in a wallet, has to reference a faucet for that asset
176        let mut faucet_accounts = IndexMap::<TokenSymbolStr, Account>::new();
177
178        // Collect the generated secret keys for the test, so one can interact with those
179        // accounts/sign transactions
180        let mut secrets = Vec::new();
181
182        // Handle native faucet: build from defaults or load from file
183        let (native_faucet_account, symbol, native_secret) =
184            NativeFaucetConfig(native_faucet).build_account(&config_dir)?;
185        if let Some(secret_key) = native_secret {
186            secrets.push((
187                format!("faucet_{symbol}.mac", symbol = symbol.to_string().to_lowercase()),
188                native_faucet_account.id(),
189                secret_key,
190            ));
191        }
192        let native_faucet_account_id = native_faucet_account.id();
193        faucet_accounts.insert(symbol.clone(), native_faucet_account);
194
195        // Setup additional fungible faucets from parameters
196        for fungible_faucet_config in fungible_faucet_configs {
197            let symbol = fungible_faucet_config.symbol.clone();
198            let (faucet_account, secret_key) = fungible_faucet_config.build_account()?;
199
200            if faucet_accounts.insert(symbol.clone(), faucet_account.clone()).is_some() {
201                return Err(GenesisConfigError::DuplicateFaucetDefinition { symbol });
202            }
203
204            secrets.push((
205                format!("faucet_{symbol}.mac", symbol = symbol.to_string().to_lowercase()),
206                faucet_account.id(),
207                secret_key,
208            ));
209            // Do _not_ collect the account, only after we know all wallet assets we know the
210            // remaining supply in the faucets.
211        }
212
213        let fee_parameters =
214            FeeParameters::new(native_faucet_account_id, fee_parameters.verification_base_fee);
215
216        // Track all adjustments, one per faucet account id
217        let mut faucet_issuance = IndexMap::<AccountId, u64>::new();
218
219        let zero_padding_width = usize::ilog10(std::cmp::max(10, wallet_configs.len())) as usize;
220
221        // Setup all wallet accounts, which reference the faucet's for their provided assets.
222        for (index, WalletConfig { account_type, assets }) in wallet_configs.into_iter().enumerate()
223        {
224            tracing::debug!(index, assets = ?assets, "Adding wallet account");
225
226            let mut rng = ChaCha20Rng::from_seed(rand::random());
227            let secret_key = RpoSecretKey::with_rng(&mut get_random_coin(&mut rng));
228            let auth = AuthMethod::SingleSig {
229                approver: (secret_key.public_key().into(), AuthScheme::Falcon512Poseidon2),
230            };
231            let init_seed: [u8; 32] = rng.random();
232
233            let mut wallet_account = create_basic_wallet(init_seed, auth, account_type.into())?;
234
235            // Add fungible assets and track the faucet adjustments per faucet/asset.
236            let wallet_fungible_asset_update =
237                prepare_fungible_asset_update(assets, &faucet_accounts, &mut faucet_issuance)?;
238
239            // Force the account nonce to 1.
240            //
241            // By convention, a nonce of zero indicates a freshly generated local account that has
242            // yet to be deployed. An account is deployed onchain along with its first
243            // transaction which results in a non-zero nonce onchain.
244            //
245            // The genesis block is special in that accounts are "deployed" without transactions and
246            // therefore we need bump the nonce manually to uphold this invariant.
247            let wallet_delta = AccountDelta::new(
248                wallet_account.id(),
249                AccountStorageDelta::default(),
250                AccountVaultDelta::new(
251                    wallet_fungible_asset_update,
252                    NonFungibleAssetDelta::default(),
253                ),
254                ONE,
255            )?;
256
257            wallet_account.apply_delta(&wallet_delta)?;
258
259            debug_assert_eq!(wallet_account.nonce(), ONE);
260
261            secrets.push((
262                format!("wallet_{index:0zero_padding_width$}.mac"),
263                wallet_account.id(),
264                secret_key,
265            ));
266
267            wallet_accounts.push(wallet_account);
268        }
269
270        let mut all_accounts = Vec::<Account>::new();
271        // Apply all fungible faucet adjustments to the respective faucet
272        for (symbol, mut faucet_account) in faucet_accounts {
273            let faucet_id = faucet_account.id();
274            // If there is no account using the asset, we use an empty delta to set the nonce to
275            // `ONE`.
276            let total_issuance = faucet_issuance.get(&faucet_id).copied().unwrap_or_default();
277
278            let mut storage_delta = AccountStorageDelta::default();
279
280            if total_issuance != 0 {
281                let current_faucet = FungibleFaucet::try_from(faucet_account.storage())?;
282                let new_token_supply = AssetAmount::new(total_issuance)?;
283                let max_supply = current_faucet.max_supply().as_u64();
284                if max_supply < total_issuance {
285                    return Err(GenesisConfigError::MaxIssuanceExceeded {
286                        max_supply,
287                        symbol: symbol.clone(),
288                        total_issuance,
289                    });
290                }
291                let updated_faucet = current_faucet.with_token_supply(new_token_supply)?;
292                let slot = updated_faucet.token_config_slot_value();
293                storage_delta.set_item(slot.name().clone(), slot.value())?;
294                tracing::debug!(
295                    "Reducing faucet account {faucet} for {symbol} by {amount}",
296                    faucet = faucet_id.to_hex(),
297                    symbol = symbol,
298                    amount = total_issuance
299                );
300            } else {
301                tracing::debug!(
302                    "No wallet is referencing {faucet} for {symbol}",
303                    faucet = faucet_id.to_hex(),
304                    symbol = symbol,
305                );
306            }
307
308            faucet_account.apply_delta(&AccountDelta::new(
309                faucet_id,
310                storage_delta,
311                AccountVaultDelta::default(),
312                ONE,
313            )?)?;
314
315            debug_assert_eq!(faucet_account.nonce(), ONE);
316
317            // sanity check the total issuance against
318            let faucet = FungibleFaucet::try_from(faucet_account.storage())?;
319            let max_supply = faucet.max_supply().as_u64();
320            if max_supply < total_issuance {
321                return Err(GenesisConfigError::MaxIssuanceExceeded {
322                    max_supply,
323                    symbol,
324                    total_issuance,
325                });
326            }
327
328            all_accounts.push(faucet_account);
329        }
330        // Ensure the faucets always precede the wallets referencing them
331        all_accounts.extend(wallet_accounts);
332
333        // Append file-loaded accounts as-is
334        all_accounts.extend(file_loaded_accounts);
335
336        Ok((
337            GenesisState {
338                fee_parameters,
339                accounts: all_accounts,
340                version,
341                timestamp,
342                validator_key,
343            },
344            AccountSecrets { secrets },
345        ))
346    }
347}
348
349// FEE PARAMETER CONFIG
350// ================================================================================================
351
352/// Represents a the fee parameters using the given asset
353///
354/// A faucet providing the `symbol` token moste exist.
355#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct FeeParameterConfig {
358    /// Verification base fee, in units of smallest denomination.
359    verification_base_fee: u32,
360}
361
362// NATIVE FAUCET CONFIG
363// ================================================================================================
364
365/// Wraps an optional path to a pre-built faucet account file.
366///
367/// When no path is provided, a default native faucet is built using hardcoded MIDEN defaults.
368struct NativeFaucetConfig(Option<PathBuf>);
369
370impl NativeFaucetConfig {
371    /// Build or load the native faucet account.
372    ///
373    /// For `None`, builds a new faucet from defaults and returns the generated secret key.
374    /// For `Some(path)`, loads the account from disk and validates it is a fungible faucet.
375    fn build_account(
376        self,
377        config_dir: &Path,
378    ) -> Result<(Account, TokenSymbolStr, Option<RpoSecretKey>), GenesisConfigError> {
379        match self.0 {
380            None => {
381                let symbol = TokenSymbolStr::from_str(DEFAULT_NATIVE_FAUCET_SYMBOL).unwrap();
382                let faucet_config = FungibleFaucetConfig {
383                    symbol: symbol.clone(),
384                    decimals: DEFAULT_NATIVE_FAUCET_DECIMALS,
385                    max_supply: DEFAULT_NATIVE_FAUCET_MAX_SUPPLY,
386                    account_type: AccountTypeConfig::Public,
387                };
388                let (account, secret_key) = faucet_config.build_account()?;
389                Ok((account, symbol, Some(secret_key)))
390            },
391            Some(path) => {
392                let full_path = config_dir.join(&path);
393                let account_file = AccountFile::read(&full_path)
394                    .map_err(|e| GenesisConfigError::AccountFileRead(e, full_path.clone()))?;
395                let account = account_file.account;
396
397                let faucet = FungibleFaucet::try_from(&account).map_err(|_| {
398                    GenesisConfigError::NativeFaucetNotFungible { path: full_path.clone() }
399                })?;
400                let symbol = TokenSymbolStr::from(faucet.symbol().clone());
401                Ok((account, symbol, None))
402            },
403        }
404    }
405}
406
407// FUNGIBLE FAUCET CONFIG
408// ================================================================================================
409
410/// Represents a faucet with asset specific properties
411#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
412#[serde(deny_unknown_fields)]
413pub struct FungibleFaucetConfig {
414    symbol: TokenSymbolStr,
415    decimals: u8,
416    /// Max supply in full token units
417    ///
418    /// It will be converted internally to the smallest representable unit,
419    /// using based `10.powi(decimals)` as a multiplier.
420    max_supply: u64,
421    #[serde(default)]
422    account_type: AccountTypeConfig,
423}
424
425impl FungibleFaucetConfig {
426    /// Create a fungible faucet from a config entry
427    fn build_account(self) -> Result<(Account, RpoSecretKey), GenesisConfigError> {
428        let FungibleFaucetConfig {
429            symbol,
430            decimals,
431            max_supply,
432            account_type,
433        } = self;
434        let mut rng = ChaCha20Rng::from_seed(rand::random());
435        let secret_key = RpoSecretKey::with_rng(&mut get_random_coin(&mut rng));
436        let auth =
437            AuthSingleSig::new(secret_key.public_key().into(), AuthScheme::Falcon512Poseidon2);
438        let init_seed: [u8; 32] = rng.random();
439
440        let faucet = FungibleFaucet::builder()
441            .name(
442                TokenName::new(&symbol.to_string())
443                    .expect("token symbol fits within token name byte limit"),
444            )
445            .symbol(symbol.as_ref().clone())
446            .decimals(decimals)
447            .max_supply(AssetAmount::new(max_supply)?)
448            .build()?;
449
450        // It's similar to `fn create_basic_fungible_faucet`, but we need to cover more cases.
451        let faucet_account = AccountBuilder::new(init_seed)
452            .account_type(account_type.into())
453            .with_auth_component(auth)
454            .with_component(faucet)
455            .with_components(
456                TokenPolicyManager::new()
457                    .with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)?
458                    .with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)?,
459            )
460            .build()?;
461
462        debug_assert_eq!(faucet_account.nonce(), Felt::ZERO);
463
464        Ok((faucet_account, secret_key))
465    }
466}
467
468// WALLET CONFIG
469// ================================================================================================
470
471/// Represents a wallet, containing a set of assets
472#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct WalletConfig {
475    #[serde(default)]
476    account_type: AccountTypeConfig,
477    assets: Vec<AssetEntry>,
478}
479
480#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
481struct AssetEntry {
482    symbol: TokenSymbolStr,
483    /// The amount of full token units the given asset is populated with
484    amount: u64,
485}
486
487// ACCOUNT TYPE CONFIG
488// ================================================================================================
489
490/// See the [full description](https://0xmiden.github.io/miden-protocol/account.html?highlight=Accoun#account-storage-mode)
491/// for details
492#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Default)]
493pub enum AccountTypeConfig {
494    /// A publicly stored account, lives on-chain.
495    #[serde(alias = "public")]
496    Public,
497    /// A private account, which must be known by interactors.
498    #[serde(alias = "private")]
499    #[default]
500    Private,
501}
502
503impl From<AccountTypeConfig> for AccountType {
504    fn from(value: AccountTypeConfig) -> AccountType {
505        match value {
506            AccountTypeConfig::Public => AccountType::Public,
507            AccountTypeConfig::Private => AccountType::Private,
508        }
509    }
510}
511
512// ACCOUNTS
513// ================================================================================================
514
515#[derive(Debug, Clone)]
516pub struct AccountFileWithName {
517    pub name: String,
518    pub account_file: AccountFile,
519}
520
521/// Secrets generated during the state generation
522#[derive(Debug, Clone)]
523pub struct AccountSecrets {
524    // name, account, private key, account seed
525    pub secrets: Vec<(String, AccountId, RpoSecretKey)>,
526}
527
528impl AccountSecrets {
529    /// Convert the internal tuple into an `AccountFile`
530    ///
531    /// If no name is present, a new one is generated based on the current time
532    /// and the index in
533    pub fn as_account_files(
534        &self,
535        genesis_state: &GenesisState,
536    ) -> impl Iterator<Item = Result<AccountFileWithName, GenesisConfigError>> + '_ {
537        let account_lut = IndexMap::<AccountId, Account>::from_iter(
538            genesis_state.accounts.iter().map(|account| (account.id(), account.clone())),
539        );
540        self.secrets.iter().cloned().map(move |(name, account_id, secret_key)| {
541            let account = account_lut
542                .get(&account_id)
543                .ok_or(GenesisConfigError::MissingGenesisAccount { account_id })?;
544            let account_file = AccountFile::new(
545                account.clone(),
546                vec![AuthSecretKey::Falcon512Poseidon2(secret_key)],
547            );
548            Ok(AccountFileWithName { name, account_file })
549        })
550    }
551}
552
553// HELPERS
554// ================================================================================================
555
556/// Process wallet assets and return them as a fungible asset delta. Track the negative adjustments
557/// for the respective faucets.
558fn prepare_fungible_asset_update(
559    assets: impl IntoIterator<Item = AssetEntry>,
560    faucets: &IndexMap<TokenSymbolStr, Account>,
561    faucet_issuance: &mut IndexMap<AccountId, u64>,
562) -> Result<FungibleAssetDelta, GenesisConfigError> {
563    let assets =
564        Result::<Vec<_>, _>::from_iter(assets.into_iter().map(|AssetEntry { amount, symbol }| {
565            let faucet_account = faucets.get(&symbol).ok_or_else(|| {
566                GenesisConfigError::MissingFaucetDefinition { symbol: symbol.clone() }
567            })?;
568
569            Ok::<_, GenesisConfigError>(FungibleAsset::new(faucet_account.id(), amount)?)
570        }))?;
571
572    let mut wallet_asset_delta = FungibleAssetDelta::default();
573    assets
574        .into_iter()
575        .try_for_each(|fungible_asset| wallet_asset_delta.add(fungible_asset))?;
576
577    wallet_asset_delta.iter().try_for_each(|(vault_key, amount)| {
578        let faucet_id = vault_key.faucet_id();
579        let issuance: &mut u64 = faucet_issuance.entry(faucet_id).or_default();
580        tracing::debug!(
581            "Updating faucet issuance {faucet} with {issuance} += {amount}",
582            faucet = faucet_id.to_hex()
583        );
584
585        // check against total supply is deferred
586        issuance
587            .checked_add_assign(
588                &u64::try_from(*amount)
589                    .expect("Issuance must always be positive in the scope of genesis config"),
590            )
591            .map_err(|_| GenesisConfigError::IssuanceOverflow)?;
592
593        Ok::<_, GenesisConfigError>(())
594    })?;
595
596    Ok(wallet_asset_delta)
597}
598
599/// Wrapper type used for configuration representation.
600///
601/// Required since `Felt` does not implement `Hash` or `Eq`, but both are useful and necessary for a
602/// coherent model construction.
603#[derive(Debug, Clone, PartialEq)]
604pub struct TokenSymbolStr {
605    /// The raw representation, used for `Hash` and `Eq`.
606    raw: String,
607    /// Maintain the duality with the actual implementation.
608    encoded: TokenSymbol,
609}
610
611impl AsRef<TokenSymbol> for TokenSymbolStr {
612    fn as_ref(&self) -> &TokenSymbol {
613        &self.encoded
614    }
615}
616
617impl std::fmt::Display for TokenSymbolStr {
618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619        f.write_str(&self.raw)
620    }
621}
622
623impl FromStr for TokenSymbolStr {
624    // note: we re-use the error type
625    type Err = TokenSymbolError;
626    fn from_str(s: &str) -> Result<Self, Self::Err> {
627        Ok(Self {
628            encoded: TokenSymbol::new(s)?,
629            raw: s.to_string(),
630        })
631    }
632}
633
634impl Eq for TokenSymbolStr {}
635
636impl From<TokenSymbolStr> for TokenSymbol {
637    fn from(value: TokenSymbolStr) -> Self {
638        value.encoded
639    }
640}
641
642impl From<TokenSymbol> for TokenSymbolStr {
643    fn from(symbol: TokenSymbol) -> Self {
644        let raw = symbol.to_string();
645        Self { raw, encoded: symbol }
646    }
647}
648
649impl Ord for TokenSymbolStr {
650    fn cmp(&self, other: &Self) -> Ordering {
651        self.raw.cmp(&other.raw)
652    }
653}
654
655impl PartialOrd for TokenSymbolStr {
656    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
657        Some(self.cmp(other))
658    }
659}
660
661impl std::hash::Hash for TokenSymbolStr {
662    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
663        self.raw.hash::<H>(state);
664    }
665}
666
667impl Serialize for TokenSymbolStr {
668    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
669    where
670        S: serde::Serializer,
671    {
672        serializer.serialize_str(&self.raw)
673    }
674}
675
676impl<'de> Deserialize<'de> for TokenSymbolStr {
677    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
678    where
679        D: serde::Deserializer<'de>,
680    {
681        deserializer.deserialize_str(TokenSymbolVisitor)
682    }
683}
684
685use serde::de::Visitor;
686
687struct TokenSymbolVisitor;
688
689impl Visitor<'_> for TokenSymbolVisitor {
690    type Value = TokenSymbolStr;
691
692    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
693        formatter.write_str("1 to 6 uppercase ascii letters")
694    }
695
696    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
697    where
698        E: serde::de::Error,
699    {
700        let encoded = TokenSymbol::new(v).map_err(|e| E::custom(format!("{e}")))?;
701        let raw = v.to_string();
702        Ok(TokenSymbolStr { raw, encoded })
703    }
704}