From 4c3291468c0fa8a3141377448d0e57be38d1331a Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 11 Aug 2026 16:51:56 -0400 Subject: [PATCH 1/2] feat: add document transfer, set-price, and purchase tutorials Add three marketplace tutorials covering the general document trading APIs, demonstrated against the DPNS `domain` document selected by `NAME_LABEL`. The same calls work with any document type whose contract enables `transferable` or `tradeMode`. --- .env.example | 12 ++- .../document-purchase.mjs | 55 ++++++++++ .../document-set-price.mjs | 52 +++++++++ .../document-transfer.mjs | 55 ++++++++++ README.md | 15 +-- test/read-write.test.mjs | 100 ++++++++++++++++++ 6 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 2-Contracts-and-Documents/document-purchase.mjs create mode 100644 2-Contracts-and-Documents/document-set-price.mjs create mode 100644 2-Contracts-and-Documents/document-transfer.mjs diff --git a/.env.example b/.env.example index da5b4468..d82cb680 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ NETWORK='testnet' # Used by document and contract-update tutorials DATA_CONTRACT_ID='' -# RECIPIENT_ID is an identity ID for credit transfer tutorials +# RECIPIENT_ID is an identity ID for the credit transfer tutorial RECIPIENT_ID='' # Token transfer tutorial variables @@ -32,6 +32,16 @@ DISABLE_KEY_ID='' # NAME_LABEL is an optional username label for name registration (without .dash) NAME_LABEL='' +# Optional second funded identity used by document purchase and transfer tests +SECONDARY_PLATFORM_MNEMONIC='your mnemonic phrase goes here ...' + +# DOCUMENT_RECIPIENT_ID is the new owner identity ID for document-transfer.mjs +DOCUMENT_RECIPIENT_ID='' + +# DOCUMENT_PRICE is the listing price in credits for document-set-price.mjs +# Set it to 0 to remove the document from sale +DOCUMENT_PRICE=100000000 + # NFT Variables # NFT_CONTRACT_ID comes from contract-register-nft.mjs output NFT_CONTRACT_ID='' diff --git a/2-Contracts-and-Documents/document-purchase.mjs b/2-Contracts-and-Documents/document-purchase.mjs new file mode 100644 index 00000000..27ce3213 --- /dev/null +++ b/2-Contracts-and-Documents/document-purchase.mjs @@ -0,0 +1,55 @@ +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// Purchase works with any document type whose contract enables `tradeMode`. +// Here the purchased document is the `domain` behind a DPNS name. +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const NAME_LABEL = process.env.NAME_LABEL || 'alice'; + +try { + // Fetch immediately before purchase so the revision, owner, and price are + // current. Platform rejects the purchase if the listing changes meanwhile. + const documents = await sdk.documents.query({ + dataContractId: DPNS_CONTRACT_ID, + documentTypeName: 'domain', + where: [ + ['normalizedParentDomainName', '==', 'dash'], + ['normalizedLabel', '==', NAME_LABEL.toLowerCase()], + ], + }); + const document = [...documents.values()][0]; + + if (!document) { + throw new Error(`Name "${NAME_LABEL}.dash" was not found`); + } + + // Buying your own listing is rejected by the platform + if (document.ownerId.toString() === identity.id.toString()) { + throw new Error(`"${NAME_LABEL}.dash" is already owned by ${identity.id}`); + } + + // Read the native bigint directly. toJSON() cannot safely represent every + // possible unsigned 64-bit credit value. + const price = document.properties?.['$price']; + if (typeof price !== 'bigint' || price <= 0n) { + throw new Error(`Name "${NAME_LABEL}.dash" is not currently for sale`); + } + + document.revision = BigInt(document.revision ?? 0) + 1n; + + await sdk.documents.purchase({ + document, + buyerId: identity.id, + price, + identityKey, + signer, + }); + + console.log( + `Document for "${NAME_LABEL}.dash" purchased for ${price} credits.`, + ); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/2-Contracts-and-Documents/document-set-price.mjs b/2-Contracts-and-Documents/document-set-price.mjs new file mode 100644 index 00000000..8a77a285 --- /dev/null +++ b/2-Contracts-and-Documents/document-set-price.mjs @@ -0,0 +1,52 @@ +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// Pricing works with any document type whose contract enables `tradeMode`. +// This tutorial lists the `domain` document behind a DPNS name. +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const NAME_LABEL = process.env.NAME_LABEL || 'alice'; + +// Price in credits. A price of 0 removes the document from sale. +const PRICE = BigInt(process.env.DOCUMENT_PRICE || 100_000_000); + +try { + const documents = await sdk.documents.query({ + dataContractId: DPNS_CONTRACT_ID, + documentTypeName: 'domain', + where: [ + ['normalizedParentDomainName', '==', 'dash'], + ['normalizedLabel', '==', NAME_LABEL.toLowerCase()], + ], + }); + const document = [...documents.values()][0]; + + if (!document) { + throw new Error(`Name "${NAME_LABEL}.dash" was not found`); + } + + // Only the current owner can change a document's sale price + if (document.ownerId.toString() !== identity.id.toString()) { + throw new Error( + `"${NAME_LABEL}.dash" is owned by ${document.ownerId}, not ${identity.id}`, + ); + } + + document.revision = BigInt(document.revision ?? 0) + 1n; + + await sdk.documents.setPrice({ + document, + price: PRICE, + identityKey, + signer, + }); + + console.log( + PRICE === 0n + ? `Document for "${NAME_LABEL}.dash" removed from sale.` + : `Document for "${NAME_LABEL}.dash" listed for ${PRICE} credits.`, + ); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/2-Contracts-and-Documents/document-transfer.mjs b/2-Contracts-and-Documents/document-transfer.mjs new file mode 100644 index 00000000..adf987f2 --- /dev/null +++ b/2-Contracts-and-Documents/document-transfer.mjs @@ -0,0 +1,55 @@ +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// Transfer works with any document type whose contract enables `transferable`. +// This tutorial uses a DPNS name because names are familiar transferable documents. +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const NAME_LABEL = process.env.NAME_LABEL || 'alice'; +const DOCUMENT_RECIPIENT_ID = + process.env.DOCUMENT_RECIPIENT_ID || 'YOUR_DOCUMENT_RECIPIENT_ID'; + +try { + // Check configuration before spending a network round-trip on the query + if (DOCUMENT_RECIPIENT_ID === 'YOUR_DOCUMENT_RECIPIENT_ID') { + throw new Error('Set DOCUMENT_RECIPIENT_ID to the new owner identity ID'); + } + + const documents = await sdk.documents.query({ + dataContractId: DPNS_CONTRACT_ID, + documentTypeName: 'domain', + where: [ + ['normalizedParentDomainName', '==', 'dash'], + ['normalizedLabel', '==', NAME_LABEL.toLowerCase()], + ], + }); + const document = [...documents.values()][0]; + + if (!document) { + throw new Error(`Name "${NAME_LABEL}.dash" was not found`); + } + + // Only the current owner can transfer a document + if (document.ownerId.toString() !== identity.id.toString()) { + throw new Error( + `"${NAME_LABEL}.dash" is owned by ${document.ownerId}, not ${identity.id}`, + ); + } + + document.revision = BigInt(document.revision ?? 0) + 1n; + + // A successful transfer also clears any active sale price + await sdk.documents.transfer({ + document, + recipientId: DOCUMENT_RECIPIENT_ID, + identityKey, + signer, + }); + + console.log( + `Document for "${NAME_LABEL}.dash" transferred to ${DOCUMENT_RECIPIENT_ID}.`, + ); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/README.md b/README.md index 09c91aee..b005ccc1 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,15 @@ Proceed with the [Identities and Names tutorials](./1-Identities-and-Names/) fir [Tokens tutorials](./3-Tokens/) after that. They align with the tutorials section on the [documentation site](https://docs.dash.org/projects/platform/en/stable/docs/tutorials/introduction.html). -The identity ID is automatically resolved from your mnemonic, so there is no need to set it -manually. After [registering a data -contract](./2-Contracts-and-Documents/contract-register-minimal.mjs), set `DATA_CONTRACT_ID` in your -`.env` file to the new contract ID for use in subsequent document tutorials. -For token tutorials, run -[`token-register.mjs`](./3-Tokens/token-register.mjs), then set +- The identity ID is automatically resolved from your mnemonic, so there is no need to set it +manually. +- After [registering a data contract](./2-Contracts-and-Documents/contract-register-minimal.mjs), +set `DATA_CONTRACT_ID` in your `.env` file to the new contract ID for use in subsequent document +tutorials. +- The document marketplace tutorials use the DPNS `domain` document behind `NAME_LABEL` to +demonstrate the general transfer, set-price, and purchase APIs. These APIs also work with other +document types whose contracts enable `transferable` or `tradeMode` as appropriate. +- For token tutorials, run [`token-register.mjs`](./3-Tokens/token-register.mjs), then set `TOKEN_CONTRACT_ID` in `.env` to the newly registered contract ID. The token tutorials then follow the normal lifecycle: info, mint, transfer, and burn. diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index b04f3c23..e38cd52d 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -1,5 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import dotenv from 'dotenv'; import { runTutorial } from './run-tutorial.mjs'; import { assertTutorialSuccess, @@ -8,8 +9,11 @@ import { extractKeyId, } from './assertions.mjs'; +dotenv.config(); + // Accumulated state passed forward as env vars to dependent tutorials. const state = {}; +const secondaryMnemonic = process.env.SECONDARY_PLATFORM_MNEMONIC; describe('Write tutorials (sequential)', { concurrency: 1 }, () => { // ----------------------------------------------------------------------- @@ -55,6 +59,13 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { expectedPatterns: ['Identity retrieved:'], errorPatterns: ['Something went wrong'], }); + + const id = extractId(result.stdout); + assert.ok( + id, + `Failed to extract identity ID from stdout:\n${result.stdout}`, + ); + state.primaryIdentityId = id; }); it('identity-topup', { timeout: 120_000 }, async () => { @@ -144,6 +155,95 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { expectedPatterns: ['Name registered:'], errorPatterns: ['Something went wrong', 'already registered'], }); + state.nameLabel = label; + }); + + it('document-set-price (DPNS domain)', { timeout: 120_000 }, async (ctx) => { + if (!state.nameLabel) { + ctx.skip('No NAME_LABEL (name-register must pass first)'); + return; + } + + const result = await runTutorial( + '2-Contracts-and-Documents/document-set-price.mjs', + { + env: { NAME_LABEL: state.nameLabel }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'document-set-price', + expectedPatterns: ['listed for [1-9][0-9]* credits'], + errorPatterns: ['Something went wrong'], + }); + + const listedPrice = extractFromOutput( + result.stdout, + /listed for ([1-9][0-9]*) credits/, + ); + assert.ok( + listedPrice, + `Failed to extract listed document price from stdout:\n${result.stdout}`, + ); + state.documentSalePrice = listedPrice; + }); + + it('document-purchase (DPNS domain)', { timeout: 120_000 }, async (ctx) => { + if (!secondaryMnemonic) { + ctx.skip('SECONDARY_PLATFORM_MNEMONIC is not configured'); + return; + } + if (!state.nameLabel || !state.documentSalePrice) { + ctx.skip('Name registration or set-price did not complete'); + return; + } + + const result = await runTutorial( + '2-Contracts-and-Documents/document-purchase.mjs', + { + env: { + PLATFORM_MNEMONIC: secondaryMnemonic, + NAME_LABEL: state.nameLabel, + }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'document-purchase', + expectedPatterns: [`purchased for ${state.documentSalePrice} credits`], + errorPatterns: ['Something went wrong'], + }); + state.secondaryOwnsName = true; + }); + + it('document-transfer (DPNS domain)', { timeout: 120_000 }, async (ctx) => { + if (!secondaryMnemonic) { + ctx.skip('SECONDARY_PLATFORM_MNEMONIC is not configured'); + return; + } + if (!state.secondaryOwnsName || !state.primaryIdentityId) { + ctx.skip( + 'Purchase did not complete or primary identity ID is unavailable', + ); + return; + } + + const result = await runTutorial( + '2-Contracts-and-Documents/document-transfer.mjs', + { + env: { + PLATFORM_MNEMONIC: secondaryMnemonic, + NAME_LABEL: state.nameLabel, + DOCUMENT_RECIPIENT_ID: state.primaryIdentityId, + }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'document-transfer', + expectedPatterns: ['transferred to'], + errorPatterns: ['Something went wrong'], + }); }); // ----------------------------------------------------------------------- From 93b5e5c5c401b2ae63b4c785ae189c6f9561a0ca Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 11 Aug 2026 17:01:29 -0400 Subject: [PATCH 2/2] test: pin DOCUMENT_PRICE for the document-set-price tutorial run Pin the price in the subprocess env so the suite no longer depends on local `.env` contents. Co-Authored-By: Claude Opus 5 (1M context) --- test/read-write.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index e38cd52d..895459db 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -167,7 +167,9 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { const result = await runTutorial( '2-Contracts-and-Documents/document-set-price.mjs', { - env: { NAME_LABEL: state.nameLabel }, + // Pin the price so a DOCUMENT_PRICE of 0 in the developer's .env + // cannot delist the name the purchase and transfer tests depend on. + env: { NAME_LABEL: state.nameLabel, DOCUMENT_PRICE: '100000000' }, timeoutMs: 120_000, }, );