diff --git a/.env.example b/.env.example index e49c633..da5b446 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 token-register.mjs +TOKEN_CONTRACT_ID='' + # RECIPIENT_PLATFORM_ADDRESS is a bech32m platform address (tdash1...) for send-funds tutorial RECIPIENT_PLATFORM_ADDRESS='' 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..9f44bcd --- /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 identityBalances = await sdk.tokens.identityBalances( + keyManager.identityId, + [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 new file mode 100644 index 0000000..336a397 --- /dev/null +++ b/3-Tokens/token-register.mjs @@ -0,0 +1,137 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/tokens/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 = 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. +const documentSchemas = { + note: { + type: 'object', + properties: { + message: { + type: 'string', + position: 0, + }, + }, + additionalProperties: false, + }, +}; + +function createTutorialTokenConfiguration(ownerId) { + 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: TOKEN_BASE_SUPPLY, + maxSupply: TOKEN_MAX_SUPPLY, + keepsHistory: new TokenKeepsHistoryRules({ + isKeepingBurningHistory: true, + isKeepingMintingHistory: 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, + ), + // 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: 'Issuer-managed 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(), + ), + }, + 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()); + console.log('Maximum token supply:', TOKEN_MAX_SUPPLY.toString()); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/3-Tokens/token-transfer.mjs b/3-Tokens/token-transfer.mjs new file mode 100644 index 0000000..343a06b --- /dev/null +++ b/3-Tokens/token-transfer.mjs @@ -0,0 +1,60 @@ +// 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 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'; +const amount = 1n; + +try { + if (!dataContractId) { + throw new Error( + 'Set TOKEN_CONTRACT_ID in .env from token-register.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/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 fe70f3a..9e2d359 100644 --- a/README.md +++ b/README.md @@ -76,15 +76,19 @@ 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 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 +`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 9069570..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'; @@ -355,4 +356,125 @@ 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) ?? + 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}`, + ); + 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)'); + 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: [ + 'Transferred 1 token', + 'Recipient token balance after transfer:', + ], + 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'], + }); + }); });