From a4e90baf6c7312b1ba12f5b5142a020cb49e70ed Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 10 Jun 2026 10:56:14 -0400 Subject: [PATCH 1/5] feat: add token contract registration and transfer tutorials Add contract-register-token.mjs (fixed-supply token contract) and identity-transfer-tokens.mjs, with TOKEN_CONTRACT_ID env var, README guidance, and read-write test coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 + .../identity-transfer-tokens.mjs | 60 ++++++++ .../contract-register-token.mjs | 137 ++++++++++++++++++ README.md | 3 + test/read-write.test.mjs | 41 ++++++ 5 files changed, 245 insertions(+) create mode 100644 1-Identities-and-Names/identity-transfer-tokens.mjs create mode 100644 2-Contracts-and-Documents/contract-register-token.mjs diff --git a/.env.example b/.env.example index e49c633..1ded915 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ DATA_CONTRACT_ID='' # RECIPIENT_ID is an identity ID for credit transfer tutorials RECIPIENT_ID='' +# Token transfer tutorial variables +# TOKEN_CONTRACT_ID comes from contract-register-token.mjs +TOKEN_CONTRACT_ID='' + # RECIPIENT_PLATFORM_ADDRESS is a bech32m platform address (tdash1...) for send-funds tutorial RECIPIENT_PLATFORM_ADDRESS='' diff --git a/1-Identities-and-Names/identity-transfer-tokens.mjs b/1-Identities-and-Names/identity-transfer-tokens.mjs new file mode 100644 index 0000000..1c50c43 --- /dev/null +++ b/1-Identities-and-Names/identity-transfer-tokens.mjs @@ -0,0 +1,60 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/identities-and-names/transfer-tokens-to-an-identity.html +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getTransfer(); + +// TOKEN_CONTRACT_ID comes from contract-register-token.mjs. +const dataContractId = process.env.TOKEN_CONTRACT_ID; +const tokenPosition = 0; + +// Default recipient (testnet). Replace or override via RECIPIENT_ID. +const recipientId = + process.env.RECIPIENT_ID || '7XcruVSsGQVSgTcmPewaE4tXLutnW1F6PXxwMbo8GYQC'; +const amount = 1n; + +try { + if (!dataContractId) { + throw new Error( + 'Set TOKEN_CONTRACT_ID in .env from contract-register-token.mjs output.', + ); + } + + const senderId = identity.id.toString(); + if (recipientId === senderId) { + throw new Error('Cannot transfer tokens to yourself.'); + } + + const tokenId = await sdk.tokens.calculateId(dataContractId, tokenPosition); + const balancesBefore = await sdk.tokens.identityBalances(recipientId, [ + tokenId, + ]); + + console.log( + `Recipient token balance before transfer: ${balancesBefore.get(tokenId) ?? 0n}`, + ); + + await sdk.tokens.transfer({ + dataContractId, + tokenPosition, + amount, + senderId, + recipientId, + identityKey, + signer, + }); + + const balancesAfter = await sdk.tokens.identityBalances(recipientId, [ + tokenId, + ]); + + console.log( + `Transferred ${amount} token${amount === 1n ? '' : 's'} from ${senderId} to ${recipientId}`, + ); + console.log('Token ID:', tokenId); + console.log( + `Recipient token balance after transfer: ${balancesAfter.get(tokenId) ?? 0n}`, + ); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/2-Contracts-and-Documents/contract-register-token.mjs b/2-Contracts-and-Documents/contract-register-token.mjs new file mode 100644 index 0000000..1e0fd0a --- /dev/null +++ b/2-Contracts-and-Documents/contract-register-token.mjs @@ -0,0 +1,137 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/register-a-token-contract.html +import { + AuthorizedActionTakers, + ChangeControlRules, + DataContract, + TokenConfiguration, + TokenConfigurationConvention, + TokenConfigurationLocalization, + TokenDistributionRules, + TokenKeepsHistoryRules, + TokenMarketplaceRules, + TokenTradeMode, +} from '@dashevo/evo-sdk'; +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +const TOKEN_POSITION = 0; +const TOKEN_NAME = 'TutorialToken'; +const TOKEN_PLURAL = 'TutorialTokens'; +const TOKEN_BASE_SUPPLY = 1000n; // Token amounts are bigint values + +// This contract includes one small document type so learners can still use the +// standard document tutorials with the same contract if they want to. +const documentSchemas = { + note: { + type: 'object', + properties: { + message: { + type: 'string', + position: 0, + }, + }, + additionalProperties: false, + }, +}; + +function createTutorialTokenConfiguration(ownerId, tokenBaseSupply) { + const contractOwner = AuthorizedActionTakers.ContractOwner(); + const noOne = AuthorizedActionTakers.NoOne(); + + const ownerRules = new ChangeControlRules({ + authorizedToMakeChange: contractOwner, + adminActionTakers: contractOwner, + isChangingAuthorizedActionTakersToNoOneAllowed: true, + isChangingAdminActionTakersToNoOneAllowed: true, + isSelfChangingAdminActionTakersAllowed: true, + }); + const lockedRules = new ChangeControlRules({ + authorizedToMakeChange: noOne, + adminActionTakers: noOne, + }); + + return new TokenConfiguration({ + conventions: new TokenConfigurationConvention( + { + en: new TokenConfigurationLocalization( + false, + TOKEN_NAME, + TOKEN_PLURAL, + ), + }, + 0, + ), + conventionsChangeRules: ownerRules, + baseSupply: tokenBaseSupply, + maxSupply: tokenBaseSupply, + keepsHistory: new TokenKeepsHistoryRules({ + isKeepingBurningHistory: true, + isKeepingTransferHistory: true, + }), + maxSupplyChangeRules: lockedRules, + distributionRules: new TokenDistributionRules({ + newTokensDestinationIdentity: ownerId, + newTokensDestinationIdentityRules: ownerRules, + mintingAllowChoosingDestination: false, + mintingAllowChoosingDestinationRules: ownerRules, + perpetualDistributionRules: lockedRules, + changeDirectPurchasePricingRules: lockedRules, + }), + marketplaceRules: new TokenMarketplaceRules( + TokenTradeMode.NotTradeable(), + lockedRules, + ), + manualMintingRules: lockedRules, + manualBurningRules: lockedRules, + freezeRules: lockedRules, + unfreezeRules: lockedRules, + destroyFrozenFundsRules: lockedRules, + emergencyActionRules: lockedRules, + mainControlGroupCanBeModified: noOne, + description: 'Fixed-supply token for Platform token tutorials.', + }); +} + +try { + const identityNonce = await sdk.identities.nonce(identity.id.toString()); + + const dataContract = new DataContract({ + ownerId: identity.id, + identityNonce: (identityNonce || 0n) + 1n, + schemas: documentSchemas, + tokens: { + [TOKEN_POSITION]: createTutorialTokenConfiguration( + identity.id.toString(), + TOKEN_BASE_SUPPLY, + ), + }, + fullValidation: true, + }); + + const publishedContract = await sdk.contracts.publish({ + dataContract, + identityKey, + signer, + }); + + const contractId = + publishedContract.id?.toString() || publishedContract.toJSON?.()?.id; + + if (!contractId) { + const publishResult = publishedContract.toJSON?.() ?? publishedContract; + throw new Error( + `Contract publish returned no id: ${JSON.stringify(publishResult)}`, + ); + } + + const tokenId = await sdk.tokens.calculateId(contractId, TOKEN_POSITION); + + console.log('Token contract registered:\n', publishedContract.toJSON()); + console.log('Token position:', TOKEN_POSITION); + console.log('Token ID:', tokenId); + console.log('Initial owner token balance:', TOKEN_BASE_SUPPLY.toString()); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/README.md b/README.md index fe70f3a..f40404c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,9 @@ The identity ID is automatically resolved from your mnemonic, so there is no nee 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 +[`contract-register-token.mjs`](./2-Contracts-and-Documents/contract-register-token.mjs), then set +`TOKEN_CONTRACT_ID` in `.env` to the newly registered contract ID. Some client configuration options are included as comments in [`setupDashClient.mjs`](./setupDashClient.mjs) if more advanced configuration is required. diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index 9069570..4df2945 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -234,6 +234,47 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { }); }); + it('contract-register-token', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-token.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-token', + expectedPatterns: ['Token contract registered:', 'Token ID:'], + errorPatterns: ['Something went wrong'], + }); + + const id = extractId(result.stdout); + assert.ok( + id, + `Failed to extract token contract ID from stdout:\n${result.stdout}`, + ); + state.tokenContractId = id; + }); + + it('identity-transfer-tokens', { timeout: 120_000 }, async (ctx) => { + if (!state.tokenContractId) { + ctx.skip('No TOKEN_CONTRACT_ID (contract-register-token must pass first)'); + return; + } + + const result = await runTutorial( + '1-Identities-and-Names/identity-transfer-tokens.mjs', + { + env: { + TOKEN_CONTRACT_ID: state.tokenContractId, + }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'identity-transfer-tokens', + expectedPatterns: ['Recipient token balance after transfer:'], + errorPatterns: ['Something went wrong'], + }); + }); + it('contract-update-minimal', { timeout: 120_000 }, async (ctx) => { if (!state.dataContractId) { ctx.skip( From c6f431069e0e6406d12b5892b5f8e49199b81183 Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 10 Jun 2026 11:03:41 -0400 Subject: [PATCH 2/5] refactor: move token tutorials into 3-Tokens directory Relocate token-register.mjs and token-transfer.mjs into a dedicated 3-Tokens directory, update doc URLs and the .env.example reference, and group the tests under a Phase 4: Tokens block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 2 +- .../token-register.mjs | 2 +- .../token-transfer.mjs | 6 +- README.md | 8 +- test/read-write.test.mjs | 82 +++++++++---------- 5 files changed, 50 insertions(+), 50 deletions(-) rename 2-Contracts-and-Documents/contract-register-token.mjs => 3-Tokens/token-register.mjs (98%) rename 1-Identities-and-Names/identity-transfer-tokens.mjs => 3-Tokens/token-transfer.mjs (88%) diff --git a/.env.example b/.env.example index 1ded915..da5b446 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ DATA_CONTRACT_ID='' RECIPIENT_ID='' # Token transfer tutorial variables -# TOKEN_CONTRACT_ID comes from contract-register-token.mjs +# TOKEN_CONTRACT_ID comes from token-register.mjs TOKEN_CONTRACT_ID='' # RECIPIENT_PLATFORM_ADDRESS is a bech32m platform address (tdash1...) for send-funds tutorial diff --git a/2-Contracts-and-Documents/contract-register-token.mjs b/3-Tokens/token-register.mjs similarity index 98% rename from 2-Contracts-and-Documents/contract-register-token.mjs rename to 3-Tokens/token-register.mjs index 1e0fd0a..7418473 100644 --- a/2-Contracts-and-Documents/contract-register-token.mjs +++ b/3-Tokens/token-register.mjs @@ -1,4 +1,4 @@ -// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/register-a-token-contract.html +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/register-a-token-contract.html import { AuthorizedActionTakers, ChangeControlRules, diff --git a/1-Identities-and-Names/identity-transfer-tokens.mjs b/3-Tokens/token-transfer.mjs similarity index 88% rename from 1-Identities-and-Names/identity-transfer-tokens.mjs rename to 3-Tokens/token-transfer.mjs index 1c50c43..343a06b 100644 --- a/1-Identities-and-Names/identity-transfer-tokens.mjs +++ b/3-Tokens/token-transfer.mjs @@ -1,10 +1,10 @@ -// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/identities-and-names/transfer-tokens-to-an-identity.html +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/transfer-tokens-to-an-identity.html import { setupDashClient } from '../setupDashClient.mjs'; const { sdk, keyManager } = await setupDashClient(); const { identity, identityKey, signer } = await keyManager.getTransfer(); -// TOKEN_CONTRACT_ID comes from contract-register-token.mjs. +// TOKEN_CONTRACT_ID comes from token-register.mjs. const dataContractId = process.env.TOKEN_CONTRACT_ID; const tokenPosition = 0; @@ -16,7 +16,7 @@ const amount = 1n; try { if (!dataContractId) { throw new Error( - 'Set TOKEN_CONTRACT_ID in .env from contract-register-token.mjs output.', + 'Set TOKEN_CONTRACT_ID in .env from token-register.mjs output.', ); } diff --git a/README.md b/README.md index f40404c..4af3a42 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ and identity are found, and proceed with [Next Steps](#next-steps). ### Next steps -Proceed with the [Identities and Names tutorials](./1-Identities-and-Names/) first and the -[Contracts and Documents tutorials](./2-Contracts-and-Documents/) next. They align with the -tutorials section on the [documentation +Proceed with the [Identities and Names tutorials](./1-Identities-and-Names/) first, the +[Contracts and Documents tutorials](./2-Contracts-and-Documents/) next, and the +[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 @@ -86,7 +86,7 @@ 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 -[`contract-register-token.mjs`](./2-Contracts-and-Documents/contract-register-token.mjs), then set +[`token-register.mjs`](./3-Tokens/token-register.mjs), then set `TOKEN_CONTRACT_ID` in `.env` to the newly registered contract ID. Some client configuration options are included as comments in diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index 4df2945..ce01f76 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -234,47 +234,6 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { }); }); - it('contract-register-token', { timeout: 180_000 }, async () => { - const result = await runTutorial( - '2-Contracts-and-Documents/contract-register-token.mjs', - { timeoutMs: 180_000 }, - ); - assertTutorialSuccess(result, { - name: 'contract-register-token', - expectedPatterns: ['Token contract registered:', 'Token ID:'], - errorPatterns: ['Something went wrong'], - }); - - const id = extractId(result.stdout); - assert.ok( - id, - `Failed to extract token contract ID from stdout:\n${result.stdout}`, - ); - state.tokenContractId = id; - }); - - it('identity-transfer-tokens', { timeout: 120_000 }, async (ctx) => { - if (!state.tokenContractId) { - ctx.skip('No TOKEN_CONTRACT_ID (contract-register-token must pass first)'); - return; - } - - const result = await runTutorial( - '1-Identities-and-Names/identity-transfer-tokens.mjs', - { - env: { - TOKEN_CONTRACT_ID: state.tokenContractId, - }, - timeoutMs: 120_000, - }, - ); - assertTutorialSuccess(result, { - name: 'identity-transfer-tokens', - expectedPatterns: ['Recipient token balance after transfer:'], - errorPatterns: ['Something went wrong'], - }); - }); - it('contract-update-minimal', { timeout: 120_000 }, async (ctx) => { if (!state.dataContractId) { ctx.skip( @@ -396,4 +355,45 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { errorPatterns: ['Something went wrong'], }); }); + + // ----------------------------------------------------------------------- + // Phase 4: Tokens + // ----------------------------------------------------------------------- + + it('token-register', { timeout: 180_000 }, async () => { + const result = await runTutorial('3-Tokens/token-register.mjs', { + timeoutMs: 180_000, + }); + assertTutorialSuccess(result, { + name: 'token-register', + expectedPatterns: ['Token contract registered:', 'Token ID:'], + errorPatterns: ['Something went wrong'], + }); + + const id = extractId(result.stdout); + assert.ok( + id, + `Failed to extract token contract ID from stdout:\n${result.stdout}`, + ); + state.tokenContractId = id; + }); + + it('token-transfer', { timeout: 120_000 }, async (ctx) => { + if (!state.tokenContractId) { + ctx.skip('No TOKEN_CONTRACT_ID (token-register must pass first)'); + return; + } + + const result = await runTutorial('3-Tokens/token-transfer.mjs', { + env: { + TOKEN_CONTRACT_ID: state.tokenContractId, + }, + timeoutMs: 120_000, + }); + assertTutorialSuccess(result, { + name: 'token-transfer', + expectedPatterns: ['Recipient token balance after transfer:'], + errorPatterns: ['Something went wrong'], + }); + }); }); From a9a170b7d8d5ffde5dbb99b119f2202b830610bf Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 10 Jun 2026 12:05:42 -0400 Subject: [PATCH 3/5] feat(3-Tokens): add mint, burn, and info tutorials with issuer-managed token Rework token-register from a fixed-supply token into an issuer-managed one with manual minting and burning enabled and a separate base/max supply, so the new mint, burn, and info tutorials can demonstrate the normal token lifecycle. Add token type declarations to setupDashClient-core.d.mts, read-write tests covering info/mint/burn, and update the README and CLAUDE.md to document the token tutorials and TOKEN_CONTRACT_ID. Co-Authored-By: Claude Opus 4.8 (1M context) --- 3-Tokens/token-burn.mjs | 39 ++++++++++++++++++++ 3-Tokens/token-info.mjs | 50 ++++++++++++++++++++++++++ 3-Tokens/token-mint.mjs | 39 ++++++++++++++++++++ 3-Tokens/token-register.mjs | 26 +++++++------- CLAUDE.md | 2 ++ README.md | 3 +- setupDashClient-core.d.mts | 19 ++++++++++ test/read-write.test.mjs | 72 +++++++++++++++++++++++++++++++++++++ 8 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 3-Tokens/token-burn.mjs create mode 100644 3-Tokens/token-info.mjs create mode 100644 3-Tokens/token-mint.mjs diff --git a/3-Tokens/token-burn.mjs b/3-Tokens/token-burn.mjs new file mode 100644 index 0000000..de91994 --- /dev/null +++ b/3-Tokens/token-burn.mjs @@ -0,0 +1,39 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/burn-tokens.html +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// TOKEN_CONTRACT_ID comes from token-register.mjs. +const dataContractId = process.env.TOKEN_CONTRACT_ID; +const tokenPosition = 0; +const amount = 1n; // Token amounts are bigint values + +try { + if (!dataContractId) { + throw new Error( + 'Set TOKEN_CONTRACT_ID in .env from token-register.mjs output.', + ); + } + + const tokenId = await sdk.tokens.calculateId(dataContractId, tokenPosition); + + await sdk.tokens.burn({ + dataContractId, + tokenPosition, + amount, + identityId: identity.id.toString(), + identityKey, + signer, + }); + + const balances = await sdk.tokens.identityBalances(identity.id, [tokenId]); + const totalSupply = await sdk.tokens.totalSupply(tokenId); + + console.log(`Burned ${amount} token`); + console.log('Token ID:', tokenId); + console.log(`Identity token balance: ${balances.get(tokenId) ?? 0n}`); + console.log('Total token supply:', totalSupply?.totalSupply ?? 0n); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/3-Tokens/token-info.mjs b/3-Tokens/token-info.mjs new file mode 100644 index 0000000..02224b2 --- /dev/null +++ b/3-Tokens/token-info.mjs @@ -0,0 +1,50 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/retrieve-token-info.html +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); + +// TOKEN_CONTRACT_ID comes from token-register.mjs. +const dataContractId = process.env.TOKEN_CONTRACT_ID; +const tokenPosition = 0; + +// Default recipient (testnet). Replace or override via RECIPIENT_ID. +const recipientId = + process.env.RECIPIENT_ID || '7XcruVSsGQVSgTcmPewaE4tXLutnW1F6PXxwMbo8GYQC'; + +try { + if (!dataContractId) { + throw new Error( + 'Set TOKEN_CONTRACT_ID in .env from token-register.mjs output.', + ); + } + + const tokenId = await sdk.tokens.calculateId(dataContractId, tokenPosition); + const contractInfo = await sdk.tokens.contractInfo(tokenId); + const totalSupply = await sdk.tokens.totalSupply(tokenId); + const statuses = await sdk.tokens.statuses([tokenId]); + const identity = await sdk.identities.fetch(keyManager.identityId); + const identityBalances = await sdk.tokens.identityBalances(identity.id, [ + tokenId, + ]); + const recipientBalances = await sdk.tokens.identityBalances(recipientId, [ + tokenId, + ]); + + // A token only has a status record once one is published on-chain (e.g. via + // an emergency pause), so the Map is empty for a freshly registered token. + const status = statuses.get(tokenId); + + console.log('Token ID:', tokenId); + console.log('Token contract info:\n', contractInfo?.toJSON()); + console.log( + 'Token status:', + status ? status.isPaused : '(no status published)', + ); + console.log('Total token supply:', totalSupply?.totalSupply ?? 0n); + console.log(`Identity token balance: ${identityBalances.get(tokenId) ?? 0n}`); + console.log( + `Recipient token balance: ${recipientBalances.get(tokenId) ?? 0n}`, + ); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/3-Tokens/token-mint.mjs b/3-Tokens/token-mint.mjs new file mode 100644 index 0000000..430b2ae --- /dev/null +++ b/3-Tokens/token-mint.mjs @@ -0,0 +1,39 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/mint-tokens.html +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// TOKEN_CONTRACT_ID comes from token-register.mjs. +const dataContractId = process.env.TOKEN_CONTRACT_ID; +const tokenPosition = 0; +const amount = 10n; // Token amounts are bigint values + +try { + if (!dataContractId) { + throw new Error( + 'Set TOKEN_CONTRACT_ID in .env from token-register.mjs output.', + ); + } + + const tokenId = await sdk.tokens.calculateId(dataContractId, tokenPosition); + + await sdk.tokens.mint({ + dataContractId, + tokenPosition, + amount, + identityId: identity.id.toString(), + identityKey, + signer, + }); + + const balances = await sdk.tokens.identityBalances(identity.id, [tokenId]); + const totalSupply = await sdk.tokens.totalSupply(tokenId); + + console.log(`Minted ${amount} tokens`); + console.log('Token ID:', tokenId); + console.log(`Identity token balance: ${balances.get(tokenId) ?? 0n}`); + console.log('Total token supply:', totalSupply?.totalSupply ?? 0n); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/3-Tokens/token-register.mjs b/3-Tokens/token-register.mjs index 7418473..336a397 100644 --- a/3-Tokens/token-register.mjs +++ b/3-Tokens/token-register.mjs @@ -19,7 +19,8 @@ const { identity, identityKey, signer } = await keyManager.getAuth(); const TOKEN_POSITION = 0; const TOKEN_NAME = 'TutorialToken'; const TOKEN_PLURAL = 'TutorialTokens'; -const TOKEN_BASE_SUPPLY = 1000n; // Token amounts are bigint values +const TOKEN_BASE_SUPPLY = 100n; // Token amounts are bigint values +const TOKEN_MAX_SUPPLY = 1000n; // This contract includes one small document type so learners can still use the // standard document tutorials with the same contract if they want to. @@ -36,7 +37,7 @@ const documentSchemas = { }, }; -function createTutorialTokenConfiguration(ownerId, tokenBaseSupply) { +function createTutorialTokenConfiguration(ownerId) { const contractOwner = AuthorizedActionTakers.ContractOwner(); const noOne = AuthorizedActionTakers.NoOne(); @@ -55,19 +56,16 @@ function createTutorialTokenConfiguration(ownerId, tokenBaseSupply) { return new TokenConfiguration({ conventions: new TokenConfigurationConvention( { - en: new TokenConfigurationLocalization( - false, - TOKEN_NAME, - TOKEN_PLURAL, - ), + en: new TokenConfigurationLocalization(false, TOKEN_NAME, TOKEN_PLURAL), }, 0, ), conventionsChangeRules: ownerRules, - baseSupply: tokenBaseSupply, - maxSupply: tokenBaseSupply, + baseSupply: TOKEN_BASE_SUPPLY, + maxSupply: TOKEN_MAX_SUPPLY, keepsHistory: new TokenKeepsHistoryRules({ isKeepingBurningHistory: true, + isKeepingMintingHistory: true, isKeepingTransferHistory: true, }), maxSupplyChangeRules: lockedRules, @@ -83,14 +81,16 @@ function createTutorialTokenConfiguration(ownerId, tokenBaseSupply) { TokenTradeMode.NotTradeable(), lockedRules, ), - manualMintingRules: lockedRules, - manualBurningRules: lockedRules, + // Minting and burning are enabled so the next tutorials can demonstrate + // the normal issuer-managed token lifecycle. + manualMintingRules: ownerRules, + manualBurningRules: ownerRules, freezeRules: lockedRules, unfreezeRules: lockedRules, destroyFrozenFundsRules: lockedRules, emergencyActionRules: lockedRules, mainControlGroupCanBeModified: noOne, - description: 'Fixed-supply token for Platform token tutorials.', + description: 'Issuer-managed token for Platform token tutorials.', }); } @@ -104,7 +104,6 @@ try { tokens: { [TOKEN_POSITION]: createTutorialTokenConfiguration( identity.id.toString(), - TOKEN_BASE_SUPPLY, ), }, fullValidation: true, @@ -132,6 +131,7 @@ try { console.log('Token position:', TOKEN_POSITION); console.log('Token ID:', tokenId); console.log('Initial owner token balance:', TOKEN_BASE_SUPPLY.toString()); + console.log('Maximum token supply:', TOKEN_MAX_SUPPLY.toString()); } catch (e) { console.error('Something went wrong:\n', e.message); } diff --git a/CLAUDE.md b/CLAUDE.md index f5dd0c1..2cc75d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,7 @@ Copy `.env.example` to `.env`. Key variables: | `NETWORK` | `testnet` (default) or `mainnet` | | `DATA_CONTRACT_ID` | Output of `contract-register-minimal.mjs` | | `DOCUMENT_ID` | Output of `document-submit.mjs` | +| `TOKEN_CONTRACT_ID` | Output of `token-register.mjs`; required by the other `3-Tokens/` tutorials | | `RECIPIENT_ID` | Identity ID for credit transfers | | `RECIPIENT_PLATFORM_ADDRESS` | `tdash1...` address for send-funds | @@ -119,6 +120,7 @@ Read-only tests skip gracefully when `PLATFORM_MNEMONIC` is unset. - **Root** — shared utilities (`setupDashClient.mjs`, `connect.mjs`, `create-wallet.mjs`, `view-wallet.mjs`, `send-funds.mjs`) - **`1-Identities-and-Names/`** — identity registration, top-up, key management, DPNS name registration/lookup - **`2-Contracts-and-Documents/`** — data contract variants (minimal, indexed, binary, timestamps, history, NFT), document CRUD, NFT operations +- **`3-Tokens/`** — token contract registration, info queries, minting, burning, and transfers - **`test/`** — test runner, assertions, read-only and read-write test suites - **`docs/`** — HTML/JS interactive tutorial runner (separate from Node tutorials) - **`example-apps/`** — Standalone applications (Vite + React + TypeScript) that consume the tutorial SDK code. Each has its own `package.json`, tsconfig, and toolchain — the conventions in this file (Node16 modules, `airbnb-base`, etc.) describe the **root** tutorial code only and do not apply inside `example-apps/`. See each app's local `CLAUDE.md` for its conventions. diff --git a/README.md b/README.md index 4af3a42..9e2d359 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ contract](./2-Contracts-and-Documents/contract-register-minimal.mjs), set `DATA_ `.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 -`TOKEN_CONTRACT_ID` in `.env` to the newly registered contract ID. +`TOKEN_CONTRACT_ID` in `.env` to the newly registered contract ID. The token tutorials then follow +the normal lifecycle: info, mint, transfer, and burn. Some client configuration options are included as comments in [`setupDashClient.mjs`](./setupDashClient.mjs) if more advanced configuration is required. diff --git a/setupDashClient-core.d.mts b/setupDashClient-core.d.mts index 3fd6bc4..9f238b4 100644 --- a/setupDashClient-core.d.mts +++ b/setupDashClient-core.d.mts @@ -105,6 +105,25 @@ interface ConnectedDashClientLike { totalSupply( tokenId: string, ): Promise<{ totalSupply: bigint; tokenId: string } | undefined>; + statuses(tokenIds: string[]): Promise>; + contractInfo(contractId: string): Promise; + mint(args: { + dataContractId: string; + tokenPosition: number; + amount: bigint; + identityId: string; + recipientId?: string; + identityKey: IdentityPublicKey | undefined; + signer: IdentitySigner; + }): Promise; + burn(args: { + dataContractId: string; + tokenPosition: number; + amount: bigint; + identityId: string; + identityKey: IdentityPublicKey | undefined; + signer: IdentitySigner; + }): Promise; transfer(args: { dataContractId: string; tokenPosition: number; diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index ce01f76..32a9526 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -378,6 +378,59 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { state.tokenContractId = id; }); + it('token-info', { timeout: 120_000 }, async (ctx) => { + if (!state.tokenContractId) { + ctx.skip('No TOKEN_CONTRACT_ID (token-register must pass first)'); + return; + } + + const result = await runTutorial('3-Tokens/token-info.mjs', { + env: { + TOKEN_CONTRACT_ID: state.tokenContractId, + }, + timeoutMs: 120_000, + }); + assertTutorialSuccess(result, { + name: 'token-info', + expectedPatterns: [ + 'Token ID:', + 'Token contract info:', + 'Token status:', + 'Total token supply:', + 'Identity token balance:', + 'Recipient token balance:', + ], + errorPatterns: ['Something went wrong'], + }); + + // Guard against the regression where contract info / status printed + // `undefined` because the result objects were logged without resolving + // their getters or handling absent on-chain records. + assert.ok( + !/:\s*undefined/.test(result.stdout), + `token-info printed an undefined field:\n${result.stdout}`, + ); + }); + + it('token-mint', { timeout: 120_000 }, async (ctx) => { + if (!state.tokenContractId) { + ctx.skip('No TOKEN_CONTRACT_ID (token-register must pass first)'); + return; + } + + const result = await runTutorial('3-Tokens/token-mint.mjs', { + env: { + TOKEN_CONTRACT_ID: state.tokenContractId, + }, + timeoutMs: 120_000, + }); + assertTutorialSuccess(result, { + name: 'token-mint', + expectedPatterns: ['Minted 10 tokens', 'Total token supply:'], + errorPatterns: ['Something went wrong'], + }); + }); + it('token-transfer', { timeout: 120_000 }, async (ctx) => { if (!state.tokenContractId) { ctx.skip('No TOKEN_CONTRACT_ID (token-register must pass first)'); @@ -396,4 +449,23 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { errorPatterns: ['Something went wrong'], }); }); + + it('token-burn', { timeout: 120_000 }, async (ctx) => { + if (!state.tokenContractId) { + ctx.skip('No TOKEN_CONTRACT_ID (token-register must pass first)'); + return; + } + + const result = await runTutorial('3-Tokens/token-burn.mjs', { + env: { + TOKEN_CONTRACT_ID: state.tokenContractId, + }, + timeoutMs: 120_000, + }); + assertTutorialSuccess(result, { + name: 'token-burn', + expectedPatterns: ['Burned 1 token', 'Total token supply:'], + errorPatterns: ['Something went wrong'], + }); + }); }); From f452858bb9d1046e65a566fb456c4743b51d1f97 Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 10 Jun 2026 13:50:20 -0400 Subject: [PATCH 4/5] refactor(token-info): drop redundant identity fetch Use keyManager.identityId directly for the balance query instead of fetching the identity from the network just to read identity.id, removing an unnecessary round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- 3-Tokens/token-info.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/3-Tokens/token-info.mjs b/3-Tokens/token-info.mjs index 02224b2..9f44bcd 100644 --- a/3-Tokens/token-info.mjs +++ b/3-Tokens/token-info.mjs @@ -22,10 +22,10 @@ try { const contractInfo = await sdk.tokens.contractInfo(tokenId); const totalSupply = await sdk.tokens.totalSupply(tokenId); const statuses = await sdk.tokens.statuses([tokenId]); - const identity = await sdk.identities.fetch(keyManager.identityId); - const identityBalances = await sdk.tokens.identityBalances(identity.id, [ - tokenId, - ]); + const identityBalances = await sdk.tokens.identityBalances( + keyManager.identityId, + [tokenId], + ); const recipientBalances = await sdk.tokens.identityBalances(recipientId, [ tokenId, ]); From 3860296eb99f1b76bb1f4e0ec6d503708effa12c Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 10 Jun 2026 16:23:25 -0400 Subject: [PATCH 5/5] test(tokens): harden token contract ID extraction Prefer extractId, then fall back to a base58-only regex so the token contract ID is never captured as the brace opening the toJSON dump. Also assert token-transfer prints its confirmation line. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/read-write.test.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs index 32a9526..b04f3c2 100644 --- a/test/read-write.test.mjs +++ b/test/read-write.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { runTutorial } from './run-tutorial.mjs'; import { assertTutorialSuccess, + extractFromOutput, extractId, extractKeyId, } from './assertions.mjs'; @@ -370,7 +371,12 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { errorPatterns: ['Something went wrong'], }); - const id = extractId(result.stdout); + const id = + extractId(result.stdout) ?? + extractFromOutput( + result.stdout, + /Token contract registered:\s*([1-9A-HJ-NP-Za-km-z]+)/, + ); assert.ok( id, `Failed to extract token contract ID from stdout:\n${result.stdout}`, @@ -445,7 +451,10 @@ describe('Write tutorials (sequential)', { concurrency: 1 }, () => { }); assertTutorialSuccess(result, { name: 'token-transfer', - expectedPatterns: ['Recipient token balance after transfer:'], + expectedPatterns: [ + 'Transferred 1 token', + 'Recipient token balance after transfer:', + ], errorPatterns: ['Something went wrong'], }); });