-
Notifications
You must be signed in to change notification settings - Fork 56
feat(wasm-sdk): expose tiered token pricing in setPrice #3967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.1-dev
Are you sure you want to change the base?
Changes from 3 commits
f35de57
9276dbb
c348df2
8cb06a9
b9dd057
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, bigint>; | ||
|
|
||
| /** | ||
| * 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<string, bigint>` reliably. | ||
| #[derive(Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| struct TokenSetPriceOptionsInput { | ||
|
|
@@ -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))?; | ||
|
|
||
| tiers.insert(amount, credits); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| validate_price_tiers(&tiers)?; | ||
|
|
||
| Ok(Some(tiers)) | ||
| } | ||
|
Comment on lines
+2111
to
+2156
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: No wasm/JS-runtime test covers the new priceTiers extraction path
source: ['codex', 'claude']
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still present at |
||
|
|
||
| /// Result of setting the token price. | ||
| /// | ||
| /// The result type depends on token configuration: | ||
|
|
@@ -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) | ||
| }; | ||
|
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?; | ||
|
|
@@ -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"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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>, buttry_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-1will already be rounded by JS before Rust sees it, and Rust will then build aTokenPricingSchedule::SetPriceswith a silently-rounded price. Either gate this path with an explicitvalue.is_bigint()check so the TS contract is enforced at runtime, or relax the TS type tobigint | number | stringso it matches the implementation. Note this is the same lenient pattern used for the existing flatpricefield, so the team may decide to leave it as-is for consistency.source: ['codex']
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still present at
b9dd057.