diff --git a/.github/workflows/test-tutorials.yml b/.github/workflows/test-tutorials.yml index e50f4db2..2bd54fde 100644 --- a/.github/workflows/test-tutorials.yml +++ b/.github/workflows/test-tutorials.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 3c06af9d..74c52492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/README.md b/README.md index 41ff3784..09c91aee 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,18 @@ 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 + # Write tests — registers identities/contracts/documents (consumes testnet credits) npm run test:read-write @@ -118,6 +124,11 @@ npm run test:read-write 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 diff --git a/example-apps/dashmint-lab/public/dashmint-lite.html b/example-apps/dashmint-lab/public/dashmint-lite.html index 084c924e..841166c6 100644 --- a/example-apps/dashmint-lab/public/dashmint-lite.html +++ b/example-apps/dashmint-lab/public/dashmint-lite.html @@ -119,7 +119,7 @@

Browse cards

// 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 @@ -131,12 +131,11 @@

Browse cards

// 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; } diff --git a/example-apps/dashnote-starter/src/dash/createNote.ts b/example-apps/dashnote-starter/src/dash/createNote.ts index 64d74051..2c557b6d 100644 --- a/example-apps/dashnote-starter/src/dash/createNote.ts +++ b/example-apps/dashnote-starter/src/dash/createNote.ts @@ -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"; @@ -47,7 +46,7 @@ export async function createNote({ const json = typeof document.toJSON === "function" - ? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record) + ? (document.toJSON(sdk.version()) as Record) : {}; const noteId = String(json.$id ?? json.id ?? ""); if (!noteId) { diff --git a/example-apps/dashnote-starter/src/dash/types.ts b/example-apps/dashnote-starter/src/dash/types.ts index bca572d5..b9fc3f35 100644 --- a/example-apps/dashnote-starter/src/dash/types.ts +++ b/example-apps/dashnote-starter/src/dash/types.ts @@ -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; diff --git a/example-apps/dashnote/public/dashnote-lite.html b/example-apps/dashnote/public/dashnote-lite.html index 809a7f86..e1241e05 100644 --- a/example-apps/dashnote/public/dashnote-lite.html +++ b/example-apps/dashnote/public/dashnote-lite.html @@ -129,7 +129,7 @@

Get note by ID

// 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. diff --git a/example-apps/dashnote/src/dash/createNote.ts b/example-apps/dashnote/src/dash/createNote.ts index 64d74051..2c557b6d 100644 --- a/example-apps/dashnote/src/dash/createNote.ts +++ b/example-apps/dashnote/src/dash/createNote.ts @@ -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"; @@ -47,7 +46,7 @@ export async function createNote({ const json = typeof document.toJSON === "function" - ? (document.toJSON(PLATFORM_VERSION_OVERRIDE) as Record) + ? (document.toJSON(sdk.version()) as Record) : {}; const noteId = String(json.$id ?? json.id ?? ""); if (!noteId) { diff --git a/example-apps/dashnote/src/dash/types.ts b/example-apps/dashnote/src/dash/types.ts index 42df3e1e..1f77a0ec 100644 --- a/example-apps/dashnote/src/dash/types.ts +++ b/example-apps/dashnote/src/dash/types.ts @@ -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; diff --git a/example-apps/dashnote/test/dash.test.ts b/example-apps/dashnote/test/dash.test.ts index 0a3c1b90..9c2ebe62 100644 --- a/example-apps/dashnote/test/dash.test.ts +++ b/example-apps/dashnote/test/dash.test.ts @@ -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), }, @@ -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), }, diff --git a/example-apps/dashproof-lab/public/dashproof-lite.html b/example-apps/dashproof-lab/public/dashproof-lite.html index dfa6ae06..8898daed 100644 --- a/example-apps/dashproof-lab/public/dashproof-lite.html +++ b/example-apps/dashproof-lab/public/dashproof-lite.html @@ -120,7 +120,7 @@

History by chainId

// 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. diff --git a/example-apps/dashrate/public/dashrate-lite.html b/example-apps/dashrate/public/dashrate-lite.html index fdfe4552..fd82bc71 100644 --- a/example-apps/dashrate/public/dashrate-lite.html +++ b/example-apps/dashrate/public/dashrate-lite.html @@ -138,7 +138,7 @@

Recent reviews

// 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'; diff --git a/example-apps/dashrate/src/dash/review.ts b/example-apps/dashrate/src/dash/review.ts index 49657f72..c1705d04 100644 --- a/example-apps/dashrate/src/dash/review.ts +++ b/example-apps/dashrate/src/dash/review.ts @@ -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"; @@ -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) : {}; const reviewId = String(json.$id ?? json.id ?? ""); if (!reviewId) throw new Error("Created review returned no ID."); diff --git a/example-apps/dashrate/src/dash/types.ts b/example-apps/dashrate/src/dash/types.ts index 9e3ec752..ddf561e6 100644 --- a/example-apps/dashrate/src/dash/types.ts +++ b/example-apps/dashrate/src/dash/types.ts @@ -27,6 +27,8 @@ export type DashReviewQueryResults = | Record; export interface DashSdk { + /** Platform protocol version negotiated with the network. */ + version(): number; contracts: { fetch(contractId: string): Promise<{ toJSON?: () => Record; diff --git a/example-apps/dashrate/test/review.test.ts b/example-apps/dashrate/test/review.test.ts index afab00b7..95787caa 100644 --- a/example-apps/dashrate/test/review.test.ts +++ b/example-apps/dashrate/test/review.test.ts @@ -43,6 +43,7 @@ function makeKeyManager(): DashKeyManager { function makeSdk(overrides: Partial = {}): DashSdk { return { + version: () => 13, documents: { create: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined), diff --git a/package.json b/package.json index 26561820..1d38fa25 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/platformVersion.d.mts b/platformVersion.d.mts deleted file mode 100644 index 78cdb7d4..00000000 --- a/platformVersion.d.mts +++ /dev/null @@ -1 +0,0 @@ -export declare const PLATFORM_VERSION_OVERRIDE: number; diff --git a/platformVersion.mjs b/platformVersion.mjs deleted file mode 100644 index f04ad4c9..00000000 --- a/platformVersion.mjs +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: Remove this pin once dashpay/platform#3809 lands in the consumed -// @dashevo/evo-sdk release; unpinned clients should then auto-detect the -// network protocol version safely. -export const PLATFORM_VERSION_OVERRIDE = 12; diff --git a/setupDashClient-core.d.mts b/setupDashClient-core.d.mts index ec832a32..d2ae2f06 100644 --- a/setupDashClient-core.d.mts +++ b/setupDashClient-core.d.mts @@ -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, diff --git a/setupDashClient-core.mjs b/setupDashClient-core.mjs index 3b9b3103..1ee6c254 100644 --- a/setupDashClient-core.mjs +++ b/setupDashClient-core.mjs @@ -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 */ @@ -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} */ export async function createClient(network = 'testnet') { const factories = /** @type {Record 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]; diff --git a/test/lite-sdk-versions.test.mjs b/test/lite-sdk-versions.test.mjs new file mode 100644 index 00000000..79f347ed --- /dev/null +++ b/test/lite-sdk-versions.test.mjs @@ -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`, + ); + }); + } +}); diff --git a/test/platform-version-config.test.mjs b/test/platform-version-config.test.mjs new file mode 100644 index 00000000..55df27ce --- /dev/null +++ b/test/platform-version-config.test.mjs @@ -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); + }); + } +}); diff --git a/test/platform-version.test.mjs b/test/platform-version.test.mjs new file mode 100644 index 00000000..0f88013e --- /dev/null +++ b/test/platform-version.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { EvoSDK } from '@dashevo/evo-sdk'; +import { createClient } from '../setupDashClient-core.mjs'; + +// DPNS — a system contract, so it exists on every network and needs no fixture. +const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; + +describe('Platform protocol version', () => { + // Regression guard for #112: createClient() must leave the protocol version + // unpinned so the SDK negotiates it. A hardcoded pin disables negotiation + // silently — no error, no warning — and the client stays on the old version + // forever once the network upgrades. + for (const network of ['testnet', 'mainnet']) { + it(`${network} negotiates the version instead of using a hardcoded pin`, async () => { + const sdk = await createClient(network); + + // status() hands back a WASM handle — the fields are only reachable + // through toJSON(). + const status = (await sdk.system.status()).toJSON(); + const networkVersion = Number(status.version.protocol.drive.current); + const sdkNewestKnown = await EvoSDK.getLatestVersionNumber(); + + // A proof-bearing read is what carries the network's version back to the + // client; the negotiated value is only settled after the first one. + await sdk.contracts.fetch(DPNS_CONTRACT_ID); + + // The client should land on the network's active version, except when the + // network has moved past what this SDK release understands — then it stays + // at its own ceiling. Asserting against the min() of the two keeps this + // test honest without turning every network upgrade into a red build. + assert.equal( + sdk.version(), + Math.min(networkVersion, sdkNewestKnown), + `client settled on protocol version ${sdk.version()}, but ${network} ` + + `is on ${networkVersion} and this SDK understands up to ` + + `${sdkNewestKnown} — check for a reintroduced version pin`, + ); + }); + } +});