diff --git a/packages/js-evo-sdk/package.json b/packages/js-evo-sdk/package.json index c6bb2645802..67febc2766f 100644 --- a/packages/js-evo-sdk/package.json +++ b/packages/js-evo-sdk/package.json @@ -28,7 +28,7 @@ "build": "rm -rf dist && tsc -p tsconfig.json && webpack --config webpack.config.cjs", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", "test": "yarn run test:unit", - "test:unit": "mocha --import ts-node/esm --require tests/bootstrap.cjs tests/unit/**/*.spec.ts && karma start ./tests/karma/karma.conf.cjs --single-run" + "test:unit": "TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=tests/tsconfig.json TS_NODE_FILES=true mocha --node-option loader=ts-node/esm --require tests/bootstrap.cjs tests/unit/**/*.spec.ts && karma start ./tests/karma/karma.conf.cjs --single-run" }, "devDependencies": { "@types/chai": "^4.3.11", diff --git a/packages/js-evo-sdk/tests/unit/facades/tokens.spec.ts b/packages/js-evo-sdk/tests/unit/facades/tokens.spec.ts index c4df9307558..7c184461dd1 100644 --- a/packages/js-evo-sdk/tests/unit/facades/tokens.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/tokens.spec.ts @@ -476,12 +476,26 @@ describe('TokensFacade', () => { }); describe('setPrice()', () => { - it('should set direct purchase price for tokens', async () => { + it('should set a flat direct purchase price for tokens', async () => { const options = { tokenId, - price: { - type: 'fixed', - value: BigInt(1000000), // 1M credits per token + price: BigInt(1000000), // 1M credits per token + identityKey, + signer, + }; + + const result = await client.tokens.setPrice(options); + + expect(tokenSetPriceStub).to.be.calledOnceWithExactly(options); + expect(result.tokenId).to.equal(tokenId); + }); + + it('should forward a tiered direct purchase price schedule unchanged', async () => { + const options = { + tokenId, + priceTiers: { + '100': BigInt(1000), + '1000': BigInt(900), }, identityKey, signer, diff --git a/packages/wasm-sdk/src/state_transitions/token.rs b/packages/wasm-sdk/src/state_transitions/token.rs index 59480bb863d..16328f91768 100644 --- a/packages/wasm-sdk/src/state_transitions/token.rs +++ b/packages/wasm-sdk/src/state_transitions/token.rs @@ -8,6 +8,7 @@ use crate::sdk::WasmSdk; use crate::settings::{get_user_fee_increase, PutSettingsInput}; use dash_sdk::dpp::balances::credits::TokenAmount; use dash_sdk::dpp::document::Document; +use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; use dash_sdk::platform::tokens::builders::{ @@ -27,6 +28,7 @@ use dash_sdk::platform::tokens::transitions::{ use dash_sdk::platform::transition::put_settings::PutSettings; use js_sys::BigInt; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::Arc; use wasm_bindgen::prelude::*; use wasm_dpp2::data_contract::document::DocumentWasm; @@ -35,7 +37,7 @@ use wasm_dpp2::identity::IdentityPublicKeyWasm; use wasm_dpp2::state_transitions::base::GroupStateTransitionInfoStatusWasm; use wasm_dpp2::state_transitions::batch::token_pricing_schedule::TokenPricingScheduleWasm; use wasm_dpp2::tokens::configuration_change_item::TokenConfigurationChangeItemWasm; -use wasm_dpp2::utils::{try_from_options, try_from_options_optional}; +use wasm_dpp2::utils::{try_from_options, try_from_options_optional, try_to_u64}; use wasm_dpp2::version::PlatformVersionLikeJs; use wasm_dpp2::IdentitySignerWasm; @@ -1942,10 +1944,26 @@ export interface TokenSetPriceOptions { authorityId: Identifier; /** - * The price in credits for one token. + * The flat price in credits for one token (SinglePrice schedule). * Set to null to disable direct purchases. + * Mutually exclusive with `priceTiers`. */ - price: bigint | null; + price?: bigint | null; + + /** + * Tiered direct-purchase pricing (SetPrices schedule). + * + * Maps the minimum bulk-buy amount (token amount, as a string key) + * to the per-token price in credits for that tier. Keys are unsigned + * integers encoded as strings; values are credit amounts as bigint. + * + * Example: `{ "1": 1_000n, "100": 900n, "1000": 800n }` charges 1000 + * credits/token for purchases of 1+, 900 for purchases of 100+, and + * 800 for purchases of 1000+. + * + * Mutually exclusive with `price`. Must contain at least one entry. + */ + priceTiers?: Record; /** * Optional public note for the price change. @@ -1985,6 +2003,10 @@ extern "C" { } /// Main input struct for token set price options (primitives only). +/// +/// Tiered pricing (`priceTiers`) is extracted separately via `extract_price_tiers` +/// because serde-wasm-bindgen does not deserialize JS bigints inside a generic +/// `Record` reliably. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenSetPriceOptionsInput { @@ -2005,6 +2027,134 @@ fn deserialize_token_set_price_options( ) } +/// Validate a constructed `priceTiers` map. +/// +/// Rejects: +/// - empty maps (caller must specify at least one tier) +/// - a `0` minimum bulk-buy amount (use the flat `price` field for that) +/// +/// A `0` credits value is permitted — it mirrors the lower/consensus +/// `SetPrices` schedule, which allows zero-credit tiers for free +/// direct purchases at that bulk amount. +/// +/// Pure function so it can be unit-tested without a JS runtime. +fn validate_price_tiers(tiers: &BTreeMap) -> Result<(), WasmSdkError> { + if tiers.is_empty() { + return Err(WasmSdkError::invalid_argument( + "'priceTiers' must contain at least one entry", + )); + } + for amount in tiers.keys() { + if *amount == 0 { + return Err(WasmSdkError::invalid_argument( + "'priceTiers' minimum bulk-buy amount must be > 0; use 'price' for a flat single-token price", + )); + } + } + Ok(()) +} + +/// Build a `priceTiers` map from already-parsed `(originalKey, amount, credits)` entries. +/// +/// Detects keys that parse to the same `TokenAmount` (e.g. `"1"` and `"01"`) and rejects +/// them with a message naming both the original and duplicate string keys, instead of +/// silently overwriting one with the other via `BTreeMap::insert`. +/// +/// Pure function so it can be unit-tested without a JS runtime. +fn build_price_tiers( + entries: Vec<(String, TokenAmount, Credits)>, +) -> Result, WasmSdkError> { + let mut tiers: BTreeMap = BTreeMap::new(); + let mut original_keys: BTreeMap = BTreeMap::new(); + for (key_str, amount, credits) in entries { + if let Some(prev_key) = original_keys.get(&amount) { + return Err(WasmSdkError::invalid_argument(format!( + "Duplicate priceTiers tier amount {}: keys '{}' and '{}' both parse to the same token amount", + amount, prev_key, key_str + ))); + } + original_keys.insert(amount, key_str); + tiers.insert(amount, credits); + } + validate_price_tiers(&tiers)?; + Ok(tiers) +} + +/// Decide which pricing mode the caller specified for `tokenSetPrice`. +/// +/// Exactly one of `price` (flat, including explicit `null` for disable) or +/// `priceTiers` (tiered) must be provided. Both omitted is rejected so that +/// a missing field is not silently treated like an explicit `price: null`. +/// Both present is rejected as ambiguous. +/// +/// Pure function so it can be unit-tested without a JS runtime. +fn validate_pricing_mode_selection( + price_present: bool, + price_tiers_present: bool, +) -> Result<(), WasmSdkError> { + match (price_present, price_tiers_present) { + (true, true) => Err(WasmSdkError::invalid_argument( + "Specify either 'price' (flat or null) or 'priceTiers' (tiered), not both", + )), + (false, false) => Err(WasmSdkError::invalid_argument( + "Must specify exactly one of 'price' (flat or null to disable) or 'priceTiers' (tiered)", + )), + _ => Ok(()), + } +} + +/// Extract the optional `priceTiers` field from the raw JS options object. +/// +/// Returns `Ok(None)` when the field is absent, null, or undefined. +/// Returns `Err` when the field is present but malformed (wrong type, +/// empty, non-numeric keys, non-bigint/integer values, zero amount key, etc.). +fn extract_price_tiers( + options: &JsValue, +) -> Result>, WasmSdkError> { + let value = js_sys::Reflect::get(options, &JsValue::from_str("priceTiers")) + .map_err(|_| WasmSdkError::invalid_argument("failed to read 'priceTiers' from options"))?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + if !value.is_object() || js_sys::Array::is_array(&value) { + return Err(WasmSdkError::invalid_argument( + "'priceTiers' must be an object mapping token amount keys to bigint credit prices", + )); + } + + let obj = js_sys::Object::from(value); + let entries = js_sys::Object::entries(&obj); + let mut parsed_entries: Vec<(String, TokenAmount, Credits)> = + Vec::with_capacity(entries.length() as usize); + + for entry in entries.iter() { + let entry_arr = js_sys::Array::from(&entry); + let key = entry_arr.get(0); + let val = entry_arr.get(1); + + let key_str = key.as_string().ok_or_else(|| { + WasmSdkError::invalid_argument( + "'priceTiers' keys must be strings encoding token amounts", + ) + })?; + + let amount: TokenAmount = key_str.parse().map_err(|err| { + WasmSdkError::invalid_argument(format!( + "Invalid token amount key '{}' in priceTiers: {}", + key_str, err + )) + })?; + + let credits = try_to_u64(&val, &format!("priceTiers['{}']", key_str))?; + + parsed_entries.push((key_str, amount, credits)); + } + + Ok(Some(build_price_tiers(parsed_entries)?)) +} + /// Result of setting the token price. /// /// The result type depends on token configuration: @@ -2103,11 +2253,34 @@ impl WasmSdk { let authority_id: Identifier = try_from_options::(&options, "authorityId")?.into(); - // Deserialize primitive fields last (consumes options) - let parsed = deserialize_token_set_price_options(options.into())?; + // Extract optional tiered pricing map before consuming options. + let raw_options_js: JsValue = options.into(); + let price_tiers = extract_price_tiers(&raw_options_js)?; - // Convert price to pricing schedule - let pricing_schedule = parsed.price.map(TokenPricingSchedule::SinglePrice); + // Determine which pricing mode the caller selected. We must distinguish + // "field absent" from "field explicitly null" so that `price: null` can + // explicitly disable direct purchases while `{}` is rejected as ambiguous + // rather than silently treated like `price: null`. + let price_field_present = + js_sys::Reflect::get(&raw_options_js, &JsValue::from_str("price")) + .map(|v| !v.is_undefined()) + .unwrap_or(false); + let price_tiers_field_present = price_tiers.is_some(); + + validate_pricing_mode_selection(price_field_present, price_tiers_field_present)?; + + // Deserialize primitive fields last (consumes options) + let parsed = deserialize_token_set_price_options(raw_options_js)?; + + // Build the pricing schedule: + // - priceTiers present → SetPrices + // - price = Some(n) → SinglePrice(n) + // - price = None / absent → no schedule set (disables direct purchases) + let pricing_schedule = if let Some(tiers) = price_tiers { + Some(TokenPricingSchedule::SetPrices(tiers)) + } else { + parsed.price.map(TokenPricingSchedule::SinglePrice) + }; // Fetch and cache the data contract let data_contract = self.get_or_fetch_contract(contract_id).await?; @@ -2573,3 +2746,157 @@ impl WasmSdk { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A single non-zero tier passes validation. + #[test] + fn validate_price_tiers_accepts_single_tier() { + let tiers: BTreeMap = BTreeMap::from([(1u64, 1_000u64)]); + validate_price_tiers(&tiers).expect("single non-zero tier should validate"); + } + + /// Multiple non-zero tiers pass validation. + #[test] + fn validate_price_tiers_accepts_multiple_tiers() { + let tiers: BTreeMap = + BTreeMap::from([(1u64, 1_000u64), (100u64, 900u64), (1000u64, 800u64)]); + validate_price_tiers(&tiers).expect("multi-tier schedule should validate"); + } + + /// An empty tier map is rejected — the caller must specify at least one tier. + #[test] + fn validate_price_tiers_rejects_empty() { + let tiers: BTreeMap = BTreeMap::new(); + let err = validate_price_tiers(&tiers).expect_err("empty tiers should be rejected"); + assert!( + err.message().contains("at least one entry"), + "unexpected error message: {}", + err.message() + ); + } + + /// A `0` minimum bulk-buy amount is rejected — direct callers should use `price`. + #[test] + fn validate_price_tiers_rejects_zero_amount_key() { + let tiers: BTreeMap = BTreeMap::from([(0u64, 1_000u64)]); + let err = validate_price_tiers(&tiers).expect_err("zero amount key should be rejected"); + assert!( + err.message().contains("amount must be > 0"), + "unexpected error message: {}", + err.message() + ); + } + + /// A `0` per-token price is accepted — mirrors lower/consensus `SetPrices`, + /// which permits zero-credit tiers for free direct purchases. + #[test] + fn validate_price_tiers_accepts_zero_credits() { + let tiers: BTreeMap = + BTreeMap::from([(1u64, 1_000u64), (100u64, 0u64)]); + validate_price_tiers(&tiers).expect("zero credits tier should validate"); + } + + /// Distinct token amount keys build a tier map preserving all entries. + #[test] + fn build_price_tiers_accepts_distinct_keys() { + let entries = vec![ + ("1".to_string(), 1u64, 1_000u64), + ("100".to_string(), 100u64, 800u64), + ]; + let tiers = build_price_tiers(entries).expect("distinct keys should build successfully"); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers.get(&1u64), Some(&1_000u64)); + assert_eq!(tiers.get(&100u64), Some(&800u64)); + } + + /// Two string keys that parse to the same `TokenAmount` (e.g. "1" and "01") + /// are rejected instead of silently overwriting one entry with the other. + #[test] + fn build_price_tiers_rejects_duplicate_parsed_keys() { + let entries = vec![ + ("1".to_string(), 1u64, 1_000u64), + ("01".to_string(), 1u64, 2_000u64), + ]; + let err = build_price_tiers(entries) + .expect_err("duplicate parsed tier amount should be rejected"); + let msg = err.message(); + assert!( + msg.contains("Duplicate"), + "unexpected error message: {}", + msg + ); + assert!( + msg.contains("'1'"), + "error should mention original key: {}", + msg + ); + assert!( + msg.contains("'01'"), + "error should mention duplicate key: {}", + msg + ); + } + + /// Even with three keys colliding, the error names the first-seen and the + /// next colliding key so the user can locate the offending pair. + #[test] + fn build_price_tiers_reports_first_collision() { + let entries = vec![ + ("10".to_string(), 10u64, 1u64), + ("010".to_string(), 10u64, 2u64), + ("0010".to_string(), 10u64, 3u64), + ]; + let err = build_price_tiers(entries).expect_err("collision should be rejected"); + let msg = err.message(); + assert!( + msg.contains("'10'"), + "error should mention first key: {}", + msg + ); + assert!( + msg.contains("'010'"), + "error should mention second key: {}", + msg + ); + } + + /// Empty selection (neither `price` nor `priceTiers`) is rejected so that a + /// missing field is not silently treated like an explicit `price: null`. + #[test] + fn validate_pricing_mode_selection_rejects_neither() { + let err = validate_pricing_mode_selection(false, false) + .expect_err("neither field present should be rejected"); + assert!( + err.message().contains("exactly one"), + "unexpected error message: {}", + err.message() + ); + } + + /// Specifying both `price` and `priceTiers` is rejected as ambiguous. + #[test] + fn validate_pricing_mode_selection_rejects_both() { + let err = validate_pricing_mode_selection(true, true) + .expect_err("both fields present should be rejected"); + assert!( + err.message().contains("not both"), + "unexpected error message: {}", + err.message() + ); + } + + /// Only `price` present (including `price: null` for disable) is accepted. + #[test] + fn validate_pricing_mode_selection_accepts_price_only() { + validate_pricing_mode_selection(true, false).expect("price-only selection should validate"); + } + + /// Only `priceTiers` present is accepted. + #[test] + fn validate_pricing_mode_selection_accepts_tiers_only() { + validate_pricing_mode_selection(false, true).expect("tiers-only selection should validate"); + } +}