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.
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, 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/*.tsfile.The easiest entry points are
src/dash/queries.ts,src/dash/mintCard.ts, andsrc/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.tsandkeyManager.tsare thin re-exports ofsetupDashClient-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 (Node.js / Dash SDK installed)
A configured client: Setup SDK Client — DashMint re-uses
setupDashClient-core.mjsA registered identity: Register an Identity
Familiarity with data contracts: Register a Data Contract — 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#
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 |
|
|
Derive identity keys |
|
|
Deploy card contract |
|
|
Query cards |
|
|
Mint a card |
|
|
Transfer a card |
|
|
Set / remove price |
|
|
Purchase a card |
|
|
Burn (delete) a card |
|
|
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— sharedLoggertype so every operation can stream progress to the UI activity log.
client.ts and keyManager.ts are just re-exports:
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 for the full client setup details.
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.
/**
* 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<string, unknown> =
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<Card[]> {
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<Card[]> {
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<Card[]> {
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.
/**
* 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<void> {
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<string, unknown> = { 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.
/**
* 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<void> {
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.
/**
* 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<void> {
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.
/**
* 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<void> {
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.
/**
* 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<void> {
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 for what each flag does, and the NFT tab in Register a Data Contract 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.
/**
* NFT card data contract schema + registerContract / 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)
*
* Storage helpers (loadStoredContractId, saveContractId, …) and the owner
* lookup live in contractStorage.ts so they can be imported without
* pulling the @dashevo/evo-sdk runtime into the entry bundle.
*
* SDK methods: new DataContract({ ... }), sdk.contracts.publish(...)
*/
import { DataContract } from "@dashevo/evo-sdk";
import { loadStoredContractId, saveContractId } from "./contractStorage";
import type { Logger } from "./logger";
import type { DashKeyManager, DashSdk } from "./types";
export {
DEFAULT_CONTRACT_ID,
clearStoredContractId,
fetchContractOwnerId,
loadStoredContractId,
saveContractId,
} from "./contractStorage";
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;
/**
* 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<string> {
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<string> {
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.
Try the same operations headlessly from Node using the tutorials in Contracts and documents.
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.