diff --git a/_static/dashmint-lite.html b/_static/dashmint-lite.html new file mode 100644 index 000000000..41de9f965 --- /dev/null +++ b/_static/dashmint-lite.html @@ -0,0 +1,324 @@ + + + + + + + DashMint Lite + + + + + + +
+ + +
+
+

Browse cards

+

Read-only listing of NFT cards from the card data contract on testnet.

+
+ + + +
+
+
+ +

+ Contract: +

+
+
+ + + + diff --git a/_static/dashnote-lite.html b/_static/dashnote-lite.html new file mode 100644 index 000000000..4227bc038 --- /dev/null +++ b/_static/dashnote-lite.html @@ -0,0 +1,394 @@ + + + + + + + DashNote Lite + + + + + + +
+ + +
+ +
+

Recent notes

+

The latest notes on the contract. Optionally filter by an owner identity ID.

+
+ + +
+
+
+ +
+

Get note by ID

+

Fetch a single note document by its $id.

+ +
+ +
+
+
+ +

+ Contract: +

+
+
+ + + + diff --git a/_static/dashproof-lite.html b/_static/dashproof-lite.html new file mode 100644 index 000000000..ef53352f0 --- /dev/null +++ b/_static/dashproof-lite.html @@ -0,0 +1,350 @@ + + + + + + + DashProof Lite + + + + + + +
+ + +
+ +
+

Verify a file

+

Hashes the file locally with SHA-256, then looks up the digest on-chain. The file never leaves your browser.

+ +
+
+
+ +
+

History by chainId

+

List all anchors recorded under a given chainId bucket.

+ +
+ +
+
+
+ +

+ Contract: +

+
+
+ + + + diff --git a/conf.py b/conf.py index 3470600e9..f4814c927 100644 --- a/conf.py +++ b/conf.py @@ -97,6 +97,9 @@ # html_sidebars = { "index": ["sidebar-main.html"], + "docs/tutorials/example-apps/dashmint-lite": [], + "docs/tutorials/example-apps/dashnote-lite": [], + "docs/tutorials/example-apps/dashproof-lite": [], "**": ["sidebar-nav-bs"] } diff --git a/docs/index.md b/docs/index.md index d3baf594d..7c9f81501 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,16 @@ Looking for the current source tree or lower-level implementation details? See t +++ :ref:`Click to begin ` + + .. grid-item-card:: 🧩 Example apps + :margin: 2 2 auto auto + :link-type: ref + :link: tutorials-example-apps + + End-to-end SDK walkthroughs (DashMint Lab, Dashnote) + + +++ + :ref:`Browse apps ` ``` ```{toctree} diff --git a/docs/tutorials/contracts-and-documents/delete-documents.md b/docs/tutorials/contracts-and-documents/delete-documents.md index 68840b106..b306d6852 100644 --- a/docs/tutorials/contracts-and-documents/delete-documents.md +++ b/docs/tutorials/contracts-and-documents/delete-documents.md @@ -61,3 +61,7 @@ Internally, the method creates a [State Transition](../../explanations/platform- :::{note} You do not need to retrieve the full document before deleting it. The `sdk.documents.delete()` method only requires the document's identifying fields (`id`, `ownerId`, `dataContractId`, `documentTypeName`). ::: + +:::{tip} +See this in an example app: [Dashnote — Delete a note](../example-apps/dashnote.md#delete-a-note) and [DashMint Lab — Burn a card](../example-apps/dashmint-lab.md#burn-a-card). +::: diff --git a/docs/tutorials/contracts-and-documents/register-a-data-contract.md b/docs/tutorials/contracts-and-documents/register-a-data-contract.md index 094dc8033..5b5c2696f 100644 --- a/docs/tutorials/contracts-and-documents/register-a-data-contract.md +++ b/docs/tutorials/contracts-and-documents/register-a-data-contract.md @@ -618,3 +618,7 @@ Make a note of the returned data contract ID as it will be used in subsequent tu After we initialize the client, we get the auth key signer from the key manager. We then define the document schemas for our contract (e.g. a `note` document). To create the contract, we first fetch the identity's current nonce and increment it. We then create a `DataContract` object with the owner identity, nonce, and document schemas. Finally, we call `sdk.contracts.publish()` with the contract and signing credentials to submit it to the network. + +:::{tip} +See this in an example app: [Dashnote — Contract schema](../example-apps/dashnote.md#contract-schema) and [DashMint Lab — Contract schema](../example-apps/dashmint-lab.md#contract-schema). +::: diff --git a/docs/tutorials/contracts-and-documents/retrieve-documents.md b/docs/tutorials/contracts-and-documents/retrieve-documents.md index 7a4636f08..21121f09e 100644 --- a/docs/tutorials/contracts-and-documents/retrieve-documents.md +++ b/docs/tutorials/contracts-and-documents/retrieve-documents.md @@ -82,3 +82,7 @@ After we initialize the Client, we request documents using `sdk.documents.query( Results are returned as a `Map` where each key is a document ID and each value is the document object. We iterate over the entries using `for (const [id, doc] of results)`. If you need more than the default number of documents, use the `startAt` or `startAfter` parameters for pagination. + +:::{tip} +See this in an example app: [Dashnote — Read path: queries first](../example-apps/dashnote.md#read-path-queries-first) and [DashMint Lab — Read path: queries first](../example-apps/dashmint-lab.md#read-path-queries-first). +::: diff --git a/docs/tutorials/contracts-and-documents/submit-documents.md b/docs/tutorials/contracts-and-documents/submit-documents.md index 390c901ff..f86b99b9d 100644 --- a/docs/tutorials/contracts-and-documents/submit-documents.md +++ b/docs/tutorials/contracts-and-documents/submit-documents.md @@ -59,3 +59,7 @@ try { After we initialize the Client via `setupDashClient()`, we get the auth key signer from the key manager. We then create a `Document` object with the properties defined by the data contract (e.g. a `message` for the `note` document type), along with the contract ID and document type name. The `sdk.documents.create()` method takes the document and signing credentials. Internally, it creates a [State Transition](../../explanations/platform-protocol-state-transition.md) containing the document, signs the state transition, and submits it to DAPI. + +:::{tip} +See this in an example app: [Dashnote — Create a note](../example-apps/dashnote.md#create-a-note) and [DashMint Lab — Mint a card](../example-apps/dashmint-lab.md#mint-a-card). +::: diff --git a/docs/tutorials/contracts-and-documents/update-documents.md b/docs/tutorials/contracts-and-documents/update-documents.md index 5c2fd68d3..5543d0c54 100644 --- a/docs/tutorials/contracts-and-documents/update-documents.md +++ b/docs/tutorials/contracts-and-documents/update-documents.md @@ -80,3 +80,7 @@ The `sdk.documents.replace()` method takes the document and signing credentials. :::{note} The SDK requires constructing a complete replacement `Document` with all fields — including the document `id` and incremented `revision`. The example above queries the existing document first to determine the current revision. ::: + +:::{tip} +See this in an example app: [Dashnote — Update a note](../example-apps/dashnote.md#update-a-note) is the canonical fetch → bump revision → replace example. +::: diff --git a/docs/tutorials/example-apps.md b/docs/tutorials/example-apps.md index a59054012..7de41a5f5 100644 --- a/docs/tutorials/example-apps.md +++ b/docs/tutorials/example-apps.md @@ -1,5 +1,5 @@ ```{eval-rst} -.. tutorials-example-apps: +.. _tutorials-example-apps: ``` # Example apps @@ -15,4 +15,8 @@ If you are looking for a focused snippet for one SDK call, the per-operation tut :titlesonly: example-apps/dashmint-lab +example-apps/dashnote +example-apps/dashmint-lite +example-apps/dashnote-lite +example-apps/dashproof-lite ``` diff --git a/docs/tutorials/example-apps/dashmint-lab.md b/docs/tutorials/example-apps/dashmint-lab.md index 719ee0764..226ffd1ea 100644 --- a/docs/tutorials/example-apps/dashmint-lab.md +++ b/docs/tutorials/example-apps/dashmint-lab.md @@ -671,7 +671,7 @@ The card data contract defines one document type (`card`) with four fields and t :name: dashmint-contract.ts /** - * NFT card data contract schema + ensureContract(). + * 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 @@ -683,13 +683,26 @@ The card data contract defines one document type (`card`) with four fields and t * 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", @@ -734,49 +747,6 @@ export const CARD_SCHEMAS = { }, } 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. * diff --git a/docs/tutorials/example-apps/dashmint-lite.md b/docs/tutorials/example-apps/dashmint-lite.md new file mode 100644 index 000000000..9a7645e5f --- /dev/null +++ b/docs/tutorials/example-apps/dashmint-lite.md @@ -0,0 +1,25 @@ +--- +html_theme.sidebar_secondary.remove: true +--- + +```{raw} html + +``` + +# DashMint Lite + +The app embedded below is a live, read-only version of DashMint Lite running against testnet — try +it out directly in this page. + +It's a stripped-down companion to the full [DashMint Lab](dashmint-lab.md) NFT example app: browse +the cards minted on the network, with an optional "Marketplace only" filter for cards that have a +sale price set. No wallet, identity, or signing required. Writes (mint, transfer, price, purchase, +burn) aren't wired up here; for the walkthrough of those SDK calls, see the [DashMint Lab +tutorial](dashmint-lab.md). + +```{raw} html + diff --git a/docs/tutorials/example-apps/dashnote-lite.md b/docs/tutorials/example-apps/dashnote-lite.md new file mode 100644 index 000000000..a6ef04d7a --- /dev/null +++ b/docs/tutorials/example-apps/dashnote-lite.md @@ -0,0 +1,25 @@ +--- +html_theme.sidebar_secondary.remove: true +--- + +```{raw} html + +``` + +# Dashnote Lite + +The app embedded below is a live, read-only version of Dashnote running against testnet — try +it out directly in this page. + +It's a stripped-down companion to the full [Dashnote](dashnote.md) notes example app: browse the +recent notes for any identity ID, or fetch a single note by document ID, against the bundled +default contract. No wallet, identity, or signing required. Writes (create, update, delete) aren't +wired up here; for the walkthrough of those SDK calls, see the [Dashnote tutorial](dashnote.md). + +```{raw} html + +``` diff --git a/docs/tutorials/example-apps/dashnote.md b/docs/tutorials/example-apps/dashnote.md new file mode 100644 index 000000000..144409a49 --- /dev/null +++ b/docs/tutorials/example-apps/dashnote.md @@ -0,0 +1,611 @@ +```{eval-rst} +.. _tutorials-example-apps-dashnote: +``` + +# Dashnote + +[Dashnote](https://dashpay.github.io/platform-tutorials/dashnote/) is a React + TypeScript + Vite single-page app that demonstrates full mutable-document CRUD on Dash Platform. Users log in with a BIP-39 mnemonic, create notes with an optional title and required message body, edit and delete their own notes, and browse any identity's notes in read-only mode without credentials. + +Where [DashMint Lab](dashmint-lab.md) covers the NFT-shaped operations (mint, transfer, price, purchase, burn), Dashnote covers the everyday document lifecycle: create, query, update, delete — plus the **fetch-then-bump-revision** pattern that every Platform document update has to follow. + +![Dashnote](./img/dashnote.png) + +## What this app does + +The app ships with a bundled note contract so the read path works on a fresh install without registering anything. After login, users can create notes (optional `title`, required `message`), edit them in place, and delete them. The editor reports field length in **UTF-8 bytes** rather than characters, because that's what Platform's `maxLength` constraint actually checks. A localStorage-backed cache paints the recent-notes list instantly on reload while a background query revalidates against the network. + +For background on data contracts and document state transitions, see [Submit documents](../contracts-and-documents/submit-documents.md), [Update documents](../contracts-and-documents/update-documents.md), and [Delete documents](../contracts-and-documents/delete-documents.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. Like DashMint, Dashnote imports the same `setupDashClient-core.mjs` module 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 note operation lives in its own `src/dash/*.ts` file. +- The easiest entry points are `src/dash/queries.ts`, `src/dash/createNote.ts`, and `src/dash/updateNote.ts`. +- The central pattern is **fetch → bump revision → replace** in `updateNote.ts`. +- `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 `createNote.ts`, then `updateNote.ts` to see how revision bumping works. + +## Prerequisites + +- [General prerequisites](../introduction.md#prerequisites) (Node.js / Dash SDK installed) +- A configured client: [Setup SDK Client](../setup-sdk-client.md) — Dashnote 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) +- Node >= 20 and a funded testnet identity (BIP-39 mnemonic + identity index) for write operations +- Read-only browse works without any credentials against the bundled default contract + +## Clone and run + +```bash +git clone https://github.com/dashpay/platform-tutorials.git +cd platform-tutorials/example-apps/dashnote +npm install +npm run dev +``` + +The dev server runs on `http://localhost:5173`. Open it in a browser to browse notes in read-only mode immediately, or click **Login** and paste your testnet mnemonic to start creating and editing notes. + +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` | +| Register note contract | `src/dash/contract.ts` | `sdk.contracts.publish` | +| Create a note | `src/dash/createNote.ts` | `sdk.documents.create` | +| Update a note | `src/dash/updateNote.ts` | `sdk.documents.get` + `sdk.documents.replace` | +| Delete a note | `src/dash/deleteNote.ts` | `sdk.documents.delete` | +| List notes | `src/dash/queries.ts` | `sdk.documents.query` | +| Get one note | `src/dash/queries.ts` | `sdk.documents.get` | + +Three supporting files glue the operations together: + +- `src/dash/types.ts` — shared SDK types (`DashSdk`, `DashKeyManager`, query result shapes) wired through every dash helper. +- `src/lib/logger.ts` — `Logger` function type plus `errorMessage(err)`. Plumbed through every dash call so progress streams to the activity log; `level: "success" | "error"` raises sonner toasts. +- `src/lib/notesCache.ts` — localStorage-backed note list keyed by `identityId + contractId + network`. Powers optimistic paint on reload before background revalidation completes. + +`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. + +## The update pattern + +Every update on an existing Platform document follows the same three steps: + +1. Fetch the current on-chain document so you can read its revision. +2. Set `revision = BigInt(existing.revision ?? 0) + 1n`. Platform rejects state transitions that don't strictly increase the revision. +3. Call `sdk.documents.replace` with the new document body and the bumped revision. + +This is why every Platform UI that lets users edit content needs to fetch before writing — there is no blind update. Dashnote's `updateNote.ts` is the canonical example, walked through in [Update a note](#update-a-note) below. + +## Read path: queries first + +If you want to understand how data shows up in the UI, start with `src/dash/queries.ts`. The recent-notes list uses `sdk.documents.query` with the `byOwnerUpdated` index, then sorts the results client-side by `$updatedAt` descending so the most recently edited note appears first. `normalizeNotes()` flattens the three possible shapes the SDK can return (array, `Map`, or plain object) into a flat list of `NoteRecord` values the UI can render. + +`getNote()` is the single-document fetch used by the editor pane and by [Dashnote Lite's](dashnote-lite.md) "Get by ID" tab. + +```{code-block} typescript +:caption: queries.ts +:name: dashnote-queries.ts + +/** + * Read-side queries against the note contract. + * + * SDK methods: + * sdk.documents.query({ dataContractId, documentTypeName, where, orderBy, limit }) + * sdk.documents.get(contractId, documentTypeName, documentId) + */ +import type { Logger } from "../lib/logger"; +import type { + DashDocumentLike, + DashNoteQueryDocument, + DashNoteQueryJson, + DashNoteQueryResults, + DashSdk, +} from "./types"; + +const MAX_QUERY_LIMIT = 100; + +export interface NoteRecord { + id: string; + ownerId: string; + title: string | null; + message: string; + createdAt: number | null; + updatedAt: number | null; + revision: number; +} + +function toTimestamp( + value: DashNoteQueryJson["$createdAt"] | DashNoteQueryJson["$updatedAt"], +): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string" && value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function toRevision( + value: number | string | bigint | undefined, + fallback?: number | string | bigint, +): number { + const raw = value ?? fallback; + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "bigint") return Number(raw); + if (typeof raw === "string" && raw) { + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function toNote(id: string | null, raw: DashNoteQueryDocument): NoteRecord { + const json: DashNoteQueryJson = + typeof raw?.toJSON === "function" ? raw.toJSON() : raw; + return { + id: String(id ?? json.$id ?? json.id ?? ""), + ownerId: String(json.$ownerId ?? ""), + title: typeof json.title === "string" ? json.title : null, + message: typeof json.message === "string" ? json.message : "", + createdAt: toTimestamp(json.$createdAt), + updatedAt: toTimestamp(json.$updatedAt), + revision: toRevision(json.$revision, raw.revision), + }; +} + +export function normalizeNotes(results: DashNoteQueryResults): NoteRecord[] { + if (Array.isArray(results)) { + return results + .filter(Boolean) + .map((doc) => toNote(null, doc as DashNoteQueryDocument)); + } + const entries = + results instanceof Map ? Object.fromEntries(results) : results; + return Object.entries(entries) + .filter(([, doc]) => Boolean(doc)) + .map(([id, doc]) => toNote(id, doc as DashNoteQueryDocument)); +} + +export function normalizeSingleNote( + id: string, + raw: DashDocumentLike | undefined, +): NoteRecord | null { + if (!raw) return null; + return toNote(id, raw as DashNoteQueryDocument); +} + +export async function listMyNotes({ + sdk, + contractId, + ownerId, + limit = MAX_QUERY_LIMIT, + log, +}: { + sdk: DashSdk; + contractId: string; + ownerId: string; + limit?: number; + log?: Logger; +}): Promise { + log?.("Loading your notes…"); + const results = await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: "note", + where: [["$ownerId", "==", ownerId]], + orderBy: [ + ["$ownerId", "asc"], + ["$updatedAt", "asc"], + ], + limit, + }); + + return normalizeNotes(results).sort( + (left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0), + ); +} + +export async function getNote({ + sdk, + contractId, + noteId, + log, +}: { + sdk: DashSdk; + contractId: string; + noteId: string; + log?: Logger; +}): Promise { + log?.(`Loading note ${noteId}…`); + const result = await sdk.documents.get(contractId, "note", noteId); + return normalizeSingleNote(noteId, result); +} +``` + +## Operation walkthrough + +Each operation file is intentionally small. The app-level pattern is: validate input, build a `Document` (and fetch the existing one when updating), call one SDK method, then log the result. + +### Create a note + +`createNote.ts` is the simplest write in the app: build a new `Document`, hand it to `sdk.documents.create`, and pull the new note's ID off the returned document. The contract requires `message`; `title` is optional and only included when non-empty after trimming. + +```{code-block} typescript +:caption: createNote.ts +:name: dashnote-createNote.ts + +/** + * Create a new note document. + * + * SDK method: sdk.documents.create({ document, identityKey, signer }) + */ +import type { Logger } from "../lib/logger"; +import { loadSdkModule } from "./sdkModule"; +import type { DashKeyManager, DashSdk } from "./types"; + +export interface CreateNoteParams { + sdk: DashSdk; + keyManager: DashKeyManager; + contractId: string; + title?: string; + message: string; + log?: Logger; +} + +export async function createNote({ + sdk, + keyManager, + contractId, + title, + message, + log, +}: CreateNoteParams): Promise { + log?.("Creating note…"); + const { identity, identityKey, signer } = await keyManager.getAuth(); + const { Document } = await loadSdkModule(); + const trimmedTitle = title?.trim(); + const document = new Document({ + properties: { + ...(trimmedTitle ? { title: trimmedTitle } : {}), + message, + }, + documentTypeName: "note", + dataContractId: contractId, + ownerId: identity.id, + }); + + await sdk.documents.create({ + document, + identityKey, + signer, + }); + + const json = + typeof document.toJSON === "function" + ? (document.toJSON() as Record) + : {}; + const noteId = String(json.$id ?? json.id ?? ""); + if (!noteId) { + throw new Error("Created note returned no ID."); + } + log?.("Note created.", "success"); + return noteId; +} +``` + +### Update a note + +`updateNote.ts` is the canonical fetch-then-bump-revision write. It calls `sdk.documents.get` to read the on-chain revision, increments it by one, builds a new `Document` with the same id and ownerId, and submits via `sdk.documents.replace`. Replays without bumping the revision are rejected by the state transition. + +```{code-block} typescript +:caption: updateNote.ts +:name: dashnote-updateNote.ts + +/** + * Update an existing note. Fetches the current document to bump its revision, + * then submits a replace state transition. + * + * SDK methods: + * sdk.documents.get(contractId, documentTypeName, documentId) + * sdk.documents.replace({ document, identityKey, signer }) + */ +import type { Logger } from "../lib/logger"; +import { loadSdkModule } from "./sdkModule"; +import type { DashKeyManager, DashSdk } from "./types"; + +export interface UpdateNoteParams { + sdk: DashSdk; + keyManager: DashKeyManager; + contractId: string; + noteId: string; + title?: string; + message: string; + log?: Logger; +} + +export async function updateNote({ + sdk, + keyManager, + contractId, + noteId, + title, + message, + log, +}: UpdateNoteParams): Promise { + log?.(`Saving note ${noteId}…`); + const { identity, identityKey, signer } = await keyManager.getAuth(); + const existingDoc = await sdk.documents.get(contractId, "note", noteId); + if (!existingDoc) { + throw new Error(`Note ${noteId} not found.`); + } + + const { Document } = await loadSdkModule(); + const revision = BigInt(existingDoc.revision ?? 0) + 1n; + const trimmedTitle = title?.trim(); + const document = new Document({ + properties: { + ...(trimmedTitle ? { title: trimmedTitle } : {}), + message, + }, + documentTypeName: "note", + dataContractId: contractId, + ownerId: identity.id, + revision, + id: noteId, + }); + + await sdk.documents.replace({ + document, + identityKey, + signer, + }); + log?.("Note saved.", "success"); +} +``` + +### Delete a note + +`deleteNote.ts` is the inverse of create — no fetch needed. `sdk.documents.delete` only needs enough identifying fields (id, ownerId, dataContractId, documentTypeName), not a full document, so we can skip the round-trip. + +```{code-block} typescript +:caption: deleteNote.ts +:name: dashnote-deleteNote.ts + +/** + * Delete a note document. + * + * SDK method: sdk.documents.delete({ document, identityKey, signer }) + */ +import type { Logger } from "../lib/logger"; +import type { DashKeyManager, DashSdk } from "./types"; + +export interface DeleteNoteParams { + sdk: DashSdk; + keyManager: DashKeyManager; + contractId: string; + noteId: string; + log?: Logger; +} + +export async function deleteNote({ + sdk, + keyManager, + contractId, + noteId, + log, +}: DeleteNoteParams): Promise { + log?.(`Deleting note ${noteId}…`); + const { identity, identityKey, signer } = await keyManager.getAuth(); + await sdk.documents.delete({ + document: { + id: noteId, + ownerId: identity.id, + dataContractId: contractId, + documentTypeName: "note", + }, + identityKey, + signer, + }); + log?.("Note deleted.", "success"); +} +``` + +## Contract schema + +The note contract is intentionally minimal: one document type, two user-editable fields, two indices to support the recent-notes list. Key choices worth calling out: + +- `documentsMutable: true` and `canBeDeleted: true` — notes are editable and deletable. +- `maxLength: 120` for `title` and `maxLength: 10000` for `message` are **UTF-8 byte budgets**, not character counts. The editor's progress bar reflects bytes; emoji and non-ASCII sequences consume more of the budget than ASCII. +- `byOwnerUpdated` (`$ownerId`, `$updatedAt`) is the index the recent-notes list paginates on; `byOwnerCreated` is its created-time sibling. + +`registerContract` builds the `DataContract`, calls `setConfig()` to lock in those choices, then publishes via `sdk.contracts.publish`. `ensureContract` is the lazy wrapper used by the login flow: re-use a saved contract ID if one is present, otherwise register a fresh one. + +```{code-block} typescript +:caption: contract.ts +:name: dashnote-contract.ts + +/** + * Note data contract: schema definition + registration. + * + * SDK methods: + * sdk.contracts.publish({ dataContract, identityKey, signer }) + * sdk.identities.nonce(identityId) + */ +import type { Logger } from "../lib/logger"; +import { loadSdkModule } from "./sdkModule"; +import type { DashKeyManager, DashSdk } from "./types"; + +export const NOTE_SCHEMAS = { + note: { + type: "object", + documentsMutable: true, + canBeDeleted: true, + properties: { + title: { + type: "string", + maxLength: 120, + position: 0, + }, + message: { + type: "string", + maxLength: 10000, + position: 1, + }, + }, + required: ["$createdAt", "$updatedAt", "message"], + additionalProperties: false, + indices: [ + { + name: "byOwnerUpdated", + properties: [{ $ownerId: "asc" }, { $updatedAt: "asc" }], + }, + { + name: "byOwnerCreated", + properties: [{ $ownerId: "asc" }, { $createdAt: "asc" }], + }, + ], + }, +} as const; + +const STORAGE_KEY = "dashnote.contractId"; + +/** + * Default contract ID baked into the tutorial so the notebook UI works on a + * fresh machine without registering a contract first. Users can override it + * in Settings or register their own. + */ +export const DEFAULT_CONTRACT_ID = + "8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y"; + +export function loadStoredContractId(): string | null { + try { + return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_CONTRACT_ID; + } catch { + return DEFAULT_CONTRACT_ID; + } +} + +export function saveContractId(id: string): void { + localStorage.setItem(STORAGE_KEY, id); +} + +export function clearStoredContractId(): void { + localStorage.removeItem(STORAGE_KEY); +} + +export async function refreshContractCache({ + sdk, + contractId, +}: { + sdk: DashSdk; + contractId: string; +}): Promise { + if (!contractId || typeof sdk.getWasmSdkConnected !== "function") return; + const wasm = await sdk.getWasmSdkConnected(); + if (!wasm || typeof wasm.removeCachedContract !== "function") return; + const { Identifier } = await loadSdkModule(); + const identifier = new Identifier(contractId); + try { + wasm.removeCachedContract(identifier); + } finally { + identifier.free?.(); + } +} + +export async function registerContract({ + sdk, + keyManager, + log, +}: { + sdk: DashSdk; + keyManager: DashKeyManager; + log?: Logger; +}): Promise { + log?.("Registering Dashnote note contract…"); + const { identity, identityKey, signer } = await keyManager.getAuth(); + const identityNonce = await sdk.identities.nonce(identity.id.toString()); + const { DataContract } = await loadSdkModule(); + const dataContract = new DataContract({ + ownerId: identity.id, + identityNonce: (identityNonce || 0n) + 1n, + schemas: NOTE_SCHEMAS, + fullValidation: true, + }); + + ( + dataContract as unknown as { + setConfig: (config: Record) => void; + } + ).setConfig({ + canBeDeleted: false, + readonly: false, + // Must stay false: keepsHistory: true triggers dashpay/platform#3165 — + // sdk.contracts.fetch() returns undefined, breaking sdk.documents.query + // with "Data contract not found". + keepsHistory: false, + documentsKeepHistoryContractDefault: false, + documentsMutableContractDefault: true, + documentsCanBeDeletedContractDefault: true, + }); + + 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."); + } + + saveContractId(contractId); + log?.(`Dashnote contract registered: ${contractId}`, "success"); + return contractId; +} + +export async function ensureContract({ + sdk, + keyManager, + existingId, + log, +}: { + sdk: DashSdk; + keyManager: DashKeyManager; + existingId?: string | null; + log?: Logger; +}): Promise { + const reused = existingId ?? loadStoredContractId(); + if (reused) { + log?.(`Using saved contract ID: ${reused}`); + return reused; + } + return registerContract({ sdk, keyManager, log }); +} +``` + +## Notes cache and optimistic UI + +`src/lib/notesCache.ts` stores the most recent query result in `localStorage` keyed by `identityId + contractId + network`. On reload, the workspace paints from the cache immediately, then issues a background `listMyNotes` query and reconciles. Switching identity, contract, or network invalidates the cache because the key changes. Each cached payload includes a `SCHEMA_VERSION` field; entries written by older versions are ignored on read. + +This pattern is generally useful for any UI built on `sdk.documents.query`: the queries themselves are not free, and users tolerate stale-then-fresh much better than blank-then-fresh. + +## Next steps + +- Try [Dashnote Lite](dashnote-lite.md) for a zero-build, single-file read-only version that runs the SDK straight from a CDN. +- Run 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 document 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/dashproof-lite.md b/docs/tutorials/example-apps/dashproof-lite.md new file mode 100644 index 000000000..56ed2cfc2 --- /dev/null +++ b/docs/tutorials/example-apps/dashproof-lite.md @@ -0,0 +1,25 @@ +--- +html_theme.sidebar_secondary.remove: true +--- + +```{raw} html + +``` + +# DashProof Lite + +The app embedded below is a live, read-only proof-of-existence demo running against testnet — try it +out directly in this page. + +Drop a file into "Verify a file" and the page hashes it locally with SHA-256 (the file never leaves +your browser), then looks the digest up on-chain to show whether — and when — it was anchored.The +"History" tab lists every anchor recorded under a given `chainId` bucket (try "dashcore-23-1-2"). +Anchoring (writing) new files isn't wired up here; this companion app just reads what's already on +testnet. + +```{raw} html + diff --git a/docs/tutorials/example-apps/img/dashnote.png b/docs/tutorials/example-apps/img/dashnote.png new file mode 100644 index 000000000..cea1738a5 Binary files /dev/null and b/docs/tutorials/example-apps/img/dashnote.png differ diff --git a/docs/tutorials/introduction.md b/docs/tutorials/introduction.md index 00496969e..bf56233c1 100644 --- a/docs/tutorials/introduction.md +++ b/docs/tutorials/introduction.md @@ -29,3 +29,12 @@ While going through each tutorial is advantageous, the subset of tutorials liste - [Registering an Identity](../tutorials/identities-and-names/register-an-identity.md) - [Registering a Data Contract](../tutorials/contracts-and-documents/register-a-data-contract.md) - [Submitting data](../tutorials/contracts-and-documents/submit-documents.md) + +## Example apps + +For end-to-end walkthroughs of example apps built on the SDK, see: + +- [DashMint Lab](example-apps/dashmint-lab.md) — NFT marketplace exercising mint / transfer / price / purchase / burn +- [Dashnote](example-apps/dashnote.md) — personal notes app demonstrating the document CRUD lifecycle + +Each app's source lives in the [`platform-tutorials`](https://github.com/dashpay/platform-tutorials/tree/main/example-apps) repo. diff --git a/scripts/tutorial-sync/sync_tutorial_code.py b/scripts/tutorial-sync/sync_tutorial_code.py index 0f2c6ccf2..43f26adf0 100644 --- a/scripts/tutorial-sync/sync_tutorial_code.py +++ b/scripts/tutorial-sync/sync_tutorial_code.py @@ -192,6 +192,51 @@ def find_block(content: str, block_id: dict, language: str): # Main logic # --------------------------------------------------------------------------- +def process_copy_mapping(entry: dict, source_root: Path, mode: str): + """Whole-file copy from platform-tutorials into an arbitrary repo path. + + Used for standalone embed apps (e.g. _static/dashmint-lite.html) where + there's no inline code block to splice — we just mirror the file. + Returns one of "match", "mismatch", "error". + """ + if "dest" not in entry: + print(f" ERROR {entry.get('source', '?')}: copy mapping missing 'dest'") + return "error" + + src = source_root / entry["source"] + dst = PROJECT_ROOT / entry["dest"] + label = f"{entry['source']} -> {entry['dest']}" + + if not src.is_file(): + print(f" ERROR {label}: source file not found: {src}") + return "error" + + src_text = src.read_text(encoding="utf-8") + dst_text = dst.read_text(encoding="utf-8") if dst.is_file() else "" + + if src_text == dst_text: + print(f" MATCH {label}") + return "match" + + if mode == "diff": + print(f" DIFF {label}") + diff = difflib.unified_diff( + dst_text.splitlines(keepends=True), + src_text.splitlines(keepends=True), + fromfile=f"docs: {entry['dest']}", + tofile=f"src: {entry['source']}", + ) + sys.stdout.writelines(" " + line for line in diff) + print() + elif mode == "check": + print(f" DRIFT {label}") + else: + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src_text, encoding="utf-8") + print(f" SYNCED {label}") + return "mismatch" + + def process_mappings(config: dict, source_root: Path, mode: str): """Process all mappings. Returns (matched, mismatched, errors) counts.""" docs_root = PROJECT_ROOT / config["docs_root"] @@ -200,6 +245,26 @@ def process_mappings(config: dict, source_root: Path, mode: str): errors = 0 for entry in config["mappings"]: + kind = entry.get("kind", "inline") + if kind == "copy": + result = process_copy_mapping(entry, source_root, mode) + if result == "match": + matched += 1 + elif result == "mismatch": + mismatched += 1 + else: + errors += 1 + continue + if kind != "inline": + print(f" ERROR {entry.get('source', '?')}: unknown kind: {kind}") + errors += 1 + continue + + if "doc" not in entry or "block_id" not in entry: + print(f" ERROR {entry.get('source', '?')}: inline mapping requires 'doc' and 'block_id'") + errors += 1 + continue + source_path = source_root / entry["source"] doc_path = docs_root / entry["doc"] block_id = entry["block_id"] diff --git a/scripts/tutorial-sync/tutorial-code-map.yml b/scripts/tutorial-sync/tutorial-code-map.yml index c588ca721..56e9aba3e 100644 --- a/scripts/tutorial-sync/tutorial-code-map.yml +++ b/scripts/tutorial-sync/tutorial-code-map.yml @@ -1,12 +1,21 @@ # Maps platform-tutorials source files to inline code blocks in docs. # -# source: path relative to platform-tutorials root -# doc: path relative to docs/tutorials/ -# block_id: how to locate the code block in the markdown: -# caption: match :caption: value in {code-block} directives -# sync: match :sync: value in tab-items (use with language filter) -# tab: match tab-item title text (use with language filter) -# language: fenced block language (default: javascript) +# kind: "inline" (default) splices source into a code block in a doc page. +# "copy" mirrors the whole source file to `dest` (relative to repo root) — +# used for standalone embed apps in _static/. +# +# Inline mappings (default): +# source: path relative to platform-tutorials root +# doc: path relative to docs/tutorials/ +# block_id: how to locate the code block in the markdown: +# caption: match :caption: value in {code-block} directives +# sync: match :sync: value in tab-items (use with language filter) +# tab: match tab-item title text (use with language filter) +# language: fenced block language (default: javascript) +# +# Copy mappings (kind: copy): +# source: path relative to platform-tutorials root +# dest: path relative to this repo's root docs_root: docs/tutorials @@ -231,3 +240,50 @@ mappings: block_id: caption: queries.ts language: typescript + + # Dashnote — React + TypeScript notes app. Same per-operation file + # convention as DashMint Lab; each src/dash/*.ts file has a matching + # code block in the walkthrough with a :caption: equal to the bare filename. + - source: example-apps/dashnote/src/dash/contract.ts + doc: example-apps/dashnote.md + block_id: + caption: contract.ts + language: typescript + + - source: example-apps/dashnote/src/dash/queries.ts + doc: example-apps/dashnote.md + block_id: + caption: queries.ts + language: typescript + + - source: example-apps/dashnote/src/dash/createNote.ts + doc: example-apps/dashnote.md + block_id: + caption: createNote.ts + language: typescript + + - source: example-apps/dashnote/src/dash/updateNote.ts + doc: example-apps/dashnote.md + block_id: + caption: updateNote.ts + language: typescript + + - source: example-apps/dashnote/src/dash/deleteNote.ts + doc: example-apps/dashnote.md + block_id: + caption: deleteNote.ts + language: typescript + + # -- Standalone embed apps (whole-file copy, no inline block surgery) -- + + - kind: copy + source: example-apps/dashmint-lab/public/dashmint-lite.html + dest: _static/dashmint-lite.html + + - kind: copy + source: example-apps/dashproof-lab/public/dashproof-lite.html + dest: _static/dashproof-lite.html + + - kind: copy + source: example-apps/dashnote/public/dashnote-lite.html + dest: _static/dashnote-lite.html