Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions packages/js-evo-sdk/tests/unit/facades/tokens.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
194 changes: 187 additions & 7 deletions packages/wasm-sdk/src/state_transitions/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, bigint>;

/**
* Optional public note for the price change.
Expand Down Expand Up @@ -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<string, bigint>` reliably.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenSetPriceOptionsInput {
Expand All @@ -2005,6 +2027,86 @@ 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<TokenAmount, Credits>) -> 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(())
}

/// 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<Option<BTreeMap<TokenAmount, Credits>>, 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 tiers: BTreeMap<TokenAmount, Credits> = BTreeMap::new();

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))?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: priceTiers values are typed as bigint in TS but accepted as Number/String at runtime

The TypeScript declaration at line 1966 says priceTiers?: Record<string, bigint>, but try_to_u64 (wasm-dpp2/src/utils.rs:326) also accepts JS Number and numeric String. For plain JS callers (no TS type-checking), passing a Number > 2^53-1 will already be rounded by JS before Rust sees it, and Rust will then build a TokenPricingSchedule::SetPrices with a silently-rounded price. Either gate this path with an explicit value.is_bigint() check so the TS contract is enforced at runtime, or relax the TS type to bigint | number | string so it matches the implementation. Note this is the same lenient pattern used for the existing flat price field, so the team may decide to leave it as-is for consistency.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still present at b9dd057.


tiers.insert(amount, credits);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

validate_price_tiers(&tiers)?;

Ok(Some(tiers))
}
Comment on lines +2111 to +2156

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: No wasm/JS-runtime test covers the new priceTiers extraction path

extract_price_tiers is the only piece of code that exercises the hand-rolled Reflect::get + Object.entries + try_to_u64 path for Record<string, bigint>. The Rust unit tests only validate an already-constructed BTreeMap, and the js-evo-sdk facade spec only asserts stub forwarding. That leaves regressions in bigint→u64 conversion, the both-fields rejection, key parsing, and the SetPrices mapping uncovered by any test that actually crosses the JS/WASM boundary. Add a wasm-bindgen-test (or equivalent web-target test) that calls tokenSetPrice with a real JS object containing bigint tier values and asserts both the happy path and the price+priceTiers rejection.

source: ['codex', 'claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still present at b9dd057: current head adds pure-Rust helper tests, but still does not exercise the JS↔WASM priceTiers extraction path with a real JS object.


/// Result of setting the token price.
///
/// The result type depends on token configuration:
Expand Down Expand Up @@ -2103,11 +2205,36 @@ impl WasmSdk {
let authority_id: Identifier =
try_from_options::<IdentifierWasm>(&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);
// Reject ambiguous state: caller specified both flat price and tiers.
// We detect a present `price` key (including explicit `null` for "disable")
// via Reflect to distinguish "key absent" from "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);

if price_tiers.is_some() && price_field_present {
return Err(WasmSdkError::invalid_argument(
"Specify either 'price' (flat or null) or 'priceTiers' (tiered), not both",
));
}

// 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)
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Fetch and cache the data contract
let data_contract = self.get_or_fetch_contract(contract_id).await?;
Expand Down Expand Up @@ -2573,3 +2700,56 @@ 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<TokenAmount, Credits> = 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<TokenAmount, Credits> =
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<TokenAmount, Credits> = 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<TokenAmount, Credits> = 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<TokenAmount, Credits> =
BTreeMap::from([(1u64, 1_000u64), (100u64, 0u64)]);
validate_price_tiers(&tiers).expect("zero credits tier should validate");
}
}
Loading