From 26a90c23a7f978108905c95ca22aad014d521216 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 4 Aug 2026 00:18:11 -0500 Subject: [PATCH 1/8] fix: unpin the platform protocol version so clients auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLATFORM_VERSION_OVERRIDE was pinned to 12 while testnet has been running protocol version 13 since late July. A pin is not a floor: rs-sdk's version ratchet returns early when `version_pinned` is set, so a pinned client stays on the old version silently — no error, no warning — no matter what the network reports. The pin's TODO said to remove it once dashpay/platform#3809 landed in the consumed SDK. That condition is now met, and the behavior is verified against the consumed @dashevo/evo-sdk 4.1.0 and live testnet: unpinned after connect: 12 -> after a proof-bearing read: 13 version: 12 after connect: 12 -> after a proof-bearing read: 12 version: 13 after connect: 13 -> after a proof-bearing read: 13 testnet reports protocol.drive.current = 13; the SDK's own ceiling (getLatestVersionNumber) is 13. So an unpinned client negotiates its way to the network's active version, capped at what the SDK understands. Remove the pin rather than bumping it to 13. Bumping re-arms the same trap for protocol version 14 and leaves the failure mode intact — silent, invisible in CI, and only observable by manual dispatch of the read-write suite. platformVersion.mjs existed only to hold this constant, so it goes with it. Its other consumer was `document.toJSON(PLATFORM_VERSION_OVERRIDE)` in three example apps; those now pass `sdk.version()`, which reflects the negotiated version and cannot go stale. dashmint-lite.html carried the same defect independently — pinned to 11 with a comment pointing back at this constant — so it is unpinned too. Co-Authored-By: Claude --- .../dashmint-lab/public/dashmint-lite.html | 9 ++++----- .../dashnote-starter/src/dash/createNote.ts | 3 +-- .../dashnote-starter/src/dash/types.ts | 2 ++ example-apps/dashnote/src/dash/createNote.ts | 3 +-- example-apps/dashnote/src/dash/types.ts | 2 ++ example-apps/dashrate/src/dash/review.ts | 6 +----- example-apps/dashrate/src/dash/types.ts | 2 ++ platformVersion.d.mts | 1 - platformVersion.mjs | 4 ---- setupDashClient-core.d.mts | 2 -- setupDashClient-core.mjs | 19 +++++++++++-------- 11 files changed, 24 insertions(+), 29 deletions(-) delete mode 100644 platformVersion.d.mts delete mode 100644 platformVersion.mjs diff --git a/example-apps/dashmint-lab/public/dashmint-lite.html b/example-apps/dashmint-lab/public/dashmint-lite.html index 084c924e..10afd7e0 100644 --- a/example-apps/dashmint-lab/public/dashmint-lite.html +++ b/example-apps/dashmint-lab/public/dashmint-lite.html @@ -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/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/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/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]; From cd7fcc868085289215c6732de35f7f7ceb293af1 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 4 Aug 2026 00:18:23 -0500 Subject: [PATCH 2/8] test: catch a stale platform version pin in the read-only suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin that #112 reported was invisible to CI: pull requests only run test:read-only, and that suite passed against a PV-13 testnet with the client stuck on 12 the whole time. Nothing asserted the negotiated version, so the staleness could only be found by reading the constant. Assert that a client from createClient() settles on the network's active protocol version after its first proof-bearing read. Verified both ways against live testnet: passes unpinned, fails with `{ version: 12 }` restored. The expected value is min(network active version, SDK ceiling) rather than the network version alone. When the network upgrades ahead of a released SDK the client legitimately stops at its own ceiling, and that shouldn't read as a regression. Lives in the read-only suite because that is the only job CI runs on pull requests; test:setup is not wired into any workflow. No credentials or funds required — the fixture is the DPNS system contract. Co-Authored-By: Claude --- test/read-only.test.mjs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/read-only.test.mjs b/test/read-only.test.mjs index e9b7944c..e9925605 100644 --- a/test/read-only.test.mjs +++ b/test/read-only.test.mjs @@ -1,7 +1,10 @@ +import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import dotenv from 'dotenv'; +import { EvoSDK } from '@dashevo/evo-sdk'; import { runTutorial } from './run-tutorial.mjs'; import { assertTutorialSuccess } from './assertions.mjs'; +import { createClient } from '../setupDashClient-core.mjs'; dotenv.config(); @@ -79,3 +82,39 @@ describe('Read-only tutorials', () => { }); } }); + +// 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. + it('negotiates the version instead of using a hardcoded pin', async () => { + const network = process.env.NETWORK || 'testnet'; + 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 the network ` + + `is on ${networkVersion} and this SDK understands up to ` + + `${sdkNewestKnown} — check for a reintroduced version pin`, + ); + }); +}); From 01efd80d7045dcb58cb5f8e140e9707fe4947ccf Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 4 Aug 2026 00:20:51 -0500 Subject: [PATCH 3/8] test(example-apps): give the SDK stubs a version() method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createNote and saveReview now read the negotiated protocol version off the SDK instead of a shared constant, so the hand-rolled stubs in these suites need to answer version(). Both stubs are cast (`as never` / `as unknown as DashSdk`), so the compiler couldn't flag the gap — it showed up at runtime as "sdk.version is not a function". Co-Authored-By: Claude --- example-apps/dashnote/test/dash.test.ts | 2 ++ example-apps/dashrate/test/review.test.ts | 1 + 2 files changed, 3 insertions(+) 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/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), From 953a77b0a7fca9a98f52c3337bb5e2e794565607 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 4 Aug 2026 00:44:16 -0500 Subject: [PATCH 4/8] fix(example-apps): align lite SDK versions Co-Authored-By: Claude --- .../dashmint-lab/public/dashmint-lite.html | 2 +- .../dashnote/public/dashnote-lite.html | 2 +- .../dashproof-lab/public/dashproof-lite.html | 2 +- .../dashrate/public/dashrate-lite.html | 2 +- test/read-only.test.mjs | 40 +++++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/example-apps/dashmint-lab/public/dashmint-lite.html b/example-apps/dashmint-lab/public/dashmint-lite.html index 10afd7e0..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 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/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/test/read-only.test.mjs b/test/read-only.test.mjs index e9925605..1c6b9fbb 100644 --- a/test/read-only.test.mjs +++ b/test/read-only.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; import dotenv from 'dotenv'; import { EvoSDK } from '@dashevo/evo-sdk'; @@ -83,6 +84,45 @@ describe('Read-only tutorials', () => { } }); +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`, + ); + }); + } +}); + // DPNS — a system contract, so it exists on every network and needs no fixture. const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; From 254c74d047bd5db65ebb097db06c92fa76871634 Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 4 Aug 2026 16:01:09 -0400 Subject: [PATCH 5/8] test: split platform-version and lite-SDK checks into their own files The two guards added for #112 lived inside test/read-only.test.mjs, which otherwise only runs tutorials as subprocesses. Neither guard is a tutorial run, so they are moved to test/platform-version.test.mjs and test/lite-sdk-versions.test.mjs and `test:read-only` goes back to covering just the tutorial suite. `npm test` and `test:all` now run all three files, and the PR job in test-tutorials.yml calls `npm test` so the new guards run on pull requests. --- .github/workflows/test-tutorials.yml | 4 +- package.json | 4 +- test/lite-sdk-versions.test.mjs | 42 +++++++++++++++ test/platform-version.test.mjs | 43 +++++++++++++++ test/read-only.test.mjs | 79 ---------------------------- 5 files changed, 89 insertions(+), 83 deletions(-) create mode 100644 test/lite-sdk-versions.test.mjs create mode 100644 test/platform-version.test.mjs 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/package.json b/package.json index 26561820..3d96a7cf 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/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/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/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.test.mjs b/test/platform-version.test.mjs new file mode 100644 index 00000000..ba62b0fa --- /dev/null +++ b/test/platform-version.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import dotenv from 'dotenv'; +import { EvoSDK } from '@dashevo/evo-sdk'; +import { createClient } from '../setupDashClient-core.mjs'; + +dotenv.config(); + +// 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. + it('negotiates the version instead of using a hardcoded pin', async () => { + const network = process.env.NETWORK || 'testnet'; + 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 the network ` + + `is on ${networkVersion} and this SDK understands up to ` + + `${sdkNewestKnown} — check for a reintroduced version pin`, + ); + }); +}); diff --git a/test/read-only.test.mjs b/test/read-only.test.mjs index 1c6b9fbb..e9b7944c 100644 --- a/test/read-only.test.mjs +++ b/test/read-only.test.mjs @@ -1,11 +1,7 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; import dotenv from 'dotenv'; -import { EvoSDK } from '@dashevo/evo-sdk'; import { runTutorial } from './run-tutorial.mjs'; import { assertTutorialSuccess } from './assertions.mjs'; -import { createClient } from '../setupDashClient-core.mjs'; dotenv.config(); @@ -83,78 +79,3 @@ describe('Read-only tutorials', () => { }); } }); - -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`, - ); - }); - } -}); - -// 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. - it('negotiates the version instead of using a hardcoded pin', async () => { - const network = process.env.NETWORK || 'testnet'; - 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 the network ` + - `is on ${networkVersion} and this SDK understands up to ` + - `${sdkNewestKnown} — check for a reintroduced version pin`, - ); - }); -}); From 45d513d3ec533c69c53a80e6b3a2eb126caab5de Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 4 Aug 2026 16:07:52 -0400 Subject: [PATCH 6/8] test: guard the unpinned SDK version offline and across networks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live negotiation test only proves the version is unpinned once a proof-bearing read has ratcheted it, so it asserts against network state rather than an invariant. test/platform-version-config.test.mjs adds an offline guard that mocks the three `*Trusted` factories and asserts createClient() passes them no arguments — a reintroduced pin fails there without needing the network, and `local` gets covered too. The live test now loops over testnet and mainnet instead of reading NETWORK, so a pin on either network shows up rather than only on whichever one the environment happened to select. --- package.json | 4 +-- test/platform-version-config.test.mjs | 24 ++++++++++++++ test/platform-version.test.mjs | 47 ++++++++++++++------------- 3 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 test/platform-version-config.test.mjs diff --git a/package.json b/package.json index 3d96a7cf..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/platform-version.test.mjs test/lite-sdk-versions.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/platform-version.test.mjs test/lite-sdk-versions.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/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 index ba62b0fa..c7ab3ef4 100644 --- a/test/platform-version.test.mjs +++ b/test/platform-version.test.mjs @@ -14,30 +14,31 @@ describe('Platform 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. - it('negotiates the version instead of using a hardcoded pin', async () => { - const network = process.env.NETWORK || 'testnet'; - const sdk = await createClient(network); + 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(); + // 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); + // 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 the network ` + - `is on ${networkVersion} and this SDK understands up to ` + - `${sdkNewestKnown} — check for a reintroduced version pin`, - ); - }); + // 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`, + ); + }); + } }); From c78b5cbfd25b13ad51d1cf28dc1227c5523a63cc Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 4 Aug 2026 16:15:02 -0400 Subject: [PATCH 7/8] docs: update readme and claude --- CLAUDE.md | 19 +++++++++++++++---- README.md | 10 +++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) 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..8f759352 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,12 @@ required — tests use the Node.js built-in test runner. Ensure your `.env` file is configured (see [`.env.example`](./.env.example)) before running tests. ```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 +121,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 From b3d195ee64b85ec75d5d12cd1b867cbba754553b Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 4 Aug 2026 16:28:08 -0400 Subject: [PATCH 8/8] docs: scope the .env prerequisite to the tutorial suites The prerequisite said to configure .env "before running tests", which contradicted the line two rows below calling `npm test` a safe default. Only the tutorial suites need credentials: without a PLATFORM_MNEMONIC the read-only suite skips the tutorials that require one, and the write suite cannot run at all. test/platform-version.test.mjs no longer reads process.env now that it names the networks explicitly, so its dotenv import goes too. --- README.md | 5 ++++- test/platform-version.test.mjs | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8f759352..09c91aee 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,10 @@ 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 # Default — safe to run, no credits consumed diff --git a/test/platform-version.test.mjs b/test/platform-version.test.mjs index c7ab3ef4..0f88013e 100644 --- a/test/platform-version.test.mjs +++ b/test/platform-version.test.mjs @@ -1,11 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import dotenv from 'dotenv'; import { EvoSDK } from '@dashevo/evo-sdk'; import { createClient } from '../setupDashClient-core.mjs'; -dotenv.config(); - // DPNS — a system contract, so it exists on every network and needs no fixture. const DPNS_CONTRACT_ID = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec';