Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion packages/js-evo-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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