Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test-tutorials.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ jobs:
node -v
npm -v
- run: npm ci
- name: Run read-only tutorial tests
- name: Run PR-safe tests
env:
NETWORK: testnet
PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }}
run: npm run test:read-only
run: npm test

test-read-write:
runs-on: ubuntu-latest
Expand Down
19 changes: 15 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ Run `node view-wallet.mjs` to confirm the identity is found before proceeding.

```bash
nvm use # Use the repo's tested Node 22.22.x toolchain
npm test # Read-only tests (~2min, safe to run anytime)
npm test # All PR-safe suites (~2min, safe to run anytime)
npm run test:read-only # Just the tutorial subprocess suite
npm run test:read-write # Write tests (destructive, consumes testnet credits, ~5min)
npm run test:all # Both suites sequentially
npm run test:all # Every suite sequentially
npm run test:setup # Mocha tests for setupDashClient configuration

npm run lint # TypeScript type-check all JS files (tsc)
Expand Down Expand Up @@ -52,6 +53,8 @@ node 1-Identities-and-Names/identity-retrieve.mjs
node --test --test-timeout=120000 test/read-only.test.mjs
```

**Protocol version:** `createClient()` deliberately leaves the platform protocol version unset so the SDK negotiates it with the network — it starts at a conservative version and ratchets up to whatever the network reports, capped at the newest version the SDK understands. Passing the `version` option pins it and disables negotiation outright, so a hardcoded value silently goes stale at the next network upgrade. Use `sdk.version()` when you need the negotiated value.

## Architecture

### Tutorial Structure
Expand Down Expand Up @@ -89,7 +92,7 @@ The central helper (~500 lines) that all tutorials import. It handles:

### Test Framework

Tests use Node.js built-in test runner. Each test runs a tutorial as a **subprocess** via `test/run-tutorial.mjs` and validates:
Tests use Node.js built-in test runner. The tutorial suites (`read-only`, `read-write`) run each tutorial as a **subprocess** via `test/run-tutorial.mjs` and validate:

- Exit code is 0
- `stdout`/`stderr` match expected regex patterns
Expand All @@ -99,6 +102,14 @@ Tests use Node.js built-in test runner. Each test runs a tutorial as a **subproc

**Read-write tests** maintain a shared state object to pass IDs (contract IDs, document IDs, etc.) between sequential dependent tests.

The remaining suites are repo invariants rather than tutorial runs, which is why they live in their own files:

| File | Checks | Network |
| - | - | - |
| `platform-version-config.test.mjs` | `createClient()` passes no `version` to the `*Trusted` factories, so the SDK negotiates the protocol version instead of using a pin | none (factories mocked) |
| `platform-version.test.mjs` | A live client settles on `min(network active version, SDK ceiling)` after its first proof-bearing read | testnet + mainnet |
| `lite-sdk-versions.test.mjs` | Each `*-lite.html` page imports exactly the `@dashevo/evo-sdk` version its companion app declares | none |

### Derivation Paths

All key derivation uses standard Dash paths. External wallets/tools must use the same paths for compatibility.
Expand Down Expand Up @@ -135,7 +146,7 @@ Read-only tests skip gracefully when `PLATFORM_MNEMONIC` is unset.
- **`1-Identities-and-Names/`** — identity registration, top-up, key management, DPNS name registration/lookup
- **`2-Contracts-and-Documents/`** — data contract variants (minimal, indexed, binary, timestamps, history, NFT), document CRUD, NFT operations
- **`3-Tokens/`** — token contract registration, info queries, minting, burning, and transfers
- **`test/`** — test runner, assertions, read-only and read-write test suites
- **`test/`** — test runner, assertions, tutorial suites (read-only, read-write), and repo-invariant suites (platform version, lite SDK versions)
- **`docs/`** — HTML/JS interactive tutorial runner (separate from Node tutorials)
- **`example-apps/`** — Standalone applications (Vite + React + TypeScript) that consume the tutorial SDK code. Each has its own `package.json`, tsconfig, and toolchain — the conventions in this file (Node16 modules, `airbnb-base`, etc.) describe the **root** tutorial code only and do not apply inside `example-apps/`. See each app's local `CLAUDE.md` for its conventions.

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,30 @@ setup instructions and dependencies but share repository-level SDK client helper
Tests run each tutorial as a subprocess and validate its output. No test framework dependencies are
required — tests use the Node.js built-in test runner.

Ensure your `.env` file is configured (see [`.env.example`](./.env.example)) before running tests.
`npm test` needs no wallet credentials. The tutorial suites do: configure your `.env` file (see
[`.env.example`](./.env.example)) before running `test:read-only` or `test:read-write` — without a
`PLATFORM_MNEMONIC` the read-only suite skips the tutorials that need one, and the write suite
cannot run at all.

```shell
# Read-only tests (default) — safe to run, no credits consumed
# Default — safe to run, no credits consumed
npm test

# Just the tutorial suite
npm run test:read-only
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Write tests — registers identities/contracts/documents (consumes testnet credits)
npm run test:read-write

# All tests
npm run test:all
```

Alongside the tutorial suite, `npm test` checks a couple of repository invariants that need no
credits: that the SDK client negotiates the platform protocol version rather than using a hardcoded
pin, and that each standalone `*-lite.html` page imports the same Evo SDK version as its companion
app.

### Importing an existing wallet

If you already have a Dash identity created with another tool (e.g. [Dash
Expand Down
11 changes: 5 additions & 6 deletions example-apps/dashmint-lab/public/dashmint-lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ <h2>Browse cards</h2>
// package and serves it as a browser-native ES module. Pinned to the same
// version the React app at ../package.json depends on so both UIs behave
// identically against the same testnet contract.
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.0.0';
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.1.0';

// The token-enabled "card" data contract is already published on testnet by
// the React app. Anyone querying with the same contract id hits the same
Expand All @@ -131,12 +131,11 @@ <h2>Browse cards</h2>
// nodes — no node URL or config needed. connect() does the gRPC handshake
// + initial sync. No identity or signing is required for read-only queries.
//
// Workaround: pin the platform protocol version for evo-sdk dev.6 so the
// SDK doesn't ask testnet for a newer protocol it can't decode. Mirrors
// PLATFORM_VERSION_OVERRIDE in setupDashClient-core.mjs. Remove once a
// fixed SDK release lands.
// The platform protocol version is left unset on purpose: the SDK negotiates
// it with the network. Passing `version` pins it and disables that, so a
// hardcoded value silently goes stale at the next network upgrade.
async function connectSdk() {
const sdk = EvoSDK.testnetTrusted({ version: 11 });
const sdk = EvoSDK.testnetTrusted();
await sdk.connect();
return sdk;
}
Expand Down
3 changes: 1 addition & 2 deletions example-apps/dashnote-starter/src/dash/createNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* SDK method: sdk.documents.create({ document, identityKey, signer })
*/
import type { Logger } from "../lib/logger";
import { PLATFORM_VERSION_OVERRIDE } from "../../../../platformVersion.mjs";
import { loadSdkModule } from "./sdkModule";
import type { DashKeyManager, DashSdk } from "./types";

Expand Down Expand Up @@ -47,7 +46,7 @@ export async function createNote({

const json =
typeof document.toJSON === "function"
? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record<string, unknown>)
? (document.toJSON(sdk.version()) as Record<string, unknown>)
: {};
const noteId = String(json.$id ?? json.id ?? "");
if (!noteId) {
Expand Down
2 changes: 2 additions & 0 deletions example-apps/dashnote-starter/src/dash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface DashDocumentLike {
}

export interface DashSdk {
/** Platform protocol version negotiated with the network. */
version(): number;
contracts: {
fetch(contractId: string): Promise<{
toJSON?: () => Record<string, unknown>;
Expand Down
2 changes: 1 addition & 1 deletion example-apps/dashnote/public/dashnote-lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ <h2>Get note by ID</h2>
// package and serves it as a browser-native ES module. Pinned to the same
// version the React app at ../package.json depends on so both UIs behave
// identically against the same testnet contract.
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.0.0';
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.1.0';

// The "note" data contract is already published on testnet by the React app.
// Anyone querying with the same contract id hits the same documents.
Expand Down
3 changes: 1 addition & 2 deletions example-apps/dashnote/src/dash/createNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* SDK method: sdk.documents.create({ document, identityKey, signer })
*/
import type { Logger } from "../lib/logger";
import { PLATFORM_VERSION_OVERRIDE } from "../../../../platformVersion.mjs";
import { loadSdkModule } from "./sdkModule";
import type { DashKeyManager, DashSdk } from "./types";

Expand Down Expand Up @@ -47,7 +46,7 @@ export async function createNote({

const json =
typeof document.toJSON === "function"
? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record<string, unknown>)
? (document.toJSON(sdk.version()) as Record<string, unknown>)
: {};
const noteId = String(json.$id ?? json.id ?? "");
if (!noteId) {
Expand Down
2 changes: 2 additions & 0 deletions example-apps/dashnote/src/dash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface DashDocumentLike {
}

export interface DashSdk {
/** Platform protocol version negotiated with the network. */
version(): number;
contracts: {
fetch(contractId: string): Promise<{
toJSON?: () => Record<string, unknown>;
Expand Down
2 changes: 2 additions & 0 deletions example-apps/dashnote/test/dash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function makeKeyManager() {
describe("createNote", () => {
it("creates a note with a trimmed title", async () => {
const sdk = {
version: () => 13,
documents: {
create: vi.fn().mockResolvedValue(undefined),
},
Expand Down Expand Up @@ -61,6 +62,7 @@ describe("createNote", () => {
it("omits a blank title for body-only notes", async () => {
mockDocumentConstructor.mockReset();
const sdk = {
version: () => 13,
documents: {
create: vi.fn().mockResolvedValue(undefined),
},
Expand Down
2 changes: 1 addition & 1 deletion example-apps/dashproof-lab/public/dashproof-lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ <h2>History by chainId</h2>
// package and serves it as a browser-native ES module. Pinned to the same
// version the React app at ../package.json depends on so both UIs behave
// identically against the same testnet contract.
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.0.0';
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.1.0';

// The "anchor" data contract is already published on testnet by the React app.
// Anyone querying with the same contract id hits the same documents.
Expand Down
2 changes: 1 addition & 1 deletion example-apps/dashrate/public/dashrate-lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ <h2 id="reviews-heading">Recent reviews</h2>
// The rest of the file (DOM wiring, render helpers) is plumbing.
// ============================================================================

import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.0.0';
import { EvoSDK } from 'https://esm.sh/@dashevo/evo-sdk@4.1.0';

const CONTRACT_ID = 'BdgTqaTAPYMyhp1WdeWdcvYSgoD7AuJ7tVCaCSXyQgyP';
const DOC_TYPE = 'review';
Expand Down
6 changes: 1 addition & 5 deletions example-apps/dashrate/src/dash/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* sdk.documents.get(...)
* sdk.documents.replace(...)
*/
import { PLATFORM_VERSION_OVERRIDE } from "../../../../platformVersion.mjs";
import type { Logger } from "../lib/logger";
import { loadSdkModule } from "./sdkModule";
import { findMyReviewForResource } from "./queries";
Expand Down Expand Up @@ -67,10 +66,7 @@ export async function saveReview({
await sdk.documents.create({ document, identityKey, signer });
const json =
typeof document.toJSON === "function"
? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record<
string,
unknown
>)
? (document.toJSON(sdk.version()) as Record<string, unknown>)
: {};
const reviewId = String(json.$id ?? json.id ?? "");
if (!reviewId) throw new Error("Created review returned no ID.");
Expand Down
2 changes: 2 additions & 0 deletions example-apps/dashrate/src/dash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type DashReviewQueryResults =
| Record<string, DashReviewQueryDocument | undefined>;

export interface DashSdk {
/** Platform protocol version negotiated with the network. */
version(): number;
contracts: {
fetch(contractId: string): Promise<{
toJSON?: () => Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions example-apps/dashrate/test/review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function makeKeyManager(): DashKeyManager {

function makeSdk(overrides: Partial<DashSdk["documents"]> = {}): DashSdk {
return {
version: () => 13,
documents: {
create: vi.fn().mockResolvedValue(undefined),
replace: vi.fn().mockResolvedValue(undefined),
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
"scripts": {
"fmt": "prettier --write '**/*.{js,mjs}'",
"lint": "tsc",
"test": "node --test --test-timeout=120000 test/read-only.test.mjs",
"test": "node --test --test-timeout=120000 test/read-only.test.mjs test/platform-version.test.mjs test/platform-version-config.test.mjs test/lite-sdk-versions.test.mjs",
"test:read-only": "node --test --test-timeout=120000 test/read-only.test.mjs",
"test:read-write": "node --test --test-timeout=300000 --test-concurrency=1 test/read-write.test.mjs",
"test:all": "node --test --test-timeout=300000 --test-concurrency=1 test/read-only.test.mjs test/read-write.test.mjs",
"test:all": "node --test --test-timeout=300000 --test-concurrency=1 test/read-only.test.mjs test/platform-version.test.mjs test/platform-version-config.test.mjs test/lite-sdk-versions.test.mjs test/read-write.test.mjs",
"test:setup": "mocha --slow 500 test/setupDashClient.test.mjs --exit",
"walkthrough:dashmint-lab": "node scripts/walkthroughs/record.mjs dashmint-lab",
"walkthrough:dashnote": "node scripts/walkthroughs/record.mjs dashnote",
Expand Down
1 change: 0 additions & 1 deletion platformVersion.d.mts

This file was deleted.

4 changes: 0 additions & 4 deletions platformVersion.mjs

This file was deleted.

2 changes: 0 additions & 2 deletions setupDashClient-core.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,6 @@ export declare class AddressKeyManager {

export declare const KEY_SPECS: readonly unknown[];

export declare const PLATFORM_VERSION_OVERRIDE: number;

export declare function dip13KeyPath(
network: string,
identityIndex: number,
Expand Down
19 changes: 11 additions & 8 deletions setupDashClient-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
SecurityLevel,
wallet,
} from '@dashevo/evo-sdk';
import { PLATFORM_VERSION_OVERRIDE } from './platformVersion.mjs';

/** @typedef {import('@dashevo/evo-sdk').Identity} Identity */
/** @typedef {import('@dashevo/evo-sdk').IdentityPublicKey} IdentityPublicKey */
Expand Down Expand Up @@ -103,21 +102,25 @@ export async function dip13KeyPath(network, identityIndex, keyIndex) {
// SDK client helpers
// ---------------------------------------------------------------------------

export { PLATFORM_VERSION_OVERRIDE };

/**
* Create and connect an EvoSDK client for the selected network.
*
* The platform protocol version is deliberately left unset so the SDK
* negotiates it with the network: it starts from a conservative version and
* ratchets up to whatever the network reports, capped at the newest version
* the SDK itself understands. Pinning it via the `version` option disables
* that negotiation outright, so a hardcoded value silently goes stale the
* next time the network upgrades. Use `sdk.version()` when you need the
* negotiated value.
*
* @param {string} [network='testnet']
* @returns {Promise<EvoSDK>}
*/
export async function createClient(network = 'testnet') {
const factories = /** @type {Record<string, () => EvoSDK>} */ ({
testnet: () =>
EvoSDK.testnetTrusted({ version: PLATFORM_VERSION_OVERRIDE }),
mainnet: () =>
EvoSDK.mainnetTrusted({ version: PLATFORM_VERSION_OVERRIDE }),
local: () => EvoSDK.localTrusted({ version: PLATFORM_VERSION_OVERRIDE }),
testnet: () => EvoSDK.testnetTrusted(),
mainnet: () => EvoSDK.mainnetTrusted(),
local: () => EvoSDK.localTrusted(),
});

const factory = factories[network];
Expand Down
42 changes: 42 additions & 0 deletions test/lite-sdk-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, it } from 'node:test';

const litePages = [
['dashmint-lab', 'dashmint-lite.html'],
['dashnote', 'dashnote-lite.html'],
['dashrate', 'dashrate-lite.html'],
['dashproof-lab', 'dashproof-lite.html'],
];

describe('Standalone lite SDK versions', () => {
for (const [appName, pageName] of litePages) {
it(`${appName} matches its companion app`, () => {
const appDirectory = new URL(
`../example-apps/${appName}/`,
import.meta.url,
);
const packageJson = JSON.parse(
readFileSync(new URL('package.json', appDirectory), 'utf8'),
);
const page = readFileSync(
new URL(`public/${pageName}`, appDirectory),
'utf8',
);
const expectedImport =
`https://esm.sh/@dashevo/evo-sdk@` +
packageJson.dependencies['@dashevo/evo-sdk'];
const sdkImports = [
...page.matchAll(
/from\s+['"](https:\/\/esm\.sh\/@dashevo\/evo-sdk@[^'"]+)['"]/g,
),
].map((match) => match[1]);

assert.deepEqual(
sdkImports,
[expectedImport],
`${pageName} must import exactly the SDK version from ${appName}/package.json`,
);
});
}
});
24 changes: 24 additions & 0 deletions test/platform-version-config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { EvoSDK } from '@dashevo/evo-sdk';
import { createClient } from '../setupDashClient-core.mjs';

const trustedFactories = [
['testnet', 'testnetTrusted'],
['mainnet', 'mainnetTrusted'],
['local', 'localTrusted'],
];

describe('Platform protocol version configuration', () => {
for (const [network, factoryName] of trustedFactories) {
it(`${network} leaves the SDK version unpinned`, async (t) => {
const sdk = { connect: t.mock.fn(async () => {}) };
const factory = t.mock.method(EvoSDK, factoryName, () => sdk);

assert.equal(await createClient(network), sdk);
assert.equal(factory.mock.callCount(), 1);
assert.deepEqual(factory.mock.calls[0].arguments, []);
assert.equal(sdk.connect.mock.callCount(), 1);
});
}
});
Loading