diff --git a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts index 0b290cb29e5..3e776ffa403 100644 --- a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts @@ -8,6 +8,13 @@ describe('DocumentsFacade', () => { let document: wasmSDKPackage.Document; let identityKey: wasmSDKPackage.IdentityPublicKey; let signer: wasmSDKPackage.IdentitySigner; + const tokenPaymentInfo = { + paymentTokenContractId: 'BpJvvpPiR2obh7ueZixjtYXsmWQdgJhiZtQJWjD7Ruus', + tokenContractPosition: 0, + minimumTokenCost: BigInt(10), + maximumTokenCost: BigInt(25), + gasFeesPaidBy: 'PreferContractOwner', + }; // Stub references for type-safe assertions let getDocumentsStub: SinonStub; @@ -116,6 +123,7 @@ describe('DocumentsFacade', () => { document, identityKey, signer, + tokenPaymentInfo, }; await client.documents.create(options); @@ -130,6 +138,7 @@ describe('DocumentsFacade', () => { document, identityKey, signer, + tokenPaymentInfo, settings: { retries: 3 }, }; @@ -145,6 +154,7 @@ describe('DocumentsFacade', () => { document, identityKey, signer, + tokenPaymentInfo, }; await client.documents.delete(options); @@ -162,6 +172,7 @@ describe('DocumentsFacade', () => { }, identityKey, signer, + tokenPaymentInfo, }; await client.documents.delete(options); @@ -178,6 +189,7 @@ describe('DocumentsFacade', () => { recipientId, identityKey, signer, + tokenPaymentInfo, }; await client.documents.transfer(options); @@ -195,6 +207,7 @@ describe('DocumentsFacade', () => { price: BigInt(1000000), // 1M credits identityKey, signer, + tokenPaymentInfo, }; await client.documents.purchase(options); @@ -210,6 +223,7 @@ describe('DocumentsFacade', () => { price: BigInt(5000000), // 5M credits identityKey, signer, + tokenPaymentInfo, }; await client.documents.setPrice(options); diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index be6dc603a00..cba9c32e7e2 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -3,6 +3,7 @@ //! This module provides WASM bindings for document operations like create, replace, delete, etc. use crate::error::WasmSdkError; +use crate::queries::utils::deserialize_required_query; use crate::sdk::WasmSdk; use crate::settings::PutSettingsInput; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -11,11 +12,15 @@ use dash_sdk::dpp::document::{Document, DocumentV0Getters}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; +use dash_sdk::dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; +use dash_sdk::dpp::tokens::token_payment_info::{v0::TokenPaymentInfoV0, TokenPaymentInfo}; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::PutDocument; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; +use js_sys::Reflect; +use serde::Deserialize; use std::sync::Arc; use wasm_bindgen::prelude::*; use wasm_dpp2::data_contract::document::DocumentWasm; @@ -27,6 +32,97 @@ use wasm_dpp2::utils::{ }; use wasm_dpp2::IdentitySignerWasm; +#[wasm_bindgen(typescript_custom_section)] +const TOKEN_PAYMENT_INFO_TS: &str = r#" +/** + * Token-based payment metadata for document actions that require token cost agreement. + */ +export interface DocumentTokenPaymentInfo { + /** + * Optional external token contract ID. + * If omitted, the token is expected to come from the current document contract. + */ + paymentTokenContractId?: IdentifierLike; + + /** + * Token position within the token contract. + */ + tokenContractPosition: number; + + /** + * Optional minimum token amount the payer agrees to spend. + */ + minimumTokenCost?: bigint; + + /** + * Optional maximum token amount the payer agrees to spend. + */ + maximumTokenCost?: bigint; + + /** + * Which party covers gas fees for the document action. + */ + gasFeesPaidBy?: 'DocumentOwner' | 'ContractOwner' | 'PreferContractOwner'; +} +"#; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenPaymentInfoInput { + token_contract_position: u16, + minimum_token_cost: Option, + maximum_token_cost: Option, + gas_fees_paid_by: Option, +} + +fn try_from_options_optional_token_payment_info( + options: &JsValue, +) -> Result, WasmSdkError> { + let token_payment_info_value = Reflect::get(options, &JsValue::from_str("tokenPaymentInfo")) + .map_err(|err| { + WasmSdkError::invalid_argument(format!( + "Failed to read tokenPaymentInfo option: {:?}", + err + )) + })?; + + if token_payment_info_value.is_null() || token_payment_info_value.is_undefined() { + return Ok(None); + } + + let payment_token_contract_id = try_from_options_optional::( + &token_payment_info_value, + "paymentTokenContractId", + )? + .map(Into::into); + + let parsed: TokenPaymentInfoInput = deserialize_required_query( + token_payment_info_value, + "tokenPaymentInfo is required", + "token payment info", + )?; + + let gas_fees_paid_by = match parsed.gas_fees_paid_by.as_deref() { + None | Some("DocumentOwner") => GasFeesPaidBy::DocumentOwner, + Some("ContractOwner") => GasFeesPaidBy::ContractOwner, + Some("PreferContractOwner") => GasFeesPaidBy::PreferContractOwner, + Some(other) => { + return Err(WasmSdkError::invalid_argument(format!( + "Invalid tokenPaymentInfo.gasFeesPaidBy value '{}'", + other + ))) + } + }; + + Ok(Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id, + token_contract_position: parsed.token_contract_position, + minimum_token_cost: parsed.minimum_token_cost, + maximum_token_cost: parsed.maximum_token_cost, + gas_fees_paid_by, + }))) +} + // ============================================================================ // Document Create // ============================================================================ @@ -57,6 +153,11 @@ export interface DocumentCreateOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.create. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -126,6 +227,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use PutDocument trait for creation document @@ -134,7 +236,7 @@ impl WasmSdk { document_type, Some(entropy_array), identity_key, - None, // token_payment_info + token_payment_info, &signer, settings, ) @@ -174,6 +276,11 @@ export interface DocumentReplaceOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.replace. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -229,6 +336,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use PutDocument trait for replacement (revision > INITIAL_REVISION triggers replace) document @@ -237,7 +345,7 @@ impl WasmSdk { document_type, None, // entropy not needed for replace identity_key, - None, // token_payment_info + token_payment_info, &signer, settings, ) @@ -287,6 +395,11 @@ export interface DocumentDeleteOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.delete. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -368,6 +481,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build and execute delete transition using DocumentDeleteTransitionBuilder let builder = DocumentDeleteTransitionBuilder::new( @@ -377,6 +491,12 @@ impl WasmSdk { owner_id, ); + let builder = if let Some(token_payment_info) = token_payment_info { + builder.with_token_payment_info(token_payment_info) + } else { + builder + }; + let builder = if let Some(s) = settings { builder.with_settings(s) } else { @@ -425,6 +545,11 @@ export interface DocumentTransferOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.transfer. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -491,6 +616,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use TransferDocument trait document @@ -499,7 +625,7 @@ impl WasmSdk { self.inner_sdk(), document_type, identity_key, - None, // token_payment_info + token_payment_info, &signer, settings, ) @@ -549,6 +675,11 @@ export interface DocumentPurchaseOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.purchase. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -609,6 +740,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use PurchaseDocument trait document @@ -618,7 +750,7 @@ impl WasmSdk { document_type, buyer_id, identity_key, - None, // token_payment_info + token_payment_info, &signer, settings, ) @@ -663,6 +795,11 @@ export interface DocumentSetPriceOptions { */ signer: IdentitySigner; + /** + * Optional token payment agreement for document types with tokenCost.update_price. + */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -720,6 +857,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use UpdatePriceOfDocument trait document @@ -728,7 +866,7 @@ impl WasmSdk { self.inner_sdk(), document_type, identity_key, - None, // token_payment_info + token_payment_info, &signer, settings, ) diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index 02c0b72c496..98905eb7f4d 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -25,11 +25,64 @@ describe('Document State Transitions', function describeDocumentStateTransitions let client: sdk.WasmSdk; const testData = wasmFunctionalTestRequirements(); + const waitForPlatform = async (ms = 2000) => new Promise((resolve) => { setTimeout(resolve, ms); }); + const getSingleTokenBalance = async (identityId: string, tokenId: string) => { + const balances = await client.getIdentityTokenBalances(identityId, [tokenId]); + return balances.get(tokenId); + }; + const buildSimpleTokenConfiguration = (baseSupply: bigint, newTokensDestinationIdentity: string) => { + const contractOwner = sdk.AuthorizedActionTakers.ContractOwner(); + const contractOwnerChangeRules = new sdk.ChangeControlRules({ + authorizedToMakeChange: contractOwner, + adminActionTakers: contractOwner, + isChangingAuthorizedActionTakersToNoOneAllowed: true, + isChangingAdminActionTakersToNoOneAllowed: true, + isSelfChangingAdminActionTakersAllowed: true, + }); + + return new sdk.TokenConfiguration({ + conventions: new sdk.TokenConfigurationConvention({ + en: new sdk.TokenConfigurationLocalization(false, 'ticket', 'tickets'), + }, 0), + conventionsChangeRules: contractOwnerChangeRules, + baseSupply, + keepsHistory: new sdk.TokenKeepsHistoryRules({}), + maxSupplyChangeRules: contractOwnerChangeRules, + distributionRules: new sdk.TokenDistributionRules({ + perpetualDistributionRules: contractOwnerChangeRules, + newTokensDestinationIdentity, + newTokensDestinationIdentityRules: contractOwnerChangeRules, + mintingAllowChoosingDestination: false, + mintingAllowChoosingDestinationRules: contractOwnerChangeRules, + changeDirectPurchasePricingRules: contractOwnerChangeRules, + }), + marketplaceRules: new sdk.TokenMarketplaceRules( + sdk.TokenTradeMode.NotTradeable(), + contractOwnerChangeRules, + ), + manualMintingRules: contractOwnerChangeRules, + manualBurningRules: contractOwnerChangeRules, + freezeRules: contractOwnerChangeRules, + unfreezeRules: contractOwnerChangeRules, + destroyFrozenFundsRules: contractOwnerChangeRules, + emergencyActionRules: contractOwnerChangeRules, + mainControlGroupCanBeModified: sdk.AuthorizedActionTakers.NoOne(), + description: 'token-paid document flow test token', + }); + }; + const makeTokenPaymentInfo = (maximumTokenCost: bigint) => ({ + tokenContractPosition: 0, + maximumTokenCost, + gasFeesPaidBy: 'DocumentOwner', + }); // Store contract and document IDs for use across tests let testContractId = null; let createdDocumentId = null; let mutableDocumentId = null; + let tokenPaidContractId = null; + let tokenPaidDocumentId = null; + let tokenPaidTokenId = null; before(async () => { await init(); @@ -138,7 +191,7 @@ describe('Document State Transitions', function describeDocumentStateTransitions testContractId = publishedContract.id; // Wait for the contract to be indexed on platform - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + await waitForPlatform(); // Verify the contract is available const fetchedContract = await client.getDataContract(testContractId); @@ -172,7 +225,7 @@ describe('Document State Transitions', function describeDocumentStateTransitions expect(mutableDocumentId).to.exist(); // Wait for the document to be indexed on platform - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + await waitForPlatform(); // Now replace the document with updated content // Increment revision to 2 for the update @@ -219,7 +272,7 @@ describe('Document State Transitions', function describeDocumentStateTransitions expect(documentId).to.exist(); // Wait for the document to be indexed on platform - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + await waitForPlatform(); // Now delete the document using object format await client.documentDelete({ @@ -261,7 +314,7 @@ describe('Document State Transitions', function describeDocumentStateTransitions expect(documentId).to.exist(); // Wait for the document to be indexed on platform - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + await waitForPlatform(); // Create document object for transfer (revision incremented) const documentForTransfer = new sdk.Document({ @@ -282,4 +335,210 @@ describe('Document State Transitions', function describeDocumentStateTransitions }); }); }); + + describe('tokenPaymentInfo document flow', () => { + it('should publish a contract with document token costs and fund the seller and buyer', async () => { + const { signer: contractSigner, identityKey: contractIdentityKey } = createTestSignerAndKey(sdk, 1, 2); + const { signer: tokenSigner, identityKey: tokenIdentityKey } = createTestSignerAndKey(sdk, 1, 1); + + const schema = { + tokenPaidListing: { + type: 'object', + transferable: 1, + tradeMode: 1, + tokenCost: { + create: { tokenPosition: 0, amount: 5, gasFeesPaidBy: 0 }, + update_price: { tokenPosition: 0, amount: 2, gasFeesPaidBy: 0 }, + purchase: { tokenPosition: 0, amount: 3, gasFeesPaidBy: 0 }, + }, + properties: { + title: { + type: 'string', + maxLength: 100, + position: 0, + }, + }, + required: ['title'], + additionalProperties: false, + }, + }; + + const tokens = { + 0: buildSimpleTokenConfiguration(1000n, testData.identityId), + }; + + const dataContract = new sdk.DataContract({ + ownerId: testData.identityId, + identityNonce: 0n, + schemas: schema, + tokens, + fullValidation: true, + }); + + const publishedContract = await client.contractPublish({ + dataContract, + identityKey: contractIdentityKey, + signer: contractSigner, + }); + + tokenPaidContractId = publishedContract.id; + tokenPaidTokenId = sdk.WasmSdk.calculateTokenIdFromContract(tokenPaidContractId, 0); + + await waitForPlatform(); + + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(1000n); + + await client.tokenTransfer({ + dataContractId: tokenPaidContractId, + tokenPosition: 0, + senderId: testData.identityId, + recipientId: testData.identityId2, + amount: 50n, + identityKey: tokenIdentityKey, + signer: tokenSigner, + }); + + await client.tokenTransfer({ + dataContractId: tokenPaidContractId, + tokenPosition: 0, + senderId: testData.identityId, + recipientId: testData.identityId3, + amount: 50n, + identityKey: tokenIdentityKey, + signer: tokenSigner, + }); + + await waitForPlatform(); + + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(900n); + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(50n); + expect(await getSingleTokenBalance(testData.identityId3, tokenPaidTokenId)).to.equal(50n); + }); + + it('should reject create when tokenPaymentInfo is omitted', async () => { + expect(tokenPaidContractId).to.exist(); + + const { signer, identityKey } = createTestSignerAndKey(sdk, 2, 2); + const document = new sdk.Document({ + properties: { title: `Missing token payment ${Date.now()}` }, + documentTypeName: 'tokenPaidListing', + revision: 1, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + }); + + await expect(client.documentCreate({ + document, + identityKey, + signer, + })).to.be.rejectedWith('Required token payment info not set'); + }); + + it('should reject create when maximumTokenCost is below the required amount', async () => { + expect(tokenPaidContractId).to.exist(); + + const { signer, identityKey } = createTestSignerAndKey(sdk, 2, 2); + const document = new sdk.Document({ + properties: { title: `Low token cap ${Date.now()}` }, + documentTypeName: 'tokenPaidListing', + revision: 1, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + }); + + await expect(client.documentCreate({ + document, + identityKey, + signer, + tokenPaymentInfo: makeTokenPaymentInfo(4n), + })).to.be.rejectedWith('Identity has not agreed to pay the required token amount'); + }); + + it('should create, price, and purchase a document with tokenPaymentInfo', async () => { + expect(tokenPaidContractId).to.exist(); + expect(tokenPaidTokenId).to.exist(); + + const { signer: sellerDocSigner, identityKey: sellerDocKey } = createTestSignerAndKey(sdk, 2, 2); + const { signer: buyerDocSigner, identityKey: buyerDocKey } = createTestSignerAndKey(sdk, 3, 2); + + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(900n); + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(50n); + expect(await getSingleTokenBalance(testData.identityId3, tokenPaidTokenId)).to.equal(50n); + + const listingTitle = `Token paid listing ${Date.now()}`; + const document = new sdk.Document({ + properties: { title: listingTitle }, + documentTypeName: 'tokenPaidListing', + revision: 1, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + }); + + await client.documentCreate({ + document, + identityKey: sellerDocKey, + signer: sellerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(5n), + }); + + tokenPaidDocumentId = document.id; + expect(tokenPaidDocumentId).to.exist(); + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(45n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(905n); + + await waitForPlatform(); + + const documentForSale = new sdk.Document({ + properties: { title: listingTitle }, + documentTypeName: 'tokenPaidListing', + revision: 2, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + id: tokenPaidDocumentId, + }); + + await client.documentSetPrice({ + document: documentForSale, + price: 1_000_000n, + identityKey: sellerDocKey, + signer: sellerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(2n), + }); + + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(43n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(907n); + + await waitForPlatform(); + + const documentToPurchase = new sdk.Document({ + properties: { title: listingTitle }, + documentTypeName: 'tokenPaidListing', + revision: 3, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + id: tokenPaidDocumentId, + }); + + await client.documentPurchase({ + document: documentToPurchase, + buyerId: testData.identityId3, + price: 1_000_000n, + identityKey: buyerDocKey, + signer: buyerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(3n), + }); + + const purchasedDocument = await client.getDocument( + tokenPaidContractId, + 'tokenPaidListing', + tokenPaidDocumentId, + ); + + expect(purchasedDocument).to.exist(); + expect(purchasedDocument.ownerId.toString()).to.equal(testData.identityId3); + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(43n); + expect(await getSingleTokenBalance(testData.identityId3, tokenPaidTokenId)).to.equal(47n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(910n); + }); + }); });