diff --git a/_static/css/pydata-overrides.css b/_static/css/pydata-overrides.css
index 9bcdda8b5..6b07da445 100644
--- a/_static/css/pydata-overrides.css
+++ b/_static/css/pydata-overrides.css
@@ -167,3 +167,10 @@ sphinx search extension interface.
.search-button__wrapper.show .search-button__search-container {
width: 15%;
}
+
+/* Cap tall code blocks and make them scrollable. Short blocks are unaffected
+ since max-height only kicks in when content exceeds it. */
+.highlight pre {
+ max-height: 60rem;
+ overflow: auto;
+}
diff --git a/_templates/sidebar-main.html b/_templates/sidebar-main.html
index ce7d7dca7..8a3287e4c 100644
--- a/_templates/sidebar-main.html
+++ b/_templates/sidebar-main.html
@@ -163,6 +163,26 @@
+
+
+ Example apps
+
+
+
+
+
+
+
+
+
+
+
Send funds
@@ -310,6 +330,11 @@
Non-Fungible Tokens (NFTs)
+
+
+ Query Capabilities
+
+
@@ -461,6 +486,16 @@
Repository Overview
+
+
+ Dash Platform Monorepo
+
+
+
+
+ Dash Platform Book
+
+
Platform Bridge
diff --git a/docs/index.md b/docs/index.md
index 07ea44988..d3baf594d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -101,6 +101,7 @@ tutorials/create-and-fund-a-wallet
tutorials/setup-sdk-client
tutorials/identities-and-names
tutorials/contracts-and-documents
+tutorials/example-apps
tutorials/send-funds
tutorials/setup-a-node
tutorials/tui/index
diff --git a/docs/tutorials/example-apps.md b/docs/tutorials/example-apps.md
new file mode 100644
index 000000000..a59054012
--- /dev/null
+++ b/docs/tutorials/example-apps.md
@@ -0,0 +1,18 @@
+```{eval-rst}
+.. tutorials-example-apps:
+```
+
+# Example apps
+
+The tutorials in this section walk through complete, end-to-end applications built on Dash Platform. Unlike the single-operation tutorials elsewhere (for example, [Submit documents](contracts-and-documents/submit-documents.md) or [Register an identity](identities-and-names/register-an-identity.md)), each walkthrough shows how many SDK operations compose together inside a real app.
+
+Each app is a stand-alone project in the [`platform-tutorials/example-apps/`](https://github.com/dashpay/platform-tutorials/tree/main/example-apps) directory. The walkthroughs here tour the code alongside the commands needed to run it locally.
+
+If you are looking for a focused snippet for one SDK call, the per-operation tutorials under [Identities and names](identities-and-names.md) and [Contracts and documents](contracts-and-documents.md) are a better starting point.
+
+```{toctree}
+:maxdepth: 2
+:titlesonly:
+
+example-apps/dashmint-lab
+```
diff --git a/docs/tutorials/example-apps/dashmint-lab.md b/docs/tutorials/example-apps/dashmint-lab.md
new file mode 100644
index 000000000..05983242b
--- /dev/null
+++ b/docs/tutorials/example-apps/dashmint-lab.md
@@ -0,0 +1,853 @@
+```{eval-rst}
+.. tutorials-example-apps-dashmint-lab:
+```
+
+# DashMint Lab — NFT marketplace
+
+DashMint Lab is a React + TypeScript + Vite single-page app that exercises every Dash Platform NFT operation: mint, transfer, price, purchase, burn, and query. This walkthrough shows how those SDK calls are organized inside a real UI.
+
+
+
+## What this app does
+
+The app lets users log in with a BIP-39 mnemonic, mint "card" NFTs with random attack/defense stats, browse cards across the network, set sale prices, purchase cards from other identities, transfer cards as gifts, and burn cards they no longer want. Read-only browsing works without any credentials.
+
+For background on Dash Platform NFT features such as transfer, trade, delete, and creation restrictions, see the [NFT explanation](../../explanations/nft.md).
+
+## How the code is structured
+
+Every Platform SDK call lives in its own file under `src/dash/`. The React UI is a thin layer on top that wires those functions to forms and buttons. Because the app is browser-based, it imports the same `setupDashClient-core.mjs` module already covered in [Setup SDK Client](../setup-sdk-client.md), so the Node tutorials and this app share one source of truth for client creation and key derivation.
+
+## TL;DR
+
+- Each NFT operation lives in its own `src/dash/*.ts` file.
+- The easiest entry points are `src/dash/queries.ts`, `src/dash/mintCard.ts`, and `src/dash/transferCard.ts`.
+- Most mutations share one helper: `src/dash/withAuthedCard.ts`.
+- The UI mostly passes form input into those functions and renders the results.
+- `client.ts` and `keyManager.ts` are thin re-exports of `setupDashClient-core.mjs`.
+
+If you just want the mental model: read the architecture table, then `withAuthedCard.ts`, then whichever operation you care about.
+
+## Prerequisites
+
+- [General prerequisites](../introduction.md#prerequisites) (Node.js / Dash SDK installed)
+- A configured client: [Setup SDK Client](../setup-sdk-client.md) — DashMint re-uses `setupDashClient-core.mjs`
+- A registered identity: [Register an Identity](../identities-and-names/register-an-identity.md)
+- Familiarity with data contracts: [Register a Data Contract](../contracts-and-documents/register-a-data-contract.md) — particularly the NFT tab
+- Node >= 20 and a funded testnet identity (BIP-39 mnemonic + identity index)
+- (Optional) A second funded identity to test cross-profile transfer and purchase
+
+## Clone and run
+
+```bash
+git clone https://github.com/dashpay/platform-tutorials.git
+cd platform-tutorials/example-apps/dashmint-lab
+npm install
+npm run dev
+```
+
+The dev server runs on `http://localhost:5173`. Open it in a browser, click **Login**, paste your testnet mnemonic, and start minting. The app ships with a default contract ID so browse-only mode works on a fresh install.
+
+Production build: `npm run build && npm run preview`.
+
+## Architecture tour
+
+Every Platform SDK call lives in its own file under `src/dash/`:
+
+| Operation | File | SDK method |
+| --------- | ---- | ---------- |
+| Connect to testnet | `src/dash/client.ts` | `EvoSDK.testnetTrusted()` + `sdk.connect()` |
+| Derive identity keys | `src/dash/keyManager.ts` | `wallet.deriveKeyFromSeedWithPath` |
+| Deploy card contract | `src/dash/contract.ts` | `sdk.contracts.publish` |
+| Query cards | `src/dash/queries.ts` | `sdk.documents.query` |
+| Mint a card | `src/dash/mintCard.ts` | `sdk.documents.create` |
+| Transfer a card | `src/dash/transferCard.ts` | `sdk.documents.transfer` |
+| Set / remove price | `src/dash/setPrice.ts` | `sdk.documents.setPrice` |
+| Purchase a card | `src/dash/purchaseCard.ts` | `sdk.documents.purchase` |
+| Burn (delete) a card | `src/dash/burnCard.ts` | `sdk.documents.delete` |
+
+Two supporting files glue the operations together:
+
+- `src/dash/withAuthedCard.ts` — shared mutation prelude used by transfer, setPrice, purchase, and burn. Fetches the document, bumps its revision, and resolves the auth signer.
+- `src/dash/logger.ts` — shared `Logger` type so every operation can stream progress to the UI activity log.
+
+`client.ts` and `keyManager.ts` are just re-exports:
+
+```typescript
+export { createClient } from '../../../../setupDashClient-core.mjs';
+export { IdentityKeyManager } from '../../../../setupDashClient-core.mjs';
+```
+
+That means the connection and key-derivation behavior are the same as in the Node tutorials. Read [Setup SDK Client](../setup-sdk-client.md) for the full client setup details.
+
+## Shared mutation pattern
+
+Every mutation on an existing card — transfer, set price, purchase, burn — runs the same four steps:
+
+1. Get an auth signer for the current identity.
+2. Fetch the current on-chain `Document` (needed to know its revision).
+3. Bump `document.revision` by 1. Platform rejects state transitions that don't strictly increase the revision.
+4. Call the specific SDK method.
+
+`withAuthedCard()` wraps steps 1–3 so each operation file stays focused on its single SDK call. Burn passes `preFetch: false` because `sdk.documents.delete` only needs enough identifying fields, not a full fetched document.
+
+```{code-block} typescript
+:caption: withAuthedCard.ts
+:name: dashmint-withAuthedCard.ts
+
+/**
+ * Shared prelude for card mutations (transfer / setPrice / purchase / burn).
+ *
+ * Every mutation on an NFT card follows the same four steps:
+ * 1. Get an auth signer for the current identity.
+ * 2. Fetch the current on-chain Document (needed to know its revision).
+ * 3. Bump `document.revision` by 1 — Platform rejects mutations that
+ * don't strictly increase the revision number.
+ * 4. Call the SDK method (transfer/setPrice/purchase/delete).
+ *
+ * withAuthedCard() wraps steps 1-3 so the individual operation files stay
+ * focused on the one SDK call that's unique to them. Pass `preFetch: false`
+ * for burn (delete), which doesn't need the full fetched document.
+ *
+ * Ported from the original tutorial HTML:
+ * tutorial/nft/nft-collectibles.html:767 (`async function withAuthedCard`)
+ *
+ * SDK methods inside: keyManager.getAuth(), sdk.documents.get(...)
+ */
+import { errorMessage, type Logger } from "./logger.js";
+import type {
+ DashAuth,
+ DashCardDocument,
+ DashKeyManager,
+ DashSdk,
+} from "./types";
+
+export interface AuthedCardContext extends DashAuth {
+ sdk: DashSdk;
+ contractId: string;
+ /** Present when preFetch !== false. Already has its revision incremented. */
+ doc?: DashCardDocument;
+}
+
+export interface WithAuthedCardOptions {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ cardId: string;
+ /** Default true. Set to false for burn, which only needs identity + signer. */
+ preFetch?: boolean;
+ /** Label used in error messages, e.g. "Transfer error". Default "Error". */
+ errorLabel?: string;
+ log?: Logger;
+}
+
+export async function withAuthedCard(
+ opts: WithAuthedCardOptions,
+ fn: (ctx: AuthedCardContext) => Promise,
+): Promise {
+ const {
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ preFetch = true,
+ errorLabel = "Error",
+ log,
+ } = opts;
+
+ try {
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const ctx: AuthedCardContext = {
+ sdk,
+ identity,
+ identityKey,
+ signer,
+ contractId,
+ };
+
+ if (preFetch) {
+ const doc = (await sdk.documents.get(
+ contractId,
+ "card",
+ cardId,
+ )) as DashCardDocument;
+ doc.revision = BigInt(doc.revision ?? 0) + 1n;
+ ctx.doc = doc;
+ }
+
+ return await fn(ctx);
+ } catch (e) {
+ const message = errorMessage(e);
+ log?.(`${errorLabel}: ${message}`, "error");
+ throw e;
+ }
+}
+```
+
+:::{note}
+Card mutations sign with the **CRITICAL authentication key** (key 2), which `keyManager.getAuth()` returns. Despite the name, the `TRANSFER` purpose key does **not** authorize document transfers — Platform reserves that key for credit transfers and withdrawals.
+:::
+
+## Read path: queries first
+
+If you want to understand how data shows up in the UI, start with `src/dash/queries.ts`. The Collection tab has three sub-views, each backed by a different query: your own cards, every card on the contract, and only the cards that are currently for sale. `normalizeCards()` flattens the three possible shapes the SDK can return (array, `Map`, or plain object) into a single flat list the UI can render.
+
+```{code-block} typescript
+:caption: queries.ts
+:name: dashmint-queries.ts
+:emphasize-lines: 53-58,75-80,93-97,110-115
+
+/**
+ * Read queries over the card data contract.
+ *
+ * Three variants backing the Collection tab's sub-tabs:
+ * listMyCards — cards owned by the signed-in identity (uses where $ownerId)
+ * listAllCards — every card across the network (capped limit)
+ * listMarketplaceCards — every card that has a non-null $price
+ *
+ * normalizeCards() hides the three possible shapes the SDK may return
+ * (Array, Map, or plain object) so UI code always sees a plain array of
+ * { id, ownerId, data, $price }.
+ *
+ * SDK method: sdk.documents.query({ dataContractId, documentTypeName, where?, limit })
+ */
+import type { Logger } from "./logger.js";
+import type {
+ DashCardQueryDocument,
+ DashCardQueryResults,
+ DashSdk,
+} from "./types";
+
+// Platform caps document queries at 100 results per request.
+const MAX_QUERY_LIMIT = 100;
+
+export interface Card {
+ id: string;
+ ownerId: string;
+ data: {
+ name?: string;
+ description?: string;
+ attack?: number;
+ defense?: number;
+ };
+ $price?: number | bigint;
+}
+
+function toCard(id: string | null, raw: DashCardQueryDocument): Card {
+ const j: Record =
+ typeof raw?.toJSON === "function" ? raw.toJSON() : raw;
+ return {
+ id: (id ?? (j.$id as string) ?? (j.id as string)) as string,
+ ownerId: j.$ownerId as string,
+ data: {
+ name: j.name as string | undefined,
+ description: j.description as string | undefined,
+ attack: j.attack as number | undefined,
+ defense: j.defense as number | undefined,
+ },
+ $price: j.$price as number | bigint | undefined,
+ };
+}
+
+export function normalizeCards(results: DashCardQueryResults): Card[] {
+ if (Array.isArray(results)) return results.map((d) => toCard(null, d));
+ const entries =
+ results instanceof Map ? Object.fromEntries(results) : results;
+ return Object.entries(entries).map(([id, d]) => toCard(id, d));
+}
+
+interface BaseParams {
+ sdk: DashSdk;
+ contractId: string;
+ limit?: number;
+ log?: Logger;
+}
+
+export async function listMyCards({
+ sdk,
+ contractId,
+ identityId,
+ limit = MAX_QUERY_LIMIT,
+ log,
+}: BaseParams & { identityId: string }): Promise {
+ log?.("Loading your cards…");
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "card",
+ where: [["$ownerId", "==", identityId]],
+ limit,
+ });
+ const cards = normalizeCards(results);
+ log?.(`Found ${cards.length} card(s).`);
+ return cards;
+}
+
+export async function listAllCards({
+ sdk,
+ contractId,
+ limit = MAX_QUERY_LIMIT,
+ log,
+}: BaseParams): Promise {
+ log?.("Loading all cards (any owner)…");
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "card",
+ limit,
+ });
+ const cards = normalizeCards(results);
+ log?.(`Found ${cards.length} card(s) total.`);
+ return cards;
+}
+
+export async function listMarketplaceCards({
+ sdk,
+ contractId,
+ limit = MAX_QUERY_LIMIT,
+ log,
+}: BaseParams): Promise {
+ log?.("Loading marketplace…");
+ const results = await sdk.documents.query({
+ dataContractId: contractId,
+ documentTypeName: "card",
+ limit,
+ });
+ const cards = normalizeCards(results).filter((c) => c.$price);
+ log?.(`Found ${cards.length} card(s) for sale.`);
+ return cards;
+}
+```
+
+## Operation walkthrough
+
+Each operation file is intentionally small. The app-level pattern is: validate input, prepare a `Document` or reuse `withAuthedCard()`, call one SDK method, then log the result.
+
+### Mint a card
+
+Minting is the simplest write operation: build a `Document` with the card properties and owner, then call `sdk.documents.create`. No existing document to fetch, no revision to bump.
+
+```{code-block} typescript
+:caption: mintCard.ts
+:name: dashmint-mintCard.ts
+:emphasize-lines: 51,56-63
+
+/**
+ * Mint a new card (create a document against the card data contract).
+ *
+ * Attack and defense are rolled client-side (1-10 each). Name is required,
+ * description is optional.
+ *
+ * SDK method: sdk.documents.create({ document, identityKey, signer })
+ */
+import { Document } from "@dashevo/evo-sdk";
+
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export interface MintCardInput {
+ name: string;
+ description?: string;
+ /** Override for deterministic tests. Default: random 1-10. */
+ attack?: number;
+ /** Override for deterministic tests. Default: random 1-10. */
+ defense?: number;
+}
+
+export interface MintCardParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ card: MintCardInput;
+ log?: Logger;
+}
+
+function rollStat(): number {
+ return Math.floor(Math.random() * 10) + 1;
+}
+
+export async function mintCard({
+ sdk,
+ keyManager,
+ contractId,
+ card,
+ log,
+}: MintCardParams): Promise {
+ const name = card.name.trim();
+ if (!name) throw new Error("Card name is required.");
+
+ const attack = card.attack ?? rollStat();
+ const defense = card.defense ?? rollStat();
+ const description = card.description?.trim();
+
+ log?.(`Minting "${name}" (ATK ${attack} / DEF ${defense})…`);
+
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+
+ const properties: Record = { name, attack, defense };
+ if (description) properties.description = description;
+
+ const doc = new Document({
+ properties,
+ documentTypeName: "card",
+ dataContractId: contractId,
+ ownerId: identity.id,
+ });
+
+ await sdk.documents.create({ document: doc, identityKey, signer });
+ log?.(`Card "${name}" minted!`, "success");
+}
+```
+
+### Transfer a card
+
+Transfer hands ownership of an existing card to another identity without a price. The interesting work happens inside `withAuthedCard()`; this file just calls `sdk.documents.transfer` on the prepared document.
+
+```{code-block} typescript
+:caption: transferCard.ts
+:name: dashmint-transferCard.ts
+:emphasize-lines: 37-42
+
+/**
+ * Transfer a card (NFT document) to another identity.
+ *
+ * Gotcha (see tutorial/nft/CLAUDE.md): transfer uses the AUTHENTICATION
+ * key, not the TRANSFER purpose key. The Platform rejects TRANSFER-purpose
+ * keys for document state transitions.
+ *
+ * SDK method: sdk.documents.transfer({ document, recipientId, identityKey, signer })
+ */
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+import { withAuthedCard } from "./withAuthedCard";
+
+export interface TransferCardParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ cardId: string;
+ recipientId: string;
+ log?: Logger;
+}
+
+export async function transferCard({
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ recipientId,
+ log,
+}: TransferCardParams): Promise {
+ if (!recipientId) throw new Error("Recipient identity ID is required.");
+ log?.(`Transferring card ${cardId} to ${recipientId}…`);
+
+ await withAuthedCard(
+ { sdk, keyManager, contractId, cardId, errorLabel: "Transfer error", log },
+ async ({ doc, identityKey, signer }) => {
+ await sdk.documents.transfer({
+ document: doc,
+ recipientId,
+ identityKey,
+ signer,
+ });
+ log?.("Card transferred!", "success");
+ },
+ );
+}
+```
+
+### Set or remove a sale price
+
+Pricing a card adds a `$price` field to its document on-chain. That's what the Marketplace tab filters by. Passing `price = 0n` removes the card from sale.
+
+```{code-block} typescript
+:caption: setPrice.ts
+:name: dashmint-setPrice.ts
+:emphasize-lines: 32-33,51-56
+
+/**
+ * Set (or remove) the sale price on a card.
+ *
+ * Pricing a card adds a `$price` field to the document on-chain, which is
+ * what the Marketplace tab filters by. Passing price = 0n removes the
+ * card from sale.
+ *
+ * SDK method: sdk.documents.setPrice({ document, price, identityKey, signer })
+ */
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+import { withAuthedCard } from "./withAuthedCard";
+
+export interface SetPriceParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ cardId: string;
+ /** Price in credits. Pass 0 to remove the card from sale. */
+ price: number | bigint;
+ log?: Logger;
+}
+
+export async function setPrice({
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ price,
+ log,
+}: SetPriceParams): Promise {
+ const priceBig = typeof price === "bigint" ? price : BigInt(price);
+ const removing = priceBig === 0n;
+
+ log?.(
+ removing
+ ? `Removing price from card ${cardId}…`
+ : `Setting price ${priceBig} credits on card ${cardId}…`,
+ );
+
+ await withAuthedCard(
+ {
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ errorLabel: removing ? "Remove price error" : "Set price error",
+ log,
+ },
+ async ({ doc, identityKey, signer }) => {
+ await sdk.documents.setPrice({
+ document: doc,
+ price: priceBig,
+ identityKey,
+ signer,
+ });
+ log?.(removing ? "Card removed from sale." : "Price set!", "success");
+ },
+ );
+}
+```
+
+### Purchase a card
+
+The buying identity pays `price` credits and becomes the new owner in a single state transition. Platform enforces the price server-side — passing a stale price fails the transition.
+
+```{code-block} typescript
+:caption: purchaseCard.ts
+:name: dashmint-purchaseCard.ts
+:emphasize-lines: 31,44-50
+
+/**
+ * Purchase a priced card from another identity.
+ *
+ * The signed-in identity pays `price` credits and becomes the new owner.
+ * Platform enforces the price server-side — passing a stale price fails.
+ *
+ * SDK method: sdk.documents.purchase({ document, buyerId, price, identityKey, signer })
+ */
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+import { withAuthedCard } from "./withAuthedCard";
+
+export interface PurchaseCardParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ cardId: string;
+ /** Price in credits — must match the on-chain $price. */
+ price: number | bigint;
+ log?: Logger;
+}
+
+export async function purchaseCard({
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ price,
+ log,
+}: PurchaseCardParams): Promise {
+ const priceBig = typeof price === "bigint" ? price : BigInt(price);
+ log?.(`Purchasing card ${cardId} for ${priceBig} credits…`);
+
+ await withAuthedCard(
+ {
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ errorLabel: "Purchase error",
+ log,
+ },
+ async ({ doc, identity, identityKey, signer }) => {
+ await sdk.documents.purchase({
+ document: doc,
+ buyerId: identity.id,
+ price: priceBig,
+ identityKey,
+ signer,
+ });
+ log?.("Card purchased!", "success");
+ },
+ );
+}
+```
+
+### Burn a card
+
+Burn permanently deletes the document from Platform. Unlike the other mutations, delete only needs identifying fields — no full fetched document, no revision bump. That's why `withAuthedCard` is called with `preFetch: false`.
+
+```{code-block} typescript
+:caption: burnCard.ts
+:name: dashmint-burnCard.ts
+:emphasize-lines: 42-51
+
+/**
+ * Burn a card — permanently delete the document from the Platform.
+ *
+ * Unlike the other mutations, burn does NOT need the full fetched Document:
+ * the delete API only needs enough identifying fields to locate the target.
+ * That's why withAuthedCard() is called with preFetch: false.
+ *
+ * SDK method: sdk.documents.delete({ document, identityKey, signer })
+ */
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+import { withAuthedCard } from "./withAuthedCard";
+
+export interface BurnCardParams {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ contractId: string;
+ cardId: string;
+ log?: Logger;
+}
+
+export async function burnCard({
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ log,
+}: BurnCardParams): Promise {
+ log?.(`Burning card ${cardId}…`);
+
+ await withAuthedCard(
+ {
+ sdk,
+ keyManager,
+ contractId,
+ cardId,
+ preFetch: false,
+ errorLabel: "Burn error",
+ log,
+ },
+ async ({ identity, identityKey, signer }) => {
+ await sdk.documents.delete({
+ document: {
+ id: cardId,
+ ownerId: identity.id,
+ dataContractId: contractId,
+ documentTypeName: "card",
+ },
+ identityKey,
+ signer,
+ });
+ log?.("Card burned.", "success");
+ },
+ );
+}
+```
+
+## Contract schema
+
+### What makes this an NFT contract
+
+The card data contract defines one document type (`card`) with four fields and three indices. Three top-level flags turn it into an NFT contract: `transferable: 1` lets owners send cards to other identities, `tradeMode: 1` enables the built-in price/purchase flow, and `creationRestrictionMode: 1` controls who can mint. See the [NFT explanation](../../explanations/nft.md#explanations-dash-nfts) for what each flag does, and the [NFT tab in Register a Data Contract](../contracts-and-documents/register-a-data-contract.md) for the schema in JSON form.
+
+### How the app registers or reuses the contract
+
+`ensureContract()` reuses a previously published contract ID from `localStorage` when one is present, and only calls `sdk.contracts.publish` on first run. That keeps the app usable without forcing every visitor to publish their own contract.
+
+```{code-block} typescript
+:caption: contract.ts
+:name: dashmint-contract.ts
+
+/**
+ * NFT card data contract schema + ensureContract().
+ *
+ * WHAT: A Dash Platform "data contract" defines the schema for documents.
+ * This one describes a single document type (`card`) with four fields
+ * (name, description, attack, defense) plus three indices so the app can
+ * query by owner, attack, or defense.
+ *
+ * The three flags at the top of the schema are what make this an NFT:
+ * transferable: 1 — documents can be sent to another identity (0 to disable)
+ * tradeMode: 1 — documents can be priced and purchased (0 to disable)
+ * creationRestrictionMode: 1 — (1 - only the contract owner can mint; 0 - anyone can mint)
+ *
+ * SDK methods: new DataContract({ ... }), sdk.contracts.publish(...)
+ */
+import { DataContract } from "@dashevo/evo-sdk";
+
+import type { Logger } from "./logger";
+import type { DashKeyManager, DashSdk } from "./types";
+
+export const CARD_SCHEMAS = {
+ card: {
+ type: "object",
+ documentsMutable: false,
+ canBeDeleted: true,
+ transferable: 1,
+ tradeMode: 1,
+ creationRestrictionMode: 1,
+ properties: {
+ name: {
+ type: "string",
+ description: "Name of the card",
+ minLength: 1,
+ maxLength: 63,
+ position: 0,
+ },
+ description: {
+ type: "string",
+ description: "Description of the card",
+ minLength: 0,
+ maxLength: 256,
+ position: 1,
+ },
+ attack: {
+ type: "integer",
+ description: "Attack power",
+ position: 2,
+ },
+ defense: {
+ type: "integer",
+ description: "Defense level",
+ position: 3,
+ },
+ },
+ indices: [
+ { name: "owner", properties: [{ $ownerId: "asc" }] },
+ { name: "attack", properties: [{ attack: "asc" }] },
+ { name: "defense", properties: [{ defense: "asc" }] },
+ ],
+ required: ["name", "attack", "defense"],
+ additionalProperties: false,
+ },
+} as const;
+
+/**
+ * Fetch the owner identity ID for a given data contract.
+ *
+ * SDK method: sdk.contracts.fetch(...)
+ */
+export async function fetchContractOwnerId({
+ sdk,
+ contractId,
+}: {
+ sdk: DashSdk;
+ contractId: string;
+}): Promise {
+ const contract = await sdk.contracts.fetch(contractId);
+ if (!contract) return null;
+ const json =
+ typeof contract.toJSON === "function" ? contract.toJSON() : contract;
+ const ownerId = json.$ownerId ?? json.ownerId ?? null;
+ return ownerId ? String(ownerId) : null;
+}
+
+const STORAGE_KEY = "dashmint-lab.contractId";
+
+/**
+ * Default contract ID baked into the tutorial so browse-only mode works
+ * on a fresh machine without any setup. Comes from the original
+ * HTML tutorial's pre-deployed testnet contract. Users can override it
+ * in the Settings modal or register their own.
+ */
+export const DEFAULT_CONTRACT_ID =
+ "4eJR4pgV9mQdyoodfTTwFUp3SYBRJbUrJ5X1ViN2zBhY";
+
+export function loadStoredContractId(): string | null {
+ return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_CONTRACT_ID;
+}
+
+export function saveContractId(id: string): void {
+ localStorage.setItem(STORAGE_KEY, id);
+}
+
+export function clearStoredContractId(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+/**
+ * Register a fresh NFT card data contract on Platform and persist its ID.
+ *
+ * SDK methods: sdk.identities.nonce(...), sdk.contracts.publish(...).
+ */
+export async function registerContract({
+ sdk,
+ keyManager,
+ log,
+}: {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ log?: Logger;
+}): Promise {
+ log?.("Registering NFT card contract…");
+ const { identity, identityKey, signer } = await keyManager.getAuth();
+ const identityNonce = await sdk.identities.nonce(identity.id.toString());
+ const dataContract = new DataContract({
+ ownerId: identity.id,
+ identityNonce: (identityNonce || 0n) + 1n,
+ schemas: CARD_SCHEMAS,
+ fullValidation: true,
+ });
+
+ log?.("Publishing contract…");
+ const published = await sdk.contracts.publish({
+ dataContract,
+ identityKey,
+ signer,
+ });
+ const contractId = published.id?.toString() || published.toJSON?.()?.id;
+
+ if (!contractId) {
+ throw new Error(
+ `Contract publish returned no id: ${JSON.stringify(published.toJSON?.() ?? published)}`,
+ );
+ }
+
+ saveContractId(contractId);
+ log?.(`Contract registered: ${contractId}`, "success");
+ return contractId;
+}
+
+/**
+ * Ensure a card data contract exists for this app. If a contract ID is
+ * already persisted in localStorage (or passed in), we reuse it. Otherwise
+ * publish a fresh contract owned by the signed-in identity and persist its
+ * ID for next time.
+ */
+export async function ensureContract({
+ sdk,
+ keyManager,
+ existingId,
+ log,
+}: {
+ sdk: DashSdk;
+ keyManager: DashKeyManager;
+ existingId?: string | null;
+ log?: Logger;
+}): Promise {
+ const fromStorage = existingId ?? loadStoredContractId();
+ if (fromStorage) {
+ log?.(`Using saved contract ID: ${fromStorage}`);
+ return fromStorage;
+ }
+ return registerContract({ sdk, keyManager, log });
+}
+```
+
+## Next steps
+
+- Read more about NFT features in the [NFT explanation](../../explanations/nft.md).
+- Try the same operations headlessly from Node using the tutorials in [Contracts and documents](../contracts-and-documents.md).
+- Fork the app and adapt the contract schema to your own NFT use case. The one-file-per-operation layout under `src/dash/` makes it easy to swap a single operation without touching the rest.
diff --git a/docs/tutorials/example-apps/img/dashmint-card-menu.png b/docs/tutorials/example-apps/img/dashmint-card-menu.png
new file mode 100644
index 000000000..40fa57682
Binary files /dev/null and b/docs/tutorials/example-apps/img/dashmint-card-menu.png differ
diff --git a/docs/tutorials/example-apps/img/dashmint-collection.png b/docs/tutorials/example-apps/img/dashmint-collection.png
new file mode 100644
index 000000000..c4cd6a873
Binary files /dev/null and b/docs/tutorials/example-apps/img/dashmint-collection.png differ
diff --git a/docs/tutorials/example-apps/img/dashmint-mint.png b/docs/tutorials/example-apps/img/dashmint-mint.png
new file mode 100644
index 000000000..ce9450f97
Binary files /dev/null and b/docs/tutorials/example-apps/img/dashmint-mint.png differ
diff --git a/scripts/tutorial-sync/tutorial-code-map.yml b/scripts/tutorial-sync/tutorial-code-map.yml
index 375443add..c588ca721 100644
--- a/scripts/tutorial-sync/tutorial-code-map.yml
+++ b/scripts/tutorial-sync/tutorial-code-map.yml
@@ -178,3 +178,56 @@ mappings:
doc: contracts-and-documents/delete-documents.md
block_id:
caption: document-delete.mjs
+
+ # -- Example apps --
+
+ # DashMint Lab — React + TypeScript NFT app. Every SDK operation lives in
+ # its own file under src/dash/; each one has a matching code block in the
+ # walkthrough with a :caption: equal to the bare filename.
+ - source: example-apps/dashmint-lab/src/dash/contract.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: contract.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/withAuthedCard.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: withAuthedCard.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/mintCard.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: mintCard.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/transferCard.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: transferCard.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/setPrice.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: setPrice.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/purchaseCard.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: purchaseCard.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/burnCard.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: burnCard.ts
+ language: typescript
+
+ - source: example-apps/dashmint-lab/src/dash/queries.ts
+ doc: example-apps/dashmint-lab.md
+ block_id:
+ caption: queries.ts
+ language: typescript