diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs index 432adc7b6cb..a8ed4f6d5a8 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs @@ -159,7 +159,9 @@ pub enum DistributionFunction { /// /// # Example /// - Emit 100 tokens per block for the first 1,000 blocks, then 50 tokens per block thereafter. - Stepwise(BTreeMap), + Stepwise( + #[serde(deserialize_with = "deserialize_u64_token_amount_map")] BTreeMap, + ), /// Emits tokens following a linear function that can increase or decrease over time /// with fractional precision. @@ -558,6 +560,45 @@ pub enum DistributionFunction { }, } +// Custom deserializer helper that accepts both key shapes for JSON and other serde formats: +// - BTreeMap // numeric timestamp/step keys +// - BTreeMap // numeric-looking strings as keys +// The function normalizes both into `BTreeMap`. If a string key +// cannot be parsed as `u64`, deserialization fails with a serde error. +use serde::de::Deserializer; +fn deserialize_u64_token_amount_map<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + // Untagged enum tries variants in order: attempt numeric keys first, + // then fallback to string keys. + #[derive(Deserialize)] + #[serde(untagged)] + enum U64OrStrMap { + // JSON: { 0: 100, 10: 50 } + U64(BTreeMap), + // JSON: { "0": 100, "10": 50 } + Str(BTreeMap), + } + + let helper: U64OrStrMap = U64OrStrMap::deserialize(deserializer)?; + match helper { + // Already numeric keys; return as-is + U64OrStrMap::U64(m) => Ok(m), + // Parse numeric-looking string keys into u64, preserving values + U64OrStrMap::Str(sm) => sm + .into_iter() + .map(|(k, v)| { + k.parse::() + .map_err(serde::de::Error::custom) + .map(|kk| (kk, v)) + }) + .collect(), + } +} + impl fmt::Display for DistributionFunction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/v0/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/v0/mod.rs index b70a4a20ee6..427b9928a53 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/v0/mod.rs @@ -10,6 +10,7 @@ use std::fmt; #[derive(Serialize, Deserialize, Decode, Encode, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TokenPreProgrammedDistributionV0 { + #[serde(deserialize_with = "deserialize_ts_to_id_amount_map")] pub distributions: BTreeMap>, } @@ -26,3 +27,46 @@ impl fmt::Display for TokenPreProgrammedDistributionV0 { write!(f, "}}") } } + +use serde::de::Deserializer; + +// Custom deserializer for `distributions` that tolerates two JSON shapes: +// - a map keyed by timestamp millis as numbers (u64) +// - a map keyed by timestamp millis as strings (e.g. "1735689600000") +// It normalizes both into `BTreeMap` where V is the inner value map +// (`BTreeMap` here). If a string key cannot be parsed +// as `u64`, deserialization fails with a serde error. Using `BTreeMap` keeps +// keys ordered by timestamp. +fn deserialize_ts_to_id_amount_map<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // Untagged enum attempts the variants in order: first try a map with u64 + // keys; if that doesn't match, try a map with string keys. + #[derive(Deserialize)] + #[serde(untagged)] + enum U64OrStrTs { + // JSON: { 1735689600000: { : , ... }, ... } + U64(BTreeMap), + // JSON: { "1735689600000": { : , ... }, ... } + Str(BTreeMap), + } + + let helper: U64OrStrTs> = + U64OrStrTs::deserialize(deserializer)?; + match helper { + // Already has numeric timestamp keys; return as-is + U64OrStrTs::U64(m) => Ok(m), + // Convert string timestamp keys into u64, preserving values unchanged + U64OrStrTs::Str(sm) => sm + .into_iter() + .map(|(k, v)| { + k.parse::() + .map_err(serde::de::Error::custom) + .map(|ts| (ts, v)) + }) + .collect(), + } +} diff --git a/packages/rs-dpp/src/data_contract/conversion/json/mod.rs b/packages/rs-dpp/src/data_contract/conversion/json/mod.rs index 2e4147653cc..8a85936b023 100644 --- a/packages/rs-dpp/src/data_contract/conversion/json/mod.rs +++ b/packages/rs-dpp/src/data_contract/conversion/json/mod.rs @@ -52,3 +52,296 @@ impl DataContractJsonConversionMethodsV0 for DataContract { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::conversion::json::DataContractJsonConversionMethodsV0; + use crate::prelude::DataContract; + use crate::version::PlatformVersion; + use serde_json::json; + + #[test] + fn from_json_accepts_stepwise_with_string_keys() { + let platform_version = PlatformVersion::latest(); + + let owner = "HtQNfXBZJu3WnvjvCFJKgbvfgWYJxWxaFWy23TKoFjg9"; + let id = "BmKTJeLL3GfH8FxEx7SUbTog4eAKj8vJRDi97gYkxB9p"; + + let contract = json!({ + "$format_version": "1", + "id": id, + "ownerId": owner, + "version": 1, + "config": { + "$format_version": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "documentSchemas": {}, + "tokens": { + "0": { + "$format_version": "0", + "conventions": { "$format_version": "0", "decimals": 2, "localizations": {} }, + "distributionRules": { + "$format_version": "0", + "perpetualDistribution": { + "$format_version": "0", + "distributionType": { + "BlockBasedDistribution": { + "interval": 10, + "function": { + "Stepwise": { "0": 100, "10": 50 } + } + } + }, + "distributionRecipient": "ContractOwner" + }, + "perpetualDistributionRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "preProgrammedDistribution": null, + "preProgrammedDistributionRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "newTokensDestinationIdentity": null, + "newTokensDestinationIdentityRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "mintingAllowChoosingDestination": false, + "mintingAllowChoosingDestinationRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "changeDirectPurchasePricingRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }} + }, + "marketplaceRules": {"$format_version": "0", "tradeMode": "NotTradeable"}, + "manualMintingRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "manualBurningRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "freezeRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "unfreezeRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "destroyFrozenFundsRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "emergencyActionRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "directPurchaseRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "mainControlGroup": null, + "mainControlGroupCanBeModified": "NoOne" + } + } + }); + + let result = DataContract::from_json(contract, true, &platform_version); + assert!( + result.is_ok(), + "Stepwise with string keys should be accepted by from_json" + ); + } + + #[test] + fn from_json_accepts_preprogrammed_with_string_timestamp_keys() { + let platform_version = PlatformVersion::latest(); + + let owner = "HtQNfXBZJu3WnvjvCFJKgbvfgWYJxWxaFWy23TKoFjg9"; + let id = "BmKTJeLL3GfH8FxEx7SUbTog4eAKj8vJRDi97gYkxB9p"; + + let contract = json!({ + "$format_version": "1", + "id": id, + "ownerId": owner, + "version": 1, + "config": { + "$format_version": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "documentSchemas": {}, + "tokens": { + "0": { + "$format_version": "0", + "conventions": { "$format_version": "0", "decimals": 2, "localizations": {} }, + "distributionRules": { + "$format_version": "0", + "perpetualDistribution": null, + "perpetualDistributionRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "preProgrammedDistribution": { + "$format_version": "0", + "distributions": { + "1735689600000": { + "HtQNfXBZJu3WnvjvCFJKgbvfgWYJxWxaFWy23TKoFjg9": 1000 + } + } + }, + "preProgrammedDistributionRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "newTokensDestinationIdentity": null, + "newTokensDestinationIdentityRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "mintingAllowChoosingDestination": false, + "mintingAllowChoosingDestinationRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "changeDirectPurchasePricingRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }} + }, + "marketplaceRules": {"$format_version": "0", "tradeMode": "NotTradeable"}, + "manualMintingRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "manualBurningRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "freezeRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "unfreezeRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "destroyFrozenFundsRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "emergencyActionRules": {"V0": { + "authorized_to_make_change": "ContractOwner", + "admin_action_takers": "ContractOwner", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "directPurchaseRules": {"V0": { + "authorized_to_make_change": "NoOne", + "admin_action_takers": "NoOne", + "changing_authorized_action_takers_to_no_one_allowed": false, + "changing_admin_action_takers_to_no_one_allowed": false, + "self_changing_admin_action_takers_allowed": false + }}, + "mainControlGroup": null, + "mainControlGroupCanBeModified": "NoOne" + } + } + }); + + let result = DataContract::from_json(contract, true, &platform_version); + assert!( + result.is_ok(), + "PreProgrammed with string timestamp keys should be accepted by from_json" + ); + } +}