From b2d480622a0f9720c90647460c1368323db03884 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 22:51:11 +0100 Subject: [PATCH 01/15] feat(contract): add on-chain QA framework storage layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds qa-contract/: a dedicated Dash Platform data contract holding test definitions (testCase) and test-run results (testRun) so QA status lives on-chain (queryable, proof-verifiable) for a dashboard to render. Mirrors packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md and complements #3897. - schema: testCase (mutable, unique testId; indices testId/tier/category) and testRun (immutable + non-deletable append-only audit log; indices testIdCreatedAt/resultCreatedAt/buildRef; $createdAt = platform-stamped run time). Indexed strings are <=63 chars and index sort direction is asc (drive-abci rejects desc; newest-first is done at query time). - js-evo-sdk scripts: register (re-runnable across testnet resets), seed (parses TEST_PLAN §4 -> upsert testCases, idempotent), submit-run, query (index self-check + proofs), and derive-identity-key (recover the signing key from a wallet mnemonic). - README documents the schema, QA identity setup, register/seed/submit, the contract id, the testnet-reset re-seed procedure, and the v2 per-team-member testRun-submission extension. .env (mnemonic + key) is gitignored. Verified on testnet: registered the contract, seeded all 126 testCases from TEST_PLAN, submitted a sample testRun, and queried back through every index including proof-verified reads. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/.env.example | 25 ++ qa-contract/.gitignore | 3 + qa-contract/README.md | 246 ++++++++++++++++++ qa-contract/contract-id.testnet.json | 12 + qa-contract/package.json | 21 ++ qa-contract/schema/qa-contract.documents.json | 214 +++++++++++++++ qa-contract/src/derive-identity-key.mjs | 103 ++++++++ qa-contract/src/parse-test-plan.mjs | Bin 0 -> 4353 bytes qa-contract/src/query.mjs | 130 +++++++++ qa-contract/src/register.mjs | 71 +++++ qa-contract/src/sdk.mjs | 180 +++++++++++++ qa-contract/src/seed.mjs | 127 +++++++++ qa-contract/src/submit-run.mjs | 71 +++++ 13 files changed, 1203 insertions(+) create mode 100644 qa-contract/.env.example create mode 100644 qa-contract/.gitignore create mode 100644 qa-contract/README.md create mode 100644 qa-contract/contract-id.testnet.json create mode 100644 qa-contract/package.json create mode 100644 qa-contract/schema/qa-contract.documents.json create mode 100644 qa-contract/src/derive-identity-key.mjs create mode 100644 qa-contract/src/parse-test-plan.mjs create mode 100644 qa-contract/src/query.mjs create mode 100644 qa-contract/src/register.mjs create mode 100644 qa-contract/src/sdk.mjs create mode 100644 qa-contract/src/seed.mjs create mode 100644 qa-contract/src/submit-run.mjs diff --git a/qa-contract/.env.example b/qa-contract/.env.example new file mode 100644 index 00000000000..619451e21f5 --- /dev/null +++ b/qa-contract/.env.example @@ -0,0 +1,25 @@ +# Copy to `.env` and fill in. The scripts read these (loadDotEnv) or you can +# export them in the shell. `.env` is gitignored — never commit a private key. + +# Target network (testnet | mainnet | local). Defaults to testnet. +NETWORK=testnet + +# The QA identity that owns the contract and creates all documents (v1). +# base58 identity id. +QA_IDENTITY_ID= + +# Private key for an AUTHENTICATION key on QA_IDENTITY_ID with HIGH or CRITICAL +# security level. WIF or 64-char hex. TESTNET KEY ONLY. +QA_PRIVATE_KEY= + +# Optional: pin the signing key by id instead of auto-detecting it from the key above. +# QA_IDENTITY_KEY_ID=2 + +# Optional: override the TEST_PLAN commit stamped onto seeded testCases. +# Defaults to the git short-sha of TEST_PLAN.md. +# PLAN_COMMIT= + +# Optional: import the SDK from a prebuilt bundle instead of the installed +# @dashevo/evo-sdk package (handy in-repo when the workspace package is built +# elsewhere). Absolute path to dist/evo-sdk.module.js. +# EVO_SDK_BUNDLE=/abs/path/to/packages/js-evo-sdk/dist/evo-sdk.module.js diff --git a/qa-contract/.gitignore b/qa-contract/.gitignore new file mode 100644 index 00000000000..2e8157a95e1 --- /dev/null +++ b/qa-contract/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/qa-contract/README.md b/qa-contract/README.md new file mode 100644 index 00000000000..e94cceb3bcc --- /dev/null +++ b/qa-contract/README.md @@ -0,0 +1,246 @@ +# QA Contract — on-chain QA framework storage layer + +A dedicated Dash Platform data contract that stores **test definitions** and +**test-run results** on-chain, so QA status is queryable and proof-verifiable +and a website can render it. Complements GitHub issue +[#3897](https://github.com/dashpay/platform/issues/3897) and the iOS test plan at +[`packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md`](../packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md). + +This package is the **storage layer only**: the schema, a register script, a +seed script (kept in sync with `TEST_PLAN.md`), a submit-run helper, and a +read/verify tool. The website that consumes the contract is a separate task and +only needs the contract ID below. + +## Contract ID + +The live contract ID for each network is committed in +`contract-id..json` (e.g. [`contract-id.testnet.json`](contract-id.testnet.json)), +written by the register script. Read it from there — testnet resets periodically, +so the ID changes when the contract is re-registered (see +[Testnet reset / re-seed](#testnet-reset--re-seed)). + +**Live testnet deployment** (as of registration): + +| | | +|---|---| +| Contract ID | `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` | +| Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | +| Network | testnet | + +```jsonc +// contract-id.testnet.json (shape) +{ + "network": "testnet", + "contractId": "", + "ownerId": "", + "documentTypes": ["testCase", "testRun"], + "schemaSha": "", + "planCommit": "", + "registeredAt": "" +} +``` + +## Schema + +Two document types (full schema in +[`schema/qa-contract.documents.json`](schema/qa-contract.documents.json)): + +### `testCase` — a test definition (mirrors one `TEST_PLAN` §4 row) + +| Field | Type | Notes | +|---|---|---| +| `testId` | string (≤32) | e.g. `CORE-05`. **Unique index.** | +| `title` | string (≤255) | the plan's *Action* column | +| `tier` | string (≤16) | Essential / Common / Thorough / Uncommon / Manual. **Indexed.** | +| `category` | string (≤32) | Domain (Core, Identity, DPNS, Token, …). **Indexed.** | +| `layer` | string (≤16) | Core / Platform / Cross / Shielded | +| `implStatus` | string (≤32) | status glyph (✅ 🧪 ⚠️ 🔌 🚫) | +| `description` | string (≤2048) | entry point & test notes (last plan column) | +| `entryPoint` | string (≤512) | primary view / FFI entry point | +| `prerequisites` | string (≤1024) | fixtures/preconditions | +| `planCommit` | string (≤64) | `TEST_PLAN.md` commit this row was seeded from | + +- Indices: `testId` (unique, asc) · `tier` (asc) · `category` (asc). +- **Mutable** (`documentsMutable: true`) so impl-status / entry-point updates can + be pushed; deletable so removed plan rows can be cleaned up. +- `additionalProperties: false`. + +### `testRun` — an append-only run record + +| Field | Type | Notes | +|---|---|---| +| `testId` | string (≤32) | matches `testCase.testId`. **Indexed (compound).** | +| `result` | string (≤16) | `pass` / `fail` / `blocked` / `skipped`. **Indexed (compound).** | +| `network` | string (≤32) | network the run executed against | +| `buildRef` | string (≤63) | build under test (commit/branch/build no.). **Indexed.** | +| `device` | string (≤128) | device / simulator | +| `evidence` | string (≤512) | txid / on-chain id / screenshot path / URL | +| `notes` | string (≤2048) | free-form notes | +| `blockerReason` | string (≤512) | why blocked/skipped | +| `$createdAt` | system | **run time**, stamped by the platform; required + indexed | + +- Indices: `testIdCreatedAt` (`testId`, `$createdAt`) · `resultCreatedAt` + (`result`, `$createdAt`) · `buildRef`. "Most recent run first" is done at query + time with `orderBy [['$createdAt','desc']]` (see below). +- **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): + it is an audit log. `additionalProperties: false`. + +> **Platform schema constraints baked into this schema:** +> - Indexed string properties are capped at `maxLength ≤ 63`, which is why the +> indexed fields are short. +> - Index property sort direction must be **`asc`** in the contract definition +> (drive-abci rejects `desc` with `JsonSchemaError: "desc" is not one of ["asc"]`). +> Descending order is requested at *query* time instead — the index is traversed +> in reverse — so `testRun` queries still return newest-first via +> `orderBy [['$createdAt','desc']]`. +> +> The schema is validated against `rs-dpp` (`new DataContract({ …, fullValidation: true })`) +> before broadcast; note that local DPP validation does **not** catch the asc-only +> index rule — drive-abci does, at register time. + +## QA identity (v1 ownership) + +In v1 a **single QA identity** owns the contract and creates every document. +Both document types use `creationRestrictionMode: 1` (**OwnerOnly**), so only +that identity can create `testCase`/`testRun` documents. + +You need: + +1. A registered testnet identity with a **credit balance** (registration + each + document create costs credits). Create/fund one with the SwiftExampleApp + (`ID-01`) or any Platform wallet. +2. The **private key** of an `AUTHENTICATION` key on that identity with **HIGH or + CRITICAL** security level (WIF or 64-char hex). The register/seed/submit + scripts sign with it. + +Provide them via env vars or a gitignored `.env` (copy `.env.example`): + +```sh +export NETWORK=testnet +export QA_IDENTITY_ID= +export QA_PRIVATE_KEY= # testnet key only +# optional: export QA_IDENTITY_KEY_ID=2 # pin the signing key id +``` + +The scripts auto-detect which identity key matches `QA_PRIVATE_KEY`; if detection +fails they print the identity's keys so you can set `QA_IDENTITY_KEY_ID`. + +**Recovering the key from a wallet mnemonic.** If the identity was registered by a +wallet whose mnemonic you control (e.g. created/restored in SwiftExampleApp, which +mints identities via the Core asset-lock flow that the JS SDK can't do on its own), +set `QA_MNEMONIC` + `QA_IDENTITY_ID` and run: + +```sh +node src/derive-identity-key.mjs --write +``` + +It fetches the identity, derives candidate keys from the mnemonic at the +platform-wallet DIP13 path `m/9'/'/5'/0'/'/'/'`, +matches them to the on-chain public keys, and writes `QA_PRIVATE_KEY` + +`QA_IDENTITY_KEY_ID` into `.env`. + +### Extending to per-team-member `testRun` submission (v2) + +To let any identity submit runs (while keeping `testCase` owner-controlled), +change **`testRun`** only: + +- set `creationRestrictionMode: 0` (NoRestrictions) on `testRun`, and +- register the change via a data-contract **update** (or re-register on the next + testnet reset). + +`testRun` is already immutable + owner-stamped (`$ownerId`/`$createdAt` are +system fields), so opening creation keeps every run attributable and tamper-proof. +Leave `testCase` as OwnerOnly so the canonical catalog stays curated. + +## Install & run + +The scripts use the recommended **`@dashevo/evo-sdk`** (js-evo-sdk) in trusted +mode (required so state-transition responses are proof-verified). Node ≥ 18.18. + +**Option A — standalone (published SDK):** + +```sh +cd qa-contract +yarn install # or npm install +``` + +**Option B — in-repo (workspace build):** build the workspace SDK once and point +the scripts at the bundle (no `yarn install` in this dir needed): + +```sh +yarn workspace @dashevo/wasm-sdk build && yarn workspace @dashevo/evo-sdk build +export EVO_SDK_BUNDLE="$PWD/../packages/js-evo-sdk/dist/evo-sdk.module.js" +``` + +Then: + +```sh +# 1. Register the contract on testnet (writes contract-id.testnet.json) +node src/register.mjs # --force to re-register a fresh contract + +# 2. Seed testCases from TEST_PLAN.md (idempotent; skips existing) +node src/seed.mjs # all rows +node src/seed.mjs --ids CORE-01,ID-04 # a subset +node src/seed.mjs --tier Essential # filter by tier / --category / --limit +node src/seed.mjs --update # push changed rows (replace) + +# 3. Submit a test-run result +node src/submit-run.mjs --testId CORE-05 --result pass --buildRef 45fdf33901 \ + --device "iPhone 16 (iOS 18.2)" --evidence "txid:30010050…17f840fc" --notes "2-output send credited both recipients" + +# 4. Read back / verify indices (read-only; no key needed) +node src/query.mjs # self-check: exercises every index +node src/query.mjs --type testCase --tier Essential +node src/query.mjs --type testRun --testId CORE-05 --proof +``` + +`--result` must be one of `pass | fail | blocked | skipped`. Add `--proof` to any +`query.mjs` call to fetch with a verified Platform proof, `--json` for raw output. + +### Files + +``` +qa-contract/ +├── schema/qa-contract.documents.json # the two document types (the contract schema) +├── contract-id.testnet.json # committed: live contract ID per network +├── src/ +│ ├── sdk.mjs # SDK load, connect, signer, identity-key, config +│ ├── parse-test-plan.mjs # TEST_PLAN.md §4 catalog parser +│ ├── register.mjs # register the contract +│ ├── seed.mjs # seed testCases from the plan (idempotent) +│ ├── submit-run.mjs # create one testRun +│ ├── query.mjs # read back + verify indices +│ └── derive-identity-key.mjs # recover signing key from a wallet mnemonic +├── .env.example +└── README.md +``` + +## Testnet reset / re-seed + +Public testnet resets periodically; the old contract ID stops resolving and all +documents are gone. To rebuild: + +```sh +# 1. Re-register (auto-detected: register.mjs re-registers when the committed +# contractId no longer resolves; --force to force it). Overwrites contract-id.testnet.json. +node src/register.mjs + +# 2. Re-seed every testCase from the current plan +node src/seed.mjs + +# 3. (testRun history does not survive a reset — it is re-accumulated as runs happen.) +``` + +Re-running `register.mjs` while the committed contract still resolves is a no-op +(it prints the existing ID). Re-running `seed.mjs` skips testCases that already +exist, so both scripts are safe to run repeatedly. Commit the updated +`contract-id.testnet.json` after a re-register so consumers (the website) pick up +the new ID. + +## How it maps to the test plan + +`seed.mjs` parses the §4 catalog tables of `TEST_PLAN.md` — `ID`, `Action`, +`Layer`, `Tier`, `Status` columns plus the section's `Domain=` (→ `category`) — +and creates one `testCase` per row (126 rows at the current plan commit). The +`simulator-control` QA runs then post results with `submit-run.mjs`, so the +on-chain `testRun` log mirrors what the automated QA agent actually executed. diff --git a/qa-contract/contract-id.testnet.json b/qa-contract/contract-id.testnet.json new file mode 100644 index 00000000000..416ff01832d --- /dev/null +++ b/qa-contract/contract-id.testnet.json @@ -0,0 +1,12 @@ +{ + "network": "testnet", + "contractId": "2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw", + "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", + "documentTypes": [ + "testCase", + "testRun" + ], + "schemaSha": "990aa1b47a4fe61d", + "planCommit": "45fdf33901", + "registeredAt": "2026-06-15T21:30:26.173Z" +} diff --git a/qa-contract/package.json b/qa-contract/package.json new file mode 100644 index 00000000000..38836429ddf --- /dev/null +++ b/qa-contract/package.json @@ -0,0 +1,21 @@ +{ + "name": "qa-contract", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "On-chain QA framework storage layer (data contract + scripts) for Dash Platform testnet.", + "engines": { + "node": ">=18.18" + }, + "scripts": { + "register": "node src/register.mjs", + "seed": "node src/seed.mjs", + "submit-run": "node src/submit-run.mjs", + "query": "node src/query.mjs", + "parse": "node src/parse-test-plan.mjs", + "derive-key": "node src/derive-identity-key.mjs" + }, + "dependencies": { + "@dashevo/evo-sdk": "^4.0.0-rc.2" + } +} diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json new file mode 100644 index 00000000000..99956c68b96 --- /dev/null +++ b/qa-contract/schema/qa-contract.documents.json @@ -0,0 +1,214 @@ +{ + "testCase": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 1, + "indices": [ + { + "name": "testId", + "properties": [ + { + "testId": "asc" + } + ], + "unique": true + }, + { + "name": "tier", + "properties": [ + { + "tier": "asc" + } + ] + }, + { + "name": "category", + "properties": [ + { + "category": "asc" + } + ] + } + ], + "properties": { + "testId": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 0, + "description": "Stable test identifier from the test plan (e.g. CORE-05, ID-04, DPNS-05). Unique per testCase." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "position": 1, + "description": "Human-readable action being tested (the plan's Action column)." + }, + "tier": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "position": 2, + "description": "Frequency tier. One of: Essential, Common, Thorough, Uncommon, Manual (Unspecified for stub rows)." + }, + "category": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 3, + "description": "Feature area / Domain (e.g. Core, Identity, DPNS, Token, Shielded, MultiWallet)." + }, + "layer": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "position": 4, + "description": "Stack layer. One of: Core, Platform, Cross, Shielded." + }, + "implStatus": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 5, + "description": "Implementation status glyph mirrored from the plan (✅ implemented, 🧪 builder-only, ⚠️ partial/mock, 🔌 FFI-only, 🚫 not implemented)." + }, + "description": { + "type": "string", + "maxLength": 2048, + "position": 6, + "description": "Entry point & test notes for the action (the plan's last column)." + }, + "entryPoint": { + "type": "string", + "maxLength": 512, + "position": 7, + "description": "Primary code entry point (view / FFI function) that drives the action." + }, + "prerequisites": { + "type": "string", + "maxLength": 1024, + "position": 8, + "description": "Fixtures/preconditions required before this test can run." + }, + "planCommit": { + "type": "string", + "maxLength": 64, + "position": 9, + "description": "git commit (short or full SHA) of TEST_PLAN.md this testCase was seeded from." + } + }, + "required": [ + "testId", + "title", + "tier", + "category", + "layer", + "implStatus" + ], + "additionalProperties": false, + "description": "A single test definition, mirroring one row of the iOS TEST_PLAN §4 catalog. Mutable so implementation status / entry points can be updated as the plan evolves; owner-only creation (v1 single QA identity)." + }, + "testRun": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "creationRestrictionMode": 1, + "indices": [ + { + "name": "testIdCreatedAt", + "properties": [ + { + "testId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "resultCreatedAt", + "properties": [ + { + "result": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "buildRef", + "properties": [ + { + "buildRef": "asc" + } + ] + } + ], + "properties": { + "testId": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 0, + "description": "Test identifier this run is a result for (matches testCase.testId)." + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "position": 1, + "description": "Outcome. One of: pass, fail, blocked, skipped." + }, + "network": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 2, + "description": "Network the run executed against (e.g. testnet, devnet)." + }, + "buildRef": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 3, + "description": "Build under test (commit SHA, branch+sha, or app build number)." + }, + "device": { + "type": "string", + "maxLength": 128, + "position": 4, + "description": "Device / simulator the run executed on (e.g. iPhone 16 Simulator, iOS 18.2)." + }, + "evidence": { + "type": "string", + "maxLength": 512, + "position": 5, + "description": "Pointer to evidence (txid, on-chain id, screenshot path, or URL)." + }, + "notes": { + "type": "string", + "maxLength": 2048, + "position": 6, + "description": "Free-form notes about the run." + }, + "blockerReason": { + "type": "string", + "maxLength": 512, + "position": 7, + "description": "Why the run was blocked/skipped (precondition unmet, environment limit, etc.)." + } + }, + "required": [ + "testId", + "result", + "network", + "buildRef", + "$createdAt" + ], + "additionalProperties": false, + "description": "An append-only record of one test execution. Immutable and non-deletable: it is an audit log. $createdAt is the run time. Owner-only creation in v1 (single QA identity); relax creationRestrictionMode to 0 to let any identity submit runs." + } +} diff --git a/qa-contract/src/derive-identity-key.mjs b/qa-contract/src/derive-identity-key.mjs new file mode 100644 index 00000000000..6cee3dba14b --- /dev/null +++ b/qa-contract/src/derive-identity-key.mjs @@ -0,0 +1,103 @@ +// Recover the QA identity's signing key from a known wallet mnemonic. +// +// Use this when the identity was registered by a wallet whose mnemonic you +// control (e.g. registered via SwiftExampleApp / platform-wallet, which derives +// identity authentication keys at the DIP13 path +// m/9'/'/5'/0'/'/'/' +// — coin = 1 for testnet/devnet/local, 5 for mainnet; keyType 0 = ECDSA). +// +// It fetches the on-chain identity, derives candidate keys from the mnemonic, +// matches them to the identity's public keys, and prints (or writes to .env) the +// WIF + key id of a writable HIGH/CRITICAL AUTHENTICATION key suitable for +// signing contract/document transitions. +// +// Usage: +// QA_MNEMONIC="..." QA_IDENTITY_ID=... node src/derive-identity-key.mjs [--write] + +import { parseArgs } from 'node:util'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadDotEnv, connect, QA_DIR } from './sdk.mjs'; + +const COIN = (net) => (net === 'mainnet' ? 5 : 1); +const SEC_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; + +function normHex(data) { + if (data == null) return undefined; + if (typeof data === 'string') { + const s = data.toLowerCase(); + if (/^[0-9a-f]+$/.test(s)) return s; + try { return Buffer.from(data, 'base64').toString('hex'); } catch { return s; } + } + try { return Buffer.from(data).toString('hex'); } catch { return undefined; } +} + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ options: { write: { type: 'boolean', default: false } } }); + + const mnemonic = (process.env.QA_MNEMONIC || '').trim(); + const identityId = (process.env.QA_IDENTITY_ID || '').trim(); + if (!mnemonic) throw new Error('Set QA_MNEMONIC (the wallet mnemonic that owns the identity).'); + if (!identityId) throw new Error('Set QA_IDENTITY_ID (the registered identity id).'); + + const { sdk, mod, network } = await connect(); + const coin = COIN(network); + const identity = await sdk.identities.fetch(identityId); + if (!identity) throw new Error(`Identity ${identityId} not found on ${network}.`); + + const onChain = identity.publicKeys.map((k) => ({ + id: Number(k.keyId ?? k.id), purpose: String(k.purpose), securityLevel: String(k.securityLevel), + keyType: String(k.keyType), readOnly: !!k.isReadOnly, hex: normHex(k.data), + })); + + const matches = []; + for (let idIdx = 0; idIdx <= 3; idIdx += 1) { + for (let kt = 0; kt <= 1; kt += 1) { + for (let ki = 0; ki <= 6; ki += 1) { + const path = `m/9'/${coin}'/5'/0'/${kt}'/${idIdx}'/${ki}'`; + let d; + try { d = await mod.wallet.deriveKeyFromSeedWithPath({ mnemonic, path, network }); } catch { continue; } + const pub = d.publicKey.toLowerCase(); + const hit = onChain.find((k) => k.hex === pub); + if (hit) matches.push({ ...hit, path, wif: d.privateKeyWif }); + } + } + } + + if (!matches.length) { + console.error('No derived key matched any on-chain key. On-chain keys:'); + for (const k of onChain) console.error(` id=${k.id} ${k.purpose}/${k.securityLevel} ${k.keyType} ro=${k.readOnly}`); + process.exit(2); + } + + console.log(`Matched ${matches.length} key(s) on identity ${identityId}:`); + for (const m of matches) console.log(` id=${m.id} ${m.purpose}/${m.securityLevel} ${m.keyType} ro=${m.readOnly} ${m.path}`); + + const signable = matches + .filter((m) => m.purpose === 'AUTHENTICATION' && !m.readOnly && (m.securityLevel === 'HIGH' || m.securityLevel === 'CRITICAL')) + .sort((a, b) => SEC_RANK[a.securityLevel] - SEC_RANK[b.securityLevel] || a.id - b.id); + // Prefer HIGH (matches the platform doc/contract transition precedent), else CRITICAL. + const pick = signable.find((m) => m.securityLevel === 'HIGH') || signable[0]; + if (!pick) throw new Error('No writable HIGH/CRITICAL AUTHENTICATION key matched.'); + + console.log(`\nSigning key: id=${pick.id} ${pick.securityLevel} AUTHENTICATION`); + console.log(` WIF: ${pick.wif}`); + + if (values.write) { + const envPath = join(QA_DIR, '.env'); + let env = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''; + const setLine = (key, val) => { + const re = new RegExp(`^${key}=.*$`, 'm'); + env = re.test(env) ? env.replace(re, `${key}=${val}`) : `${env}\n${key}=${val}`; + }; + setLine('QA_PRIVATE_KEY', pick.wif); + if (Number.isFinite(pick.id)) setLine('QA_IDENTITY_KEY_ID', String(pick.id)); + writeFileSync(envPath, env.endsWith('\n') ? env : `${env}\n`); + console.log(`\nWrote QA_PRIVATE_KEY${Number.isFinite(pick.id) ? ' + QA_IDENTITY_KEY_ID' : ''} to ${envPath}`); + } else { + console.log('\nRe-run with --write to save QA_PRIVATE_KEY + QA_IDENTITY_KEY_ID into .env.'); + } +} + +main().catch((e) => { console.error('derive-identity-key failed:', e?.stack || e); process.exit(1); }); diff --git a/qa-contract/src/parse-test-plan.mjs b/qa-contract/src/parse-test-plan.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6e3c8f1b62e72a744611266835955acca2b68388 GIT binary patch literal 4353 zcmai1QF7bJ5zV(w(Y71|T$6w#OIsUyxh_SbtF$Fsib!XxBFW+y7?N0l0C#36ic7I- ze{zF-<~zAZj^dN#^$Y-lk~eY5Br)je>3-e)x+e_jTqrHcj3xc&-}@8^Bhq|CMtn}C zrhG`fc``KJdoh`&@^Cs0``upu=KS>VLokUc$xKd0YI7u@MCEfGY=t5Is4Jpzr427A z>DLX>u#u{GRB6j?ON+t(^n>>d7poqg+Wk-6f+$rbT3uJYykVeW0$^GBb%t@%zg^ z|BlZAZ|bjQ|C-48fPx^P2R`5^PiK=%x9Q{1RN(DL55oTo;x1GAh+?N=D-(jc=wZkEGtv{+Gj>zrU>^QOp_`LZX@d6A_plQ$&KqN!*iwFfWy9;W@HDI%usf>Q-ePC2F@qq)O zd>2+5m0Rjb;d}7SYtp3$mch5z+O?mpVXh{^bpAMYkN7O@y>1rjh@yGiuEmaI7Ugl0 zjoReR%C&rMhpl29 z^NfG>5tw{7vbKJN4dwcKkbfGBxba~kN+ckgewElGcP3tOt^&&*l zRN>0l{WSSiG9md&%w0sBJq9Xxjs5Yw(mQs@y&ogJI%KU5kPfl$-#4i91KkHEKh5V- zF+2NM>U79#uGxqcV6YlK;bW$!GD?Ptj8TL@rLs~_)5Kilb3aAWISXCdL*9_ok(kPu zrpXkn=U+WE&>wUXONwNgqBE5C_PQUqh`m2ReYs{uu*jMNB@z5c9;T`9U0po@a^BJi z?D@X;01PH#>idy(Gpft8AR@#fE+wspu=0>5Lfyvs9OZ|0gj!TaTf#&e(KJs`8Q6xr zQ+Yg#kX0lnQ?o$5DQ&`3fPyMNxKi9hDzg!GzI^FkUhZ65m=UHzT8}{|t}=dq*M_bZ z?LJmA&8JR95h^Rc8Jk4+B=}yKTshNReCiv5!q^{Zr!qK@cTz1%D8o4sbcj!!~WUg1eWs#<^Tp zJeTJLbWzlN*+6h05$d&93`?KcPqa!Lf>90r7PC$hc8NP#u3+L}f^m5ge%ju?(y#Wf zf+C5Fvw-Sy!xdzjd}F7nB;#MJ$0`o~uL_68S9LtAPy-oYVzXy3$ksuOuFjj47 zM!m4a(oJNWL!)KU62TaAB^ce|SXP@;a;3lN;J?o;N3?!L^||76_+TR=@hEoCg%>Ev zuOm{P@Teo7_prF?mqXUdzeS&Yy;q$cV{}l-ly7`Bc?(XH_ICEat0ch!YH`k2vYOymJ5rMlcK2|N ztBrsq8VIdrLPx~tLrXhsy2qMc=2f@Yq;K~d7$4iTTotygEY~t&6pe$!WcvdT8)*ve zMnE#2BjN-DzrnSQiBZrmvjl~&MV4T?h{iIyZPS47U8GeM+HTU~2)Forz6uStYJyQWLCg3dP^Rn;r zjB~?+9huA2)j_?(d|rU5ki5$k=V^`f6*E5TXpoh;R6&_vTu%PeMMP<*L9b|+HL=a5 zP2QgYQJI@a8oTw0<=#7<(dcy>5 `${f}=${JSON.stringify(d[f] ?? d[`$${f}`] ?? '')}`); + console.log(` ${parts.join(' ')}`); + } +} + +async function selfCheck(sdk, contractId, limit, proof) { + console.log('Index self-check — each query below requires the named index to succeed.\n'); + + // testCase.testId (unique) + let docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testCase', + where: [['testId', '==', 'CORE-05']], limit: 1, + }, proof); + printDocs("testCase index 'testId' where testId == CORE-05", docs, ['testId', 'title', 'tier', 'implStatus']); + + // testCase.tier + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testCase', + where: [['tier', '==', 'Essential']], orderBy: [['tier', 'asc']], limit, + }, proof); + printDocs("testCase index 'tier' where tier == Essential", docs, ['testId', 'tier', 'category']); + + // testCase.category + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testCase', + where: [['category', '==', 'Identity']], orderBy: [['category', 'asc']], limit, + }, proof); + printDocs("testCase index 'category' where category == Identity", docs, ['testId', 'category', 'tier']); + + // testRun.testIdCreatedAt + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testRun', + where: [['testId', '==', 'CORE-05']], orderBy: [['$createdAt', 'desc']], limit, + }, proof); + printDocs("testRun index 'testIdCreatedAt' where testId == CORE-05 order $createdAt desc", docs, + ['testId', 'result', 'buildRef', 'createdAt']); + + // testRun.resultCreatedAt + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testRun', + where: [['result', '==', 'pass']], orderBy: [['$createdAt', 'desc']], limit, + }, proof); + printDocs("testRun index 'resultCreatedAt' where result == pass order $createdAt desc", docs, + ['testId', 'result', 'buildRef']); + + console.log('\n✅ All indexed queries returned without error — indices are valid.'); +} + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + type: { type: 'string' }, + testId: { type: 'string' }, + tier: { type: 'string' }, + category: { type: 'string' }, + result: { type: 'string' }, + buildRef: { type: 'string' }, + limit: { type: 'string', default: '50' }, + proof: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + }, + }); + const limit = Number(values.limit); + + const { sdk, network } = await connect(); + const cfg = readConfig(network); + if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); + const contractId = cfg.contractId; + console.log(`Connected to ${network}. Contract ${contractId}${values.proof ? ' (proof-verified)' : ''}.`); + + if (!values.type) { await selfCheck(sdk, contractId, limit, values.proof); return; } + + const where = []; const orderBy = []; + if (values.type === 'testCase') { + if (values.testId) where.push(['testId', '==', values.testId]); + if (values.tier) { where.push(['tier', '==', values.tier]); orderBy.push(['tier', 'asc']); } + if (values.category) { where.push(['category', '==', values.category]); orderBy.push(['category', 'asc']); } + } else if (values.type === 'testRun') { + if (values.testId) { where.push(['testId', '==', values.testId]); orderBy.push(['$createdAt', 'desc']); } + if (values.result) { where.push(['result', '==', values.result]); orderBy.push(['$createdAt', 'desc']); } + if (values.buildRef) where.push(['buildRef', '==', values.buildRef]); + } else { + throw new Error("--type must be 'testCase' or 'testRun'."); + } + + const query = { dataContractId: contractId, documentTypeName: values.type, limit }; + if (where.length) query.where = where; + if (orderBy.length) query.orderBy = orderBy; + + const docs = await run(sdk, query, values.proof); + if (values.json) { console.log(JSON.stringify(docs, null, 2)); return; } + const fields = values.type === 'testCase' + ? ['testId', 'title', 'tier', 'category', 'layer', 'implStatus'] + : ['testId', 'result', 'network', 'buildRef', 'device', 'createdAt']; + printDocs(`${values.type} results`, docs, fields); +} + +main().catch((e) => { console.error('query failed:', e?.stack || e); process.exit(1); }); diff --git a/qa-contract/src/register.mjs b/qa-contract/src/register.mjs new file mode 100644 index 00000000000..b8e70b958f2 --- /dev/null +++ b/qa-contract/src/register.mjs @@ -0,0 +1,71 @@ +// Register the QA data contract on the configured network and write the +// resulting contract ID into qa-contract/contract-id..json. +// +// Re-runnable: if the committed config already points at a contract that still +// resolves on-network, it is left untouched (use --force to register a fresh +// contract, e.g. after a testnet reset). +// +// Usage: +// QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/register.mjs [--force] + +import { parseArgs } from 'node:util'; +import { + loadDotEnv, connect, loadOwnerAuth, loadSchema, schemaSha, + readConfig, writeConfig, +} from './sdk.mjs'; +import { resolvePlanCommit } from './parse-test-plan.mjs'; + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ options: { force: { type: 'boolean', default: false } } }); + + const { sdk, mod, network } = await connect(); + console.log(`Connected to ${network}.`); + + // Short-circuit if already registered and still resolvable. + const existing = readConfig(network); + if (existing?.contractId && !values.force) { + const onChain = await sdk.contracts.fetch(existing.contractId).catch(() => undefined); + if (onChain) { + console.log(`Already registered: ${existing.contractId} (still resolves on ${network}).`); + console.log('Pass --force to register a fresh contract.'); + return; + } + console.log(`Config has ${existing.contractId} but it no longer resolves on ${network} ` + + '(testnet reset?). Registering a fresh contract.'); + } + + const { ownerId, identity, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + console.log(`Owner identity ${ownerId} (balance ${identity.balance} credits, ` + + `signing key id=${identityKey.keyId ?? identityKey.id} ${identityKey.purpose}/${identityKey.securityLevel}).`); + + const schemas = loadSchema(); + const { DataContract } = mod; + const dataContract = new DataContract({ + ownerId, + identityNonce: 0n, // overridden by the SDK with the live identity nonce on publish + schemas, + fullValidation: true, + }); + + console.log(`Publishing contract with document types: ${Object.keys(schemas).join(', ')} ...`); + const published = await sdk.contracts.publish({ dataContract, identityKey, signer }); + const contractId = String(published.id); + + const cfg = { + network, + contractId, + ownerId, + documentTypes: Object.keys(schemas), + schemaSha: schemaSha(), + planCommit: resolvePlanCommit() ?? null, + registeredAt: new Date().toISOString(), + }; + const path = writeConfig(cfg, network); + + console.log(`\n✅ Registered QA contract on ${network}`); + console.log(` contractId: ${contractId}`); + console.log(` wrote: ${path}`); +} + +main().catch((e) => { console.error('register failed:', e?.stack || e); process.exit(1); }); diff --git a/qa-contract/src/sdk.mjs b/qa-contract/src/sdk.mjs new file mode 100644 index 00000000000..dfaa794168f --- /dev/null +++ b/qa-contract/src/sdk.mjs @@ -0,0 +1,180 @@ +// Shared helpers for the QA-contract scripts: load the Evo SDK, connect to a +// network, build a signer from a private key, resolve the signing identity key, +// and read/write the committed contract-id config. +// +// SDK loading order of precedence: +// 1. EVO_SDK_BUNDLE env var -> import that file directly (a prebuilt +// dist/evo-sdk.module.js). Useful when the workspace package is not built +// in the current working tree. +// 2. bare import of '@dashevo/evo-sdk' (the normal monorepo path, requires the +// workspace package to be built: `yarn workspace @dashevo/evo-sdk build`). + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, resolve, join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const QA_DIR = resolve(__dirname, '..'); +export const SCHEMA_PATH = join(QA_DIR, 'schema', 'qa-contract.documents.json'); + +// --------------------------------------------------------------------------- +// Minimal .env loader (no dependency). Reads qa-contract/.env if present. +// --------------------------------------------------------------------------- +export function loadDotEnv() { + const envPath = join(QA_DIR, '.env'); + if (!existsSync(envPath)) return; + for (const raw of readFileSync(envPath, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = val; + } +} + +// --------------------------------------------------------------------------- +// SDK module loading + connection +// --------------------------------------------------------------------------- +let _modPromise; +export async function loadSdkModule() { + if (!_modPromise) { + const bundle = process.env.EVO_SDK_BUNDLE; + _modPromise = bundle + ? import(pathToFileURL(resolve(bundle)).href) + : import('@dashevo/evo-sdk'); + } + return _modPromise; +} + +export function getNetwork() { + return (process.env.NETWORK || 'testnet').toLowerCase(); +} + +// Connect a trusted SDK (trusted mode is required so state-transition responses +// are proof-verified). Returns { sdk, mod, network }. +export async function connect() { + const mod = await loadSdkModule(); + const { EvoSDK } = mod; + const network = getNetwork(); + let sdk; + if (network === 'testnet') sdk = EvoSDK.testnetTrusted(); + else if (network === 'mainnet') sdk = EvoSDK.mainnetTrusted(); + else if (network === 'local') sdk = EvoSDK.localTrusted(); + else throw new Error(`Unsupported NETWORK '${network}'. Use testnet, mainnet, or local.`); + await sdk.connect(); + return { sdk, mod, network }; +} + +// --------------------------------------------------------------------------- +// Signer + identity key +// --------------------------------------------------------------------------- + +// Build a PrivateKey + single-key IdentitySigner from a WIF or 64-char hex key. +export function buildSigner(mod, keyString, network) { + const { IdentitySigner, PrivateKey } = mod; + const trimmed = (keyString || '').trim(); + if (!trimmed) throw new Error('Missing private key (set QA_PRIVATE_KEY).'); + const isHex = /^[0-9a-fA-F]{64}$/.test(trimmed); + const privateKey = isHex + ? PrivateKey.fromHex(trimmed, network) + : PrivateKey.fromWIF(trimmed); + const signer = new IdentitySigner(); + signer.addKey(privateKey); + return { signer, privateKey }; +} + +function pubKeyHex(privateKey) { + try { + const pk = privateKey.getPublicKey(); + if (typeof pk.toHex === 'function') return pk.toHex().toLowerCase(); + if (typeof pk.toString === 'function') return pk.toString().toLowerCase(); + } catch { /* ignore */ } + return undefined; +} + +function normalizeKeyData(data) { + // IdentityPublicKey.data may surface as a hex string, base64 string, or bytes. + if (data == null) return undefined; + if (typeof data === 'string') { + const s = data.toLowerCase(); + if (/^[0-9a-f]+$/.test(s)) return s; // already hex + try { return Buffer.from(data, 'base64').toString('hex'); } catch { return s; } + } + try { return Buffer.from(data).toString('hex'); } catch { return undefined; } +} + +// Resolve the IdentityPublicKey to sign with. If QA_IDENTITY_KEY_ID is set, use +// it directly; otherwise auto-detect the key whose public key matches the +// provided private key. Throws with a helpful key listing if nothing matches. +export function resolveIdentityKey(identity, privateKey) { + const keys = identity.publicKeys || []; + const keyIdOf = (k) => Number(k.keyId ?? k.id); + const explicit = process.env.QA_IDENTITY_KEY_ID; + if (explicit !== undefined && explicit !== '') { + const id = Number(explicit); + const k = typeof identity.getPublicKeyById === 'function' + ? identity.getPublicKeyById(id) + : keys.find((x) => keyIdOf(x) === id); + if (!k) throw new Error(`Identity has no public key with id ${id}.`); + return k; + } + const wantHex = pubKeyHex(privateKey); + if (wantHex) { + const match = keys.find((k) => normalizeKeyData(k.data) === wantHex && !k.isReadOnly); + if (match) return match; + } + // Fall back to first writable AUTHENTICATION key (HIGH/CRITICAL) for a clear error if it fails. + const auth = keys.find((k) => String(k.purpose).toUpperCase() === 'AUTHENTICATION' && !k.isReadOnly); + if (auth && !wantHex) return auth; + const listing = keys + .map((k) => ` id=${keyIdOf(k)} purpose=${k.purpose} security=${k.securityLevel} type=${k.keyType} readOnly=${k.isReadOnly}`) + .join('\n'); + throw new Error( + `Could not match the provided private key to any key on identity ${String(identity.id)}.\n` + + `Set QA_IDENTITY_KEY_ID to pick one explicitly. Identity keys:\n${listing}`, + ); +} + +// Load identity + signer + signing key together. Returns { identity, signer, identityKey, privateKey }. +export async function loadOwnerAuth(sdk, mod, network) { + const ownerId = (process.env.QA_IDENTITY_ID || '').trim(); + if (!ownerId) throw new Error('Missing QA identity (set QA_IDENTITY_ID).'); + const { signer, privateKey } = buildSigner(mod, process.env.QA_PRIVATE_KEY, network); + const identity = await sdk.identities.fetch(ownerId); + if (!identity) throw new Error(`Identity ${ownerId} not found on ${network}.`); + const identityKey = resolveIdentityKey(identity, privateKey); + return { ownerId, identity, signer, identityKey, privateKey }; +} + +// --------------------------------------------------------------------------- +// Schema + contract-id config +// --------------------------------------------------------------------------- +export function loadSchema() { + return JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')); +} + +export function schemaSha() { + return createHash('sha256').update(readFileSync(SCHEMA_PATH)).digest('hex').slice(0, 16); +} + +export function configPath(network = getNetwork()) { + return join(QA_DIR, `contract-id.${network}.json`); +} + +export function readConfig(network = getNetwork()) { + const p = configPath(network); + if (!existsSync(p)) return undefined; + return JSON.parse(readFileSync(p, 'utf8')); +} + +export function writeConfig(cfg, network = getNetwork()) { + const p = configPath(network); + writeFileSync(p, `${JSON.stringify(cfg, null, 2)}\n`); + return p; +} diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs new file mode 100644 index 00000000000..2f5e8525cd8 --- /dev/null +++ b/qa-contract/src/seed.mjs @@ -0,0 +1,127 @@ +// Seed testCase documents from SwiftExampleApp/TEST_PLAN.md §4 catalog. +// +// Idempotent: each row is keyed by its unique testId. Existing testCases are +// skipped by default; pass --update to replace ones whose content changed. +// +// Usage: +// QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/seed.mjs +// ... node src/seed.mjs --ids CORE-01,ID-04 --update +// ... node src/seed.mjs --tier Essential --category Identity --limit 10 + +import { parseArgs } from 'node:util'; +import { randomBytes } from 'node:crypto'; +import { + loadDotEnv, connect, loadOwnerAuth, readConfig, +} from './sdk.mjs'; +import { parseTestPlan, resolvePlanCommit, DEFAULT_TEST_PLAN } from './parse-test-plan.mjs'; + +const CONTENT_FIELDS = [ + 'testId', 'title', 'tier', 'category', 'layer', 'implStatus', + 'description', 'entryPoint', 'prerequisites', 'planCommit', +]; + +function cleanProps(row) { + const props = {}; + for (const f of CONTENT_FIELDS) { + if (row[f] !== undefined && row[f] !== null && row[f] !== '') props[f] = row[f]; + } + return props; +} + +function contentEquals(existing, props) { + return CONTENT_FIELDS.every((f) => (existing?.[f] ?? undefined) === (props[f] ?? undefined)); +} + +function csv(v) { return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined; } + +async function findExisting(sdk, contractId, testId) { + const res = await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: 'testCase', + where: [['testId', '==', testId]], + limit: 1, + }); + for (const doc of res.values()) if (doc) return doc; + return undefined; +} + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + plan: { type: 'string' }, + ids: { type: 'string' }, + tier: { type: 'string' }, + category: { type: 'string' }, + limit: { type: 'string' }, + update: { type: 'boolean', default: false }, + }, + }); + + const planPath = values.plan || DEFAULT_TEST_PLAN; + const { sdk, mod, network } = await connect(); + const cfg = readConfig(network); + if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); + const contractId = cfg.contractId; + console.log(`Connected to ${network}. Contract ${contractId}.`); + + const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + + const planCommit = resolvePlanCommit(planPath); + let rows = parseTestPlan(planPath, planCommit); + + const idFilter = csv(values.ids); + const tierFilter = csv(values.tier)?.map((s) => s.toLowerCase()); + const catFilter = csv(values.category)?.map((s) => s.toLowerCase()); + if (idFilter) rows = rows.filter((r) => idFilter.includes(r.testId)); + if (tierFilter) rows = rows.filter((r) => tierFilter.includes(r.tier.toLowerCase())); + if (catFilter) rows = rows.filter((r) => catFilter.includes(r.category.toLowerCase())); + if (values.limit) rows = rows.slice(0, Number(values.limit)); + + console.log(`Plan commit ${planCommit ?? 'unknown'}; seeding ${rows.length} testCase row(s).`); + + const { Document } = mod; + let created = 0; let updated = 0; let skipped = 0; let failed = 0; + + for (const row of rows) { + const props = cleanProps(row); + try { + const existing = await findExisting(sdk, contractId, row.testId); + if (existing) { + const existingJson = existing.toJSON(); + if (!values.update) { skipped += 1; continue; } + if (contentEquals(existingJson, props)) { skipped += 1; continue; } + const doc = new Document({ + id: String(existingJson.$id), + ownerId, + dataContractId: contractId, + documentTypeName: 'testCase', + properties: props, + revision: BigInt(existingJson.$revision ?? 1) + 1n, + }); + await sdk.documents.replace({ document: doc, identityKey, signer }); + updated += 1; + console.log(` ~ updated ${row.testId}`); + } else { + const doc = new Document({ + ownerId, + dataContractId: contractId, + documentTypeName: 'testCase', + properties: props, + entropy: Uint8Array.from(randomBytes(32)), + }); + await sdk.documents.create({ document: doc, identityKey, signer }); + created += 1; + console.log(` + created ${row.testId}`); + } + } catch (e) { + failed += 1; + console.error(` ! ${row.testId} failed: ${e?.message || e}`); + } + } + + console.log(`\nSeed complete: ${created} created, ${updated} updated, ${skipped} skipped, ${failed} failed.`); + if (failed) process.exit(1); +} + +main().catch((e) => { console.error('seed failed:', e?.stack || e); process.exit(1); }); diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs new file mode 100644 index 00000000000..7eea1db1366 --- /dev/null +++ b/qa-contract/src/submit-run.mjs @@ -0,0 +1,71 @@ +// Submit a single testRun document (an append-only test-execution record). +// +// Usage: +// QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/submit-run.mjs \ +// --testId CORE-05 --result pass --buildRef 45fdf33901 \ +// --device "iPhone 16 (iOS 18.2)" --evidence "txid:30010050…" --notes "..." +// +// --result must be one of: pass | fail | blocked | skipped +// --network defaults to $NETWORK (testnet). --blockerReason for blocked/skipped. + +import { parseArgs } from 'node:util'; +import { randomBytes } from 'node:crypto'; +import { + loadDotEnv, connect, loadOwnerAuth, readConfig, getNetwork, +} from './sdk.mjs'; + +const RESULTS = ['pass', 'fail', 'blocked', 'skipped']; + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + testId: { type: 'string' }, + result: { type: 'string' }, + buildRef: { type: 'string' }, + network: { type: 'string' }, + device: { type: 'string' }, + evidence: { type: 'string' }, + notes: { type: 'string' }, + blockerReason: { type: 'string' }, + }, + }); + + const testId = values.testId?.trim(); + const result = values.result?.trim().toLowerCase(); + const buildRef = values.buildRef?.trim(); + if (!testId || !result || !buildRef) { + throw new Error('Required: --testId, --result, --buildRef.'); + } + if (!RESULTS.includes(result)) { + throw new Error(`--result must be one of: ${RESULTS.join(', ')} (got '${result}').`); + } + + const { sdk, mod, network } = await connect(); + const cfg = readConfig(network); + if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); + const contractId = cfg.contractId; + + const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + + const properties = { testId, result, network: values.network?.trim() || getNetwork(), buildRef }; + if (values.device) properties.device = values.device; + if (values.evidence) properties.evidence = values.evidence; + if (values.notes) properties.notes = values.notes; + if (values.blockerReason) properties.blockerReason = values.blockerReason; + + const { Document } = mod; + const doc = new Document({ + ownerId, + dataContractId: contractId, + documentTypeName: 'testRun', + properties, + entropy: Uint8Array.from(randomBytes(32)), + }); + + console.log(`Submitting testRun: ${testId} = ${result} (build ${buildRef}) on ${network} ...`); + await sdk.documents.create({ document: doc, identityKey, signer }); + console.log(`✅ testRun recorded for ${testId} (${result}).`); +} + +main().catch((e) => { console.error('submit-run failed:', e?.stack || e); process.exit(1); }); From 563610407d8408cab1dcb1f68ba709c636366530 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 00:00:50 +0100 Subject: [PATCH 02/15] fix(contract): address PR review feedback for qa-contract - README: add `text` language to the file-tree fenced block (MD040) - query.mjs: validate --limit is a positive integer; add a sixth self-check query so the testRun.buildRef index is also exercised (the no-arg self-check now covers all six indices, matching the docs) - seed.mjs: validate --limit is a positive integer (avoid silent NaN/0/negative slices) - submit-run.mjs: --network now selects the target network before connect(), so the connection, the loaded contract config, and the stamped properties.network all agree (was only stamping metadata) - register.mjs: refuse a no-op re-register when the local schema sha drifts from the registered one (a data contract's schema is immutable; prompts --force to publish a fresh contract) Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 2 +- qa-contract/src/query.mjs | 13 ++++++++++++- qa-contract/src/register.mjs | 11 ++++++++++- qa-contract/src/seed.mjs | 8 +++++++- qa-contract/src/submit-run.mjs | 8 ++++++-- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index e94cceb3bcc..cff936ee43b 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -199,7 +199,7 @@ node src/query.mjs --type testRun --testId CORE-05 --proof ### Files -``` +```text qa-contract/ ├── schema/qa-contract.documents.json # the two document types (the contract schema) ├── contract-id.testnet.json # committed: live contract ID per network diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs index 31e90fefd3c..e58c3e1d1bf 100644 --- a/qa-contract/src/query.mjs +++ b/qa-contract/src/query.mjs @@ -74,7 +74,15 @@ async function selfCheck(sdk, contractId, limit, proof) { printDocs("testRun index 'resultCreatedAt' where result == pass order $createdAt desc", docs, ['testId', 'result', 'buildRef']); - console.log('\n✅ All indexed queries returned without error — indices are valid.'); + // testRun.buildRef + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testRun', + where: [['buildRef', '==', '45fdf33901']], limit, + }, proof); + printDocs("testRun index 'buildRef' where buildRef == 45fdf33901", docs, + ['testId', 'result', 'buildRef']); + + console.log('\n✅ All 6 indexed queries returned without error — indices are valid.'); } async function main() { @@ -93,6 +101,9 @@ async function main() { }, }); const limit = Number(values.limit); + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error(`--limit must be a positive integer (got '${values.limit}').`); + } const { sdk, network } = await connect(); const cfg = readConfig(network); diff --git a/qa-contract/src/register.mjs b/qa-contract/src/register.mjs index b8e70b958f2..69fbbca2bb2 100644 --- a/qa-contract/src/register.mjs +++ b/qa-contract/src/register.mjs @@ -22,11 +22,20 @@ async function main() { const { sdk, mod, network } = await connect(); console.log(`Connected to ${network}.`); + const currentSchemaSha = schemaSha(); + // Short-circuit if already registered and still resolvable. const existing = readConfig(network); if (existing?.contractId && !values.force) { const onChain = await sdk.contracts.fetch(existing.contractId).catch(() => undefined); if (onChain) { + if (existing.schemaSha && existing.schemaSha !== currentSchemaSha) { + throw new Error( + `Existing contract ${existing.contractId} resolves, but the local schema changed ` + + `(${existing.schemaSha} -> ${currentSchemaSha}). A data contract's schema is immutable, ` + + 'so re-run with --force to publish a fresh contract (new id), or revert the schema.', + ); + } console.log(`Already registered: ${existing.contractId} (still resolves on ${network}).`); console.log('Pass --force to register a fresh contract.'); return; @@ -57,7 +66,7 @@ async function main() { contractId, ownerId, documentTypes: Object.keys(schemas), - schemaSha: schemaSha(), + schemaSha: currentSchemaSha, planCommit: resolvePlanCommit() ?? null, registeredAt: new Date().toISOString(), }; diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index 2f5e8525cd8..69c2739848f 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -76,7 +76,13 @@ async function main() { if (idFilter) rows = rows.filter((r) => idFilter.includes(r.testId)); if (tierFilter) rows = rows.filter((r) => tierFilter.includes(r.tier.toLowerCase())); if (catFilter) rows = rows.filter((r) => catFilter.includes(r.category.toLowerCase())); - if (values.limit) rows = rows.slice(0, Number(values.limit)); + if (values.limit !== undefined) { + const limit = Number(values.limit); + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error(`--limit must be a positive integer (got '${values.limit}').`); + } + rows = rows.slice(0, limit); + } console.log(`Plan commit ${planCommit ?? 'unknown'}; seeding ${rows.length} testCase row(s).`); diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs index 7eea1db1366..6899ec9bf40 100644 --- a/qa-contract/src/submit-run.mjs +++ b/qa-contract/src/submit-run.mjs @@ -11,7 +11,7 @@ import { parseArgs } from 'node:util'; import { randomBytes } from 'node:crypto'; import { - loadDotEnv, connect, loadOwnerAuth, readConfig, getNetwork, + loadDotEnv, connect, loadOwnerAuth, readConfig, } from './sdk.mjs'; const RESULTS = ['pass', 'fail', 'blocked', 'skipped']; @@ -41,6 +41,10 @@ async function main() { throw new Error(`--result must be one of: ${RESULTS.join(', ')} (got '${result}').`); } + // --network selects the target network (so the connection, the loaded contract + // config, and the stamped properties.network all agree), defaulting to $NETWORK. + if (values.network?.trim()) process.env.NETWORK = values.network.trim(); + const { sdk, mod, network } = await connect(); const cfg = readConfig(network); if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); @@ -48,7 +52,7 @@ async function main() { const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); - const properties = { testId, result, network: values.network?.trim() || getNetwork(), buildRef }; + const properties = { testId, result, network, buildRef }; if (values.device) properties.device = values.device; if (values.evidence) properties.evidence = values.evidence; if (values.notes) properties.notes = values.notes; From 6fa36ea4a12c2847e8b050320ef9bfab77b2abb5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 01:16:05 +0100 Subject: [PATCH 03/15] feat(contract): re-register QA contract with int network + $ownerId testRun indices New testnet contract 2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ (supersedes 2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw). A data contract's schema is immutable, so these changes required a fresh registration (new id; consumers pinned to the old id must re-pin). - testRun.network is now an integer (0=mainnet, 1=testnet, 2=devnet, 3=regtest), matching the SDK/app Network enum; sdk.mjs gains networkId() and submit-run.mjs stamps the int. - testRun indices replaced with five $ownerId-prefixed indices (sets up v2 multi-submitter, where runs are queried per submitter): ownerTestNetwork ($ownerId, testId, network) ownerTestNetworkCreated ($ownerId, testId, network, $createdAt) ownerTestResultCreated ($ownerId, testId, result, $createdAt) ownerTestCreated ($ownerId, testId, $createdAt) buildRefOwner (buildRef, $ownerId) - query.mjs self-check now exercises all eight indices and scopes testRun queries to the contract owner + testId; --network filter accepts a name and maps to the int. - README + contract-id.testnet.json updated for the new id/schema. Verified on testnet: registered, seeded testCases, submitted a testRun (network=1), and proof-queried all eight indices. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 19 +++-- qa-contract/contract-id.testnet.json | 6 +- qa-contract/schema/qa-contract.documents.json | 56 +++++++++++-- qa-contract/src/query.mjs | 81 +++++++++++++------ qa-contract/src/sdk.mjs | 12 +++ qa-contract/src/submit-run.mjs | 6 +- 6 files changed, 139 insertions(+), 41 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index cff936ee43b..5af21193529 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -23,10 +23,14 @@ so the ID changes when the contract is re-registered (see | | | |---|---| -| Contract ID | `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` | +| Contract ID | `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` | | Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | | Network | testnet | +> Supersedes the initial contract `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` +> (re-registered with an integer `network` field and `$ownerId`-prefixed testRun +> indices). Consumers pinned to the old id must re-pin to the one above. + ```jsonc // contract-id.testnet.json (shape) { @@ -71,7 +75,7 @@ Two document types (full schema in |---|---|---| | `testId` | string (≤32) | matches `testCase.testId`. **Indexed (compound).** | | `result` | string (≤16) | `pass` / `fail` / `blocked` / `skipped`. **Indexed (compound).** | -| `network` | string (≤32) | network the run executed against | +| `network` | integer | network id: `0`=mainnet, `1`=testnet, `2`=devnet, `3`=regtest. **Indexed (compound).** | | `buildRef` | string (≤63) | build under test (commit/branch/build no.). **Indexed.** | | `device` | string (≤128) | device / simulator | | `evidence` | string (≤512) | txid / on-chain id / screenshot path / URL | @@ -79,9 +83,14 @@ Two document types (full schema in | `blockerReason` | string (≤512) | why blocked/skipped | | `$createdAt` | system | **run time**, stamped by the platform; required + indexed | -- Indices: `testIdCreatedAt` (`testId`, `$createdAt`) · `resultCreatedAt` - (`result`, `$createdAt`) · `buildRef`. "Most recent run first" is done at query - time with `orderBy [['$createdAt','desc']]` (see below). +- Indices (all `asc`; `$ownerId`-prefixed so runs are queried per submitter — sets + up v2 multi-submitter): + - `ownerTestNetwork` — `$ownerId`, `testId`, `network` + - `ownerTestNetworkCreated` — `$ownerId`, `testId`, `network`, `$createdAt` + - `ownerTestResultCreated` — `$ownerId`, `testId`, `result`, `$createdAt` + - `ownerTestCreated` — `$ownerId`, `testId`, `$createdAt` + - `buildRefOwner` — `buildRef`, `$ownerId` +- "Most recent run first" is done at query time with `orderBy [['$createdAt','desc']]`. - **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): it is an audit log. `additionalProperties: false`. diff --git a/qa-contract/contract-id.testnet.json b/qa-contract/contract-id.testnet.json index 416ff01832d..c02376333d1 100644 --- a/qa-contract/contract-id.testnet.json +++ b/qa-contract/contract-id.testnet.json @@ -1,12 +1,12 @@ { "network": "testnet", - "contractId": "2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw", + "contractId": "2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ", "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", "documentTypes": [ "testCase", "testRun" ], - "schemaSha": "990aa1b47a4fe61d", + "schemaSha": "c6424e5a60bfe67d", "planCommit": "45fdf33901", - "registeredAt": "2026-06-15T21:30:26.173Z" + "registeredAt": "2026-06-16T00:12:58.992Z" } diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json index 99956c68b96..5d62925c11d 100644 --- a/qa-contract/schema/qa-contract.documents.json +++ b/qa-contract/schema/qa-contract.documents.json @@ -117,19 +117,45 @@ "creationRestrictionMode": 1, "indices": [ { - "name": "testIdCreatedAt", + "name": "ownerTestNetwork", "properties": [ + { + "$ownerId": "asc" + }, { "testId": "asc" }, + { + "network": "asc" + } + ] + }, + { + "name": "ownerTestNetworkCreated", + "properties": [ + { + "$ownerId": "asc" + }, + { + "testId": "asc" + }, + { + "network": "asc" + }, { "$createdAt": "asc" } ] }, { - "name": "resultCreatedAt", + "name": "ownerTestResultCreated", "properties": [ + { + "$ownerId": "asc" + }, + { + "testId": "asc" + }, { "result": "asc" }, @@ -139,10 +165,27 @@ ] }, { - "name": "buildRef", + "name": "ownerTestCreated", + "properties": [ + { + "$ownerId": "asc" + }, + { + "testId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "buildRefOwner", "properties": [ { "buildRef": "asc" + }, + { + "$ownerId": "asc" } ] } @@ -163,11 +206,10 @@ "description": "Outcome. One of: pass, fail, blocked, skipped." }, "network": { - "type": "string", - "minLength": 1, - "maxLength": 32, + "type": "integer", + "minimum": 0, "position": 2, - "description": "Network the run executed against (e.g. testnet, devnet)." + "description": "Network id the run executed against: 0=mainnet, 1=testnet, 2=devnet, 3=regtest." }, "buildRef": { "type": "string", diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs index e58c3e1d1bf..030036ef451 100644 --- a/qa-contract/src/query.mjs +++ b/qa-contract/src/query.mjs @@ -5,13 +5,18 @@ // node src/query.mjs # self-check: exercises every index // node src/query.mjs --type testCase --tier Essential // node src/query.mjs --type testCase --category Identity --limit 5 -// node src/query.mjs --type testRun --testId CORE-05 -// node src/query.mjs --type testRun --result pass +// node src/query.mjs --type testRun --testId CORE-05 # owner+test, newest first +// node src/query.mjs --type testRun --testId CORE-05 --result pass +// node src/query.mjs --type testRun --testId CORE-05 --network testnet // node src/query.mjs --type testRun --buildRef 45fdf33901 // add --proof to fetch with a verified Platform proof, --json for raw output. +// testRun indices are $ownerId-prefixed, so non-buildRef queries scope to the +// contract owner (read from contract-id..json) and need a --testId. import { parseArgs } from 'node:util'; -import { loadDotEnv, connect, readConfig } from './sdk.mjs'; +import { + loadDotEnv, connect, readConfig, networkId, +} from './sdk.mjs'; function toPlain(map) { const out = []; @@ -34,55 +39,70 @@ function printDocs(label, docs, fields) { } } -async function selfCheck(sdk, contractId, limit, proof) { +async function selfCheck(sdk, contractId, ownerId, netId, limit, proof) { console.log('Index self-check — each query below requires the named index to succeed.\n'); - // testCase.testId (unique) + // --- testCase indices: testId (unique), tier, category --- let docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testCase', where: [['testId', '==', 'CORE-05']], limit: 1, }, proof); printDocs("testCase index 'testId' where testId == CORE-05", docs, ['testId', 'title', 'tier', 'implStatus']); - // testCase.tier docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testCase', where: [['tier', '==', 'Essential']], orderBy: [['tier', 'asc']], limit, }, proof); printDocs("testCase index 'tier' where tier == Essential", docs, ['testId', 'tier', 'category']); - // testCase.category docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testCase', where: [['category', '==', 'Identity']], orderBy: [['category', 'asc']], limit, }, proof); printDocs("testCase index 'category' where category == Identity", docs, ['testId', 'category', 'tier']); - // testRun.testIdCreatedAt + // --- testRun indices (all $ownerId-prefixed) --- + const trFields = ['testId', 'result', 'network', 'buildRef', 'createdAt']; + + // ownerTestNetwork: $ownerId, testId, network + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testRun', + where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['network', '==', netId]], limit, + }, proof); + printDocs(`testRun index 'ownerTestNetwork' $ownerId==owner, testId==CORE-05, network==${netId}`, docs, trFields); + + // ownerTestNetworkCreated: $ownerId, testId, network, $createdAt + docs = await run(sdk, { + dataContractId: contractId, documentTypeName: 'testRun', + where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['network', '==', netId]], + orderBy: [['$createdAt', 'desc']], limit, + }, proof); + printDocs("testRun index 'ownerTestNetworkCreated' + order $createdAt desc", docs, trFields); + + // ownerTestResultCreated: $ownerId, testId, result, $createdAt docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testRun', - where: [['testId', '==', 'CORE-05']], orderBy: [['$createdAt', 'desc']], limit, + where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['result', '==', 'pass']], + orderBy: [['$createdAt', 'desc']], limit, }, proof); - printDocs("testRun index 'testIdCreatedAt' where testId == CORE-05 order $createdAt desc", docs, - ['testId', 'result', 'buildRef', 'createdAt']); + printDocs("testRun index 'ownerTestResultCreated' $ownerId==owner, testId==CORE-05, result==pass order $createdAt desc", docs, trFields); - // testRun.resultCreatedAt + // ownerTestCreated: $ownerId, testId, $createdAt docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testRun', - where: [['result', '==', 'pass']], orderBy: [['$createdAt', 'desc']], limit, + where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05']], + orderBy: [['$createdAt', 'desc']], limit, }, proof); - printDocs("testRun index 'resultCreatedAt' where result == pass order $createdAt desc", docs, - ['testId', 'result', 'buildRef']); + printDocs("testRun index 'ownerTestCreated' $ownerId==owner, testId==CORE-05 order $createdAt desc", docs, trFields); - // testRun.buildRef + // buildRefOwner: buildRef, $ownerId docs = await run(sdk, { dataContractId: contractId, documentTypeName: 'testRun', - where: [['buildRef', '==', '45fdf33901']], limit, + where: [['buildRef', '==', '45fdf33901'], ['$ownerId', '==', ownerId]], limit, }, proof); - printDocs("testRun index 'buildRef' where buildRef == 45fdf33901", docs, - ['testId', 'result', 'buildRef']); + printDocs("testRun index 'buildRefOwner' buildRef==45fdf33901, $ownerId==owner", docs, trFields); - console.log('\n✅ All 6 indexed queries returned without error — indices are valid.'); + console.log('\n✅ All 8 indexed queries returned without error — indices are valid.'); } async function main() { @@ -94,6 +114,7 @@ async function main() { tier: { type: 'string' }, category: { type: 'string' }, result: { type: 'string' }, + network: { type: 'string' }, buildRef: { type: 'string' }, limit: { type: 'string', default: '50' }, proof: { type: 'boolean', default: false }, @@ -109,9 +130,11 @@ async function main() { const cfg = readConfig(network); if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); const contractId = cfg.contractId; + const ownerId = cfg.ownerId; + const netId = networkId(network); console.log(`Connected to ${network}. Contract ${contractId}${values.proof ? ' (proof-verified)' : ''}.`); - if (!values.type) { await selfCheck(sdk, contractId, limit, values.proof); return; } + if (!values.type) { await selfCheck(sdk, contractId, ownerId, netId, limit, values.proof); return; } const where = []; const orderBy = []; if (values.type === 'testCase') { @@ -119,9 +142,19 @@ async function main() { if (values.tier) { where.push(['tier', '==', values.tier]); orderBy.push(['tier', 'asc']); } if (values.category) { where.push(['category', '==', values.category]); orderBy.push(['category', 'asc']); } } else if (values.type === 'testRun') { - if (values.testId) { where.push(['testId', '==', values.testId]); orderBy.push(['$createdAt', 'desc']); } - if (values.result) { where.push(['result', '==', values.result]); orderBy.push(['$createdAt', 'desc']); } - if (values.buildRef) where.push(['buildRef', '==', values.buildRef]); + // testRun indices are $ownerId-prefixed (except buildRefOwner). Query by buildRef + // alone uses buildRefOwner; otherwise scope to the owner + testId per the + // owner/test/{network,result}/$createdAt indices. + if (values.buildRef) { + where.push(['buildRef', '==', values.buildRef]); + if (ownerId) where.push(['$ownerId', '==', ownerId]); + } else { + if (ownerId) where.push(['$ownerId', '==', ownerId]); + if (values.testId) where.push(['testId', '==', values.testId]); + if (values.network) where.push(['network', '==', networkId(values.network)]); + if (values.result) where.push(['result', '==', values.result]); + orderBy.push(['$createdAt', 'desc']); + } } else { throw new Error("--type must be 'testCase' or 'testRun'."); } diff --git a/qa-contract/src/sdk.mjs b/qa-contract/src/sdk.mjs index dfaa794168f..ff062054144 100644 --- a/qa-contract/src/sdk.mjs +++ b/qa-contract/src/sdk.mjs @@ -56,6 +56,18 @@ export function getNetwork() { return (process.env.NETWORK || 'testnet').toLowerCase(); } +// Canonical network id (matches the SDK/app Network enum): mainnet=0, testnet=1, +// devnet=2, regtest=3. `local` (dashmate) maps to regtest. Used for the integer +// `network` field on testRun documents. +export const NETWORK_IDS = { + mainnet: 0, testnet: 1, devnet: 2, regtest: 3, local: 3, +}; +export function networkId(name = getNetwork()) { + const id = NETWORK_IDS[String(name).toLowerCase()]; + if (id === undefined) throw new Error(`Unknown network '${name}' (expected one of ${Object.keys(NETWORK_IDS).join(', ')}).`); + return id; +} + // Connect a trusted SDK (trusted mode is required so state-transition responses // are proof-verified). Returns { sdk, mod, network }. export async function connect() { diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs index 6899ec9bf40..323945f2fa9 100644 --- a/qa-contract/src/submit-run.mjs +++ b/qa-contract/src/submit-run.mjs @@ -11,7 +11,7 @@ import { parseArgs } from 'node:util'; import { randomBytes } from 'node:crypto'; import { - loadDotEnv, connect, loadOwnerAuth, readConfig, + loadDotEnv, connect, loadOwnerAuth, readConfig, networkId, } from './sdk.mjs'; const RESULTS = ['pass', 'fail', 'blocked', 'skipped']; @@ -52,7 +52,9 @@ async function main() { const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); - const properties = { testId, result, network, buildRef }; + const properties = { + testId, result, network: networkId(network), buildRef, + }; if (values.device) properties.device = values.device; if (values.evidence) properties.evidence = values.evidence; if (values.notes) properties.notes = values.notes; From 11a50f69c9f4814d17ca1b0443c4070ab2e0780e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 01:39:26 +0100 Subject: [PATCH 04/15] =?UTF-8?q?feat(contract):=20normalize=20QA=20contra?= =?UTF-8?q?ct=20=E2=80=94=20app/tier/category=20lookup=20types=20+=20int?= =?UTF-8?q?=20FKs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New testnet contract 4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv (supersedes 2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ). Schema is immutable, so this is a fresh registration; consumers pinned to the old id must re-pin. - Add app/tier/category lookup document types ({code:int unique, name:string unique}, byCode/byName indices). testCase/testRun now reference them by integer foreign key (`code`) — smaller docs/indices, canonical names in one place, and new tiers/categories/apps can be added without a contract update. Dash Platform has no joins, so consumers resolve code->name client-side from the lookups. - testCase: tier/category are now ints; add `app` (FK); unique index is (testId, app) (testId is unique per app); plus (app, tier) and (app, category). - testRun: add `app` (FK) since testId is no longer globally unique; indices reworked to $ownerId, app, testId, {network|result|—}, $createdAt + buildRefOwner. - src/codes.mjs holds the canonical app/tier/category codes; seed.mjs seeds the lookup docs first then testCases (maps tier/category names -> codes, --app selects the app); submit-run.mjs and query.mjs take --app; query resolves codes -> names for display and its self-check now exercises all 14 indices. - README updated for the normalized schema, codes, and new contract id. Verified on testnet: registered, seeded the 20 lookup docs + testCases, submitted a testRun, and proof-queried all 14 indices. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 95 +++--- qa-contract/contract-id.testnet.json | 9 +- qa-contract/schema/qa-contract.documents.json | 290 ++++++++++-------- qa-contract/src/codes.mjs | 59 ++++ qa-contract/src/query.mjs | 158 +++++----- qa-contract/src/seed.mjs | 88 ++++-- qa-contract/src/submit-run.mjs | 10 +- 7 files changed, 424 insertions(+), 285 deletions(-) create mode 100644 qa-contract/src/codes.mjs diff --git a/qa-contract/README.md b/qa-contract/README.md index 5af21193529..8754572020f 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -23,13 +23,16 @@ so the ID changes when the contract is re-registered (see | | | |---|---| -| Contract ID | `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` | +| Contract ID | `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` | | Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | | Network | testnet | -> Supersedes the initial contract `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` -> (re-registered with an integer `network` field and `$ownerId`-prefixed testRun -> indices). Consumers pinned to the old id must re-pin to the one above. +> A data contract's schema is immutable, so each schema change is a fresh +> registration with a new id. Consumers pinned to an older id must re-pin. +> History: `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` (v1) → +> `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` (v2: integer `network` + +> `$ownerId` testRun indices) → **current** (v3: normalized `app`/`tier`/`category` +> lookup types with integer foreign keys, `(testId, app)` unique). ```jsonc // contract-id.testnet.json (shape) @@ -37,7 +40,7 @@ so the ID changes when the contract is re-registered (see "network": "testnet", "contractId": "", "ownerId": "", - "documentTypes": ["testCase", "testRun"], + "documentTypes": ["app", "tier", "category", "testCase", "testRun"], "schemaSha": "", "planCommit": "", "registeredAt": "" @@ -46,49 +49,66 @@ so the ID changes when the contract is re-registered (see ## Schema -Two document types (full schema in -[`schema/qa-contract.documents.json`](schema/qa-contract.documents.json)): +Five document types (full schema in +[`schema/qa-contract.documents.json`](schema/qa-contract.documents.json)). The +catalog is **normalized**: `app`, `tier`, and `category` are lookup tables, and +`testCase`/`testRun` reference them by an integer **foreign key** (`code`). Dash +Platform has no joins, so consumers fetch the (tiny) lookup tables once and +resolve `code → name` client-side; the canonical codes live in +[`src/codes.mjs`](src/codes.mjs). -### `testCase` — a test definition (mirrors one `TEST_PLAN` §4 row) +### Lookup tables: `app`, `tier`, `category` + +Each is `{ code: integer (unique), name: string (unique) }` (`app` also has +optional `platform` + `description`). Indices: `byCode` (unique), `byName` +(unique). Mutable; owner-only creation — add a new tier/category/app by creating +a doc with the next `code`, **no contract update needed**. Canonical codes: + +- **app**: `0`=SwiftExampleApp +- **tier**: `0`=Essential, `1`=Common, `2`=Thorough, `3`=Uncommon, `4`=Manual, `5`=Unspecified +- **category**: `0`=Core, `1`=Identity, `2`=Address, `3`=DPNS, `4`=Voting, `5`=Contract, `6`=Document, `7`=Token, `8`=Shielded, `9`=DashPay, `10`=Group, `11`=System, `12`=MultiWallet + +### `testCase` — a test definition (mirrors one test-plan row) | Field | Type | Notes | |---|---|---| -| `testId` | string (≤32) | e.g. `CORE-05`. **Unique index.** | +| `testId` | string (≤32) | e.g. `CORE-05`. Unique **per app**. | +| `app` | integer | FK → `app.code`. **Indexed.** | +| `tier` | integer | FK → `tier.code`. **Indexed.** | +| `category` | integer | FK → `category.code`. **Indexed.** | | `title` | string (≤255) | the plan's *Action* column | -| `tier` | string (≤16) | Essential / Common / Thorough / Uncommon / Manual. **Indexed.** | -| `category` | string (≤32) | Domain (Core, Identity, DPNS, Token, …). **Indexed.** | | `layer` | string (≤16) | Core / Platform / Cross / Shielded | | `implStatus` | string (≤32) | status glyph (✅ 🧪 ⚠️ 🔌 🚫) | | `description` | string (≤2048) | entry point & test notes (last plan column) | | `entryPoint` | string (≤512) | primary view / FFI entry point | | `prerequisites` | string (≤1024) | fixtures/preconditions | -| `planCommit` | string (≤64) | `TEST_PLAN.md` commit this row was seeded from | +| `planCommit` | string (≤64) | source-plan commit this row was seeded from | -- Indices: `testId` (unique, asc) · `tier` (asc) · `category` (asc). -- **Mutable** (`documentsMutable: true`) so impl-status / entry-point updates can - be pushed; deletable so removed plan rows can be cleaned up. +- Indices: **`(testId, app)` unique** · `(app, tier)` · `(app, category)`. +- **Mutable** + deletable (impl-status / entry-point updates; removing dropped rows). - `additionalProperties: false`. ### `testRun` — an append-only run record | Field | Type | Notes | |---|---|---| -| `testId` | string (≤32) | matches `testCase.testId`. **Indexed (compound).** | -| `result` | string (≤16) | `pass` / `fail` / `blocked` / `skipped`. **Indexed (compound).** | -| `network` | integer | network id: `0`=mainnet, `1`=testnet, `2`=devnet, `3`=regtest. **Indexed (compound).** | -| `buildRef` | string (≤63) | build under test (commit/branch/build no.). **Indexed.** | +| `testId` | string (≤32) | the run's test (pairs with `app`). **Indexed.** | +| `app` | integer | FK → `app.code`. **Indexed.** | +| `result` | string (≤16) | `pass` / `fail` / `blocked` / `skipped`. **Indexed.** | +| `network` | integer | `0`=mainnet, `1`=testnet, `2`=devnet, `3`=regtest. **Indexed.** | +| `buildRef` | string (≤63) | build under test. **Indexed.** | | `device` | string (≤128) | device / simulator | | `evidence` | string (≤512) | txid / on-chain id / screenshot path / URL | | `notes` | string (≤2048) | free-form notes | | `blockerReason` | string (≤512) | why blocked/skipped | | `$createdAt` | system | **run time**, stamped by the platform; required + indexed | -- Indices (all `asc`; `$ownerId`-prefixed so runs are queried per submitter — sets - up v2 multi-submitter): - - `ownerTestNetwork` — `$ownerId`, `testId`, `network` - - `ownerTestNetworkCreated` — `$ownerId`, `testId`, `network`, `$createdAt` - - `ownerTestResultCreated` — `$ownerId`, `testId`, `result`, `$createdAt` - - `ownerTestCreated` — `$ownerId`, `testId`, `$createdAt` +- Indices (all `asc`; `$ownerId`-prefixed so runs are queried per submitter — + sets up multi-submitter; `app` pairs with `testId`): + - `ownerAppTestNetwork` — `$ownerId`, `app`, `testId`, `network` + - `ownerAppTestNetworkCreated` — `$ownerId`, `app`, `testId`, `network`, `$createdAt` + - `ownerAppTestResultCreated` — `$ownerId`, `app`, `testId`, `result`, `$createdAt` + - `ownerAppTestCreated` — `$ownerId`, `app`, `testId`, `$createdAt` - `buildRefOwner` — `buildRef`, `$ownerId` - "Most recent run first" is done at query time with `orderBy [['$createdAt','desc']]`. - **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): @@ -110,8 +130,8 @@ Two document types (full schema in ## QA identity (v1 ownership) In v1 a **single QA identity** owns the contract and creates every document. -Both document types use `creationRestrictionMode: 1` (**OwnerOnly**), so only -that identity can create `testCase`/`testRun` documents. +All five document types use `creationRestrictionMode: 1` (**OwnerOnly**), so only +that identity can create documents. You need: @@ -210,13 +230,14 @@ node src/query.mjs --type testRun --testId CORE-05 --proof ```text qa-contract/ -├── schema/qa-contract.documents.json # the two document types (the contract schema) +├── schema/qa-contract.documents.json # the five document types (the contract schema) ├── contract-id.testnet.json # committed: live contract ID per network ├── src/ -│ ├── sdk.mjs # SDK load, connect, signer, identity-key, config +│ ├── sdk.mjs # SDK load, connect, signer, identity-key, networkId, config +│ ├── codes.mjs # canonical app/tier/category integer codes │ ├── parse-test-plan.mjs # TEST_PLAN.md §4 catalog parser │ ├── register.mjs # register the contract -│ ├── seed.mjs # seed testCases from the plan (idempotent) +│ ├── seed.mjs # seed lookups + testCases from the plan (idempotent) │ ├── submit-run.mjs # create one testRun │ ├── query.mjs # read back + verify indices │ └── derive-identity-key.mjs # recover signing key from a wallet mnemonic @@ -248,8 +269,12 @@ the new ID. ## How it maps to the test plan -`seed.mjs` parses the §4 catalog tables of `TEST_PLAN.md` — `ID`, `Action`, -`Layer`, `Tier`, `Status` columns plus the section's `Domain=` (→ `category`) — -and creates one `testCase` per row (126 rows at the current plan commit). The -`simulator-control` QA runs then post results with `submit-run.mjs`, so the -on-chain `testRun` log mirrors what the automated QA agent actually executed. +`seed.mjs` first ensures the `app`/`tier`/`category` lookup docs exist (codes from +[`src/codes.mjs`](src/codes.mjs)), then parses the §4 catalog tables of +`TEST_PLAN.md` — `ID`, `Action`, `Layer`, `Tier`, `Status` columns plus the +section's `Domain=` (→ `category`) — and creates one `testCase` per row (126 rows +at the current plan commit) under app `SwiftExampleApp`, mapping tier/category +names to their integer codes. Seed another app's plan with `--app ` (add the +app to `src/codes.mjs` first). The `simulator-control` QA runs then post results +with `submit-run.mjs` (`--app` defaults to SwiftExampleApp), so the on-chain +`testRun` log mirrors what the automated QA agent actually executed. diff --git a/qa-contract/contract-id.testnet.json b/qa-contract/contract-id.testnet.json index c02376333d1..86f0757c0e0 100644 --- a/qa-contract/contract-id.testnet.json +++ b/qa-contract/contract-id.testnet.json @@ -1,12 +1,15 @@ { "network": "testnet", - "contractId": "2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ", + "contractId": "4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv", "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", "documentTypes": [ + "app", + "tier", + "category", "testCase", "testRun" ], - "schemaSha": "c6424e5a60bfe67d", + "schemaSha": "de54ab82068fc7d7", "planCommit": "45fdf33901", - "registeredAt": "2026-06-16T00:12:58.992Z" + "registeredAt": "2026-06-16T00:33:14.109Z" } diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json index 5d62925c11d..0f28a6b9876 100644 --- a/qa-contract/schema/qa-contract.documents.json +++ b/qa-contract/schema/qa-contract.documents.json @@ -1,4 +1,100 @@ { + "app": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 1, + "indices": [ + { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, + { "name": "byName", "properties": [{ "name": "asc" }], "unique": true } + ], + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "position": 0, + "description": "Stable small integer id referenced by testCase.app / testRun.app." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 1, + "description": "App name (e.g. SwiftExampleApp)." + }, + "platform": { + "type": "string", + "maxLength": 32, + "position": 2, + "description": "Platform the app targets (e.g. iOS, Android)." + }, + "description": { + "type": "string", + "maxLength": 512, + "position": 3, + "description": "Optional description of the app under test." + } + }, + "required": ["code", "name"], + "additionalProperties": false, + "description": "An application under QA. testCases/testRuns reference it by the integer `code`. Lets the framework host plans for multiple apps." + }, + "tier": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 1, + "indices": [ + { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, + { "name": "byName", "properties": [{ "name": "asc" }], "unique": true } + ], + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "position": 0, + "description": "Stable small integer id referenced by testCase.tier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "position": 1, + "description": "Frequency tier name: Essential, Common, Thorough, Uncommon, Manual, Unspecified." + } + }, + "required": ["code", "name"], + "additionalProperties": false, + "description": "Lookup table of frequency tiers. testCase.tier holds the integer `code`." + }, + "category": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "creationRestrictionMode": 1, + "indices": [ + { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, + { "name": "byName", "properties": [{ "name": "asc" }], "unique": true } + ], + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "position": 0, + "description": "Stable small integer id referenced by testCase.category." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 1, + "description": "Feature-area / Domain name (Core, Identity, DPNS, Token, Shielded, MultiWallet, ...)." + } + }, + "required": ["code", "name"], + "additionalProperties": false, + "description": "Lookup table of feature-area categories. testCase.category holds the integer `code`." + }, "testCase": { "type": "object", "documentsMutable": true, @@ -6,29 +102,17 @@ "creationRestrictionMode": 1, "indices": [ { - "name": "testId", - "properties": [ - { - "testId": "asc" - } - ], + "name": "testIdApp", + "properties": [{ "testId": "asc" }, { "app": "asc" }], "unique": true }, { - "name": "tier", - "properties": [ - { - "tier": "asc" - } - ] + "name": "appTier", + "properties": [{ "app": "asc" }, { "tier": "asc" }] }, { - "name": "category", - "properties": [ - { - "category": "asc" - } - ] + "name": "appCategory", + "properties": [{ "app": "asc" }, { "category": "asc" }] } ], "properties": { @@ -37,78 +121,75 @@ "minLength": 1, "maxLength": 32, "position": 0, - "description": "Stable test identifier from the test plan (e.g. CORE-05, ID-04, DPNS-05). Unique per testCase." + "description": "Test identifier from the test plan (e.g. CORE-05). Unique per app." }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 255, + "app": { + "type": "integer", + "minimum": 0, "position": 1, - "description": "Human-readable action being tested (the plan's Action column)." + "description": "App this test belongs to (foreign key -> app.code)." }, "tier": { - "type": "string", - "minLength": 1, - "maxLength": 16, + "type": "integer", + "minimum": 0, "position": 2, - "description": "Frequency tier. One of: Essential, Common, Thorough, Uncommon, Manual (Unspecified for stub rows)." + "description": "Frequency tier (foreign key -> tier.code)." }, "category": { + "type": "integer", + "minimum": 0, + "position": 3, + "description": "Feature area / Domain (foreign key -> category.code)." + }, + "title": { "type": "string", "minLength": 1, - "maxLength": 32, - "position": 3, - "description": "Feature area / Domain (e.g. Core, Identity, DPNS, Token, Shielded, MultiWallet)." + "maxLength": 255, + "position": 4, + "description": "Human-readable action being tested (the plan's Action column)." }, "layer": { "type": "string", "minLength": 1, "maxLength": 16, - "position": 4, - "description": "Stack layer. One of: Core, Platform, Cross, Shielded." + "position": 5, + "description": "Stack layer: Core, Platform, Cross, Shielded." }, "implStatus": { "type": "string", "minLength": 1, "maxLength": 32, - "position": 5, - "description": "Implementation status glyph mirrored from the plan (✅ implemented, 🧪 builder-only, ⚠️ partial/mock, 🔌 FFI-only, 🚫 not implemented)." + "position": 6, + "description": "Implementation status glyph (✅ 🧪 ⚠️ 🔌 🚫)." }, "description": { "type": "string", "maxLength": 2048, - "position": 6, - "description": "Entry point & test notes for the action (the plan's last column)." + "position": 7, + "description": "Entry point & test notes for the action." }, "entryPoint": { "type": "string", "maxLength": 512, - "position": 7, - "description": "Primary code entry point (view / FFI function) that drives the action." + "position": 8, + "description": "Primary code entry point (view / FFI function)." }, "prerequisites": { "type": "string", "maxLength": 1024, - "position": 8, + "position": 9, "description": "Fixtures/preconditions required before this test can run." }, "planCommit": { "type": "string", "maxLength": 64, - "position": 9, - "description": "git commit (short or full SHA) of TEST_PLAN.md this testCase was seeded from." + "position": 10, + "description": "git commit of the source plan this testCase was seeded from." } }, - "required": [ - "testId", - "title", - "tier", - "category", - "layer", - "implStatus" - ], + "required": ["testId", "app", "tier", "category", "title", "layer", "implStatus"], "additionalProperties": false, - "description": "A single test definition, mirroring one row of the iOS TEST_PLAN §4 catalog. Mutable so implementation status / entry points can be updated as the plan evolves; owner-only creation (v1 single QA identity)." + "description": "A single test definition, mirroring one row of an app's test plan. Unique per (testId, app); tier/category/app are integer foreign keys into the lookup document types. Mutable; owner-only creation (v1 single QA identity)." }, "testRun": { "type": "object", @@ -117,77 +198,24 @@ "creationRestrictionMode": 1, "indices": [ { - "name": "ownerTestNetwork", - "properties": [ - { - "$ownerId": "asc" - }, - { - "testId": "asc" - }, - { - "network": "asc" - } - ] + "name": "ownerAppTestNetwork", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "network": "asc" }] }, { - "name": "ownerTestNetworkCreated", - "properties": [ - { - "$ownerId": "asc" - }, - { - "testId": "asc" - }, - { - "network": "asc" - }, - { - "$createdAt": "asc" - } - ] + "name": "ownerAppTestNetworkCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "network": "asc" }, { "$createdAt": "asc" }] }, { - "name": "ownerTestResultCreated", - "properties": [ - { - "$ownerId": "asc" - }, - { - "testId": "asc" - }, - { - "result": "asc" - }, - { - "$createdAt": "asc" - } - ] + "name": "ownerAppTestResultCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "result": "asc" }, { "$createdAt": "asc" }] }, { - "name": "ownerTestCreated", - "properties": [ - { - "$ownerId": "asc" - }, - { - "testId": "asc" - }, - { - "$createdAt": "asc" - } - ] + "name": "ownerAppTestCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "$createdAt": "asc" }] }, { "name": "buildRefOwner", - "properties": [ - { - "buildRef": "asc" - }, - { - "$ownerId": "asc" - } - ] + "properties": [{ "buildRef": "asc" }, { "$ownerId": "asc" }] } ], "properties": { @@ -196,61 +224,61 @@ "minLength": 1, "maxLength": 32, "position": 0, - "description": "Test identifier this run is a result for (matches testCase.testId)." + "description": "Test identifier this run is a result for (matches testCase.testId within the same app)." + }, + "app": { + "type": "integer", + "minimum": 0, + "position": 1, + "description": "App this run targets (foreign key -> app.code; pairs with testId)." }, "result": { "type": "string", "minLength": 1, "maxLength": 16, - "position": 1, + "position": 2, "description": "Outcome. One of: pass, fail, blocked, skipped." }, "network": { "type": "integer", "minimum": 0, - "position": 2, + "position": 3, "description": "Network id the run executed against: 0=mainnet, 1=testnet, 2=devnet, 3=regtest." }, "buildRef": { "type": "string", "minLength": 1, "maxLength": 63, - "position": 3, + "position": 4, "description": "Build under test (commit SHA, branch+sha, or app build number)." }, "device": { "type": "string", "maxLength": 128, - "position": 4, - "description": "Device / simulator the run executed on (e.g. iPhone 16 Simulator, iOS 18.2)." + "position": 5, + "description": "Device / simulator the run executed on." }, "evidence": { "type": "string", "maxLength": 512, - "position": 5, + "position": 6, "description": "Pointer to evidence (txid, on-chain id, screenshot path, or URL)." }, "notes": { "type": "string", "maxLength": 2048, - "position": 6, + "position": 7, "description": "Free-form notes about the run." }, "blockerReason": { "type": "string", "maxLength": 512, - "position": 7, - "description": "Why the run was blocked/skipped (precondition unmet, environment limit, etc.)." + "position": 8, + "description": "Why the run was blocked/skipped." } }, - "required": [ - "testId", - "result", - "network", - "buildRef", - "$createdAt" - ], + "required": ["testId", "app", "result", "network", "buildRef", "$createdAt"], "additionalProperties": false, - "description": "An append-only record of one test execution. Immutable and non-deletable: it is an audit log. $createdAt is the run time. Owner-only creation in v1 (single QA identity); relax creationRestrictionMode to 0 to let any identity submit runs." + "description": "An append-only record of one test execution for (app, testId). Immutable and non-deletable: an audit log. $createdAt is the run time. Owner-only creation in v1; relax creationRestrictionMode to 0 to let any identity submit runs." } } diff --git a/qa-contract/src/codes.mjs b/qa-contract/src/codes.mjs new file mode 100644 index 00000000000..54e21440a60 --- /dev/null +++ b/qa-contract/src/codes.mjs @@ -0,0 +1,59 @@ +// Canonical integer codes for the app / tier / category lookup document types. +// These are the foreign-key values stored on testCase (app, tier, category) and +// testRun (app). Codes are STABLE — append new entries with the next id; never +// renumber an existing one (it would orphan already-stored references). + +export const APPS = [ + { code: 0, name: 'SwiftExampleApp', platform: 'iOS', description: 'Dash Platform iOS example wallet (Core SPV + Platform).' }, +]; + +export const TIERS = [ + { code: 0, name: 'Essential' }, + { code: 1, name: 'Common' }, + { code: 2, name: 'Thorough' }, + { code: 3, name: 'Uncommon' }, + { code: 4, name: 'Manual' }, + { code: 5, name: 'Unspecified' }, +]; + +export const CATEGORIES = [ + { code: 0, name: 'Core' }, + { code: 1, name: 'Identity' }, + { code: 2, name: 'Address' }, + { code: 3, name: 'DPNS' }, + { code: 4, name: 'Voting' }, + { code: 5, name: 'Contract' }, + { code: 6, name: 'Document' }, + { code: 7, name: 'Token' }, + { code: 8, name: 'Shielded' }, + { code: 9, name: 'DashPay' }, + { code: 10, name: 'Group' }, + { code: 11, name: 'System' }, + { code: 12, name: 'MultiWallet' }, +]; + +// The app the iOS TEST_PLAN.md belongs to. +export const DEFAULT_APP = 'SwiftExampleApp'; + +const byNameMap = (rows) => Object.fromEntries(rows.map((r) => [r.name.toLowerCase(), r.code])); +const byCodeMap = (rows) => Object.fromEntries(rows.map((r) => [r.code, r.name])); + +const APP_BY_NAME = byNameMap(APPS); +const TIER_BY_NAME = byNameMap(TIERS); +const CATEGORY_BY_NAME = byNameMap(CATEGORIES); + +export const APP_BY_CODE = byCodeMap(APPS); +export const TIER_BY_CODE = byCodeMap(TIERS); +export const CATEGORY_BY_CODE = byCodeMap(CATEGORIES); + +function lookup(map, kind, name) { + const code = map[String(name).toLowerCase()]; + if (code === undefined) { + throw new Error(`Unknown ${kind} '${name}'. Add it to src/codes.mjs (and re-seed the lookup docs).`); + } + return code; +} + +export const appCode = (name) => lookup(APP_BY_NAME, 'app', name); +export const tierCode = (name) => lookup(TIER_BY_NAME, 'tier', name); +export const categoryCode = (name) => lookup(CATEGORY_BY_NAME, 'category', name); diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs index 030036ef451..ce86c780ebd 100644 --- a/qa-contract/src/query.mjs +++ b/qa-contract/src/query.mjs @@ -1,22 +1,28 @@ -// Read back testCase / testRun documents and verify the contract's indices. -// Read-only: no identity or private key required. +// Read back documents and verify the contract's indices. Read-only: no identity +// or private key required. // // Usage: -// node src/query.mjs # self-check: exercises every index -// node src/query.mjs --type testCase --tier Essential -// node src/query.mjs --type testCase --category Identity --limit 5 -// node src/query.mjs --type testRun --testId CORE-05 # owner+test, newest first +// node src/query.mjs # self-check: exercises every index +// node src/query.mjs --type app # list apps (or tier / category) +// node src/query.mjs --type testCase --app SwiftExampleApp --tier Essential +// node src/query.mjs --type testCase --testId CORE-05 +// node src/query.mjs --type testRun --testId CORE-05 # owner+app+test, newest first // node src/query.mjs --type testRun --testId CORE-05 --result pass -// node src/query.mjs --type testRun --testId CORE-05 --network testnet // node src/query.mjs --type testRun --buildRef 45fdf33901 -// add --proof to fetch with a verified Platform proof, --json for raw output. -// testRun indices are $ownerId-prefixed, so non-buildRef queries scope to the -// contract owner (read from contract-id..json) and need a --testId. +// add --proof for a verified Platform proof, --json for raw output. +// +// tier/category/app are integer foreign keys; this tool resolves them to names +// via src/codes.mjs for display. testRun indices are $ownerId-prefixed, so +// non-buildRef testRun queries scope to the contract owner + app (+ a testId). import { parseArgs } from 'node:util'; +import { loadDotEnv, connect, readConfig, networkId } from './sdk.mjs'; import { - loadDotEnv, connect, readConfig, networkId, -} from './sdk.mjs'; + appCode, tierCode, categoryCode, DEFAULT_APP, + APP_BY_CODE, TIER_BY_CODE, CATEGORY_BY_CODE, +} from './codes.mjs'; + +const RESOLVE = { app: APP_BY_CODE, tier: TIER_BY_CODE, category: CATEGORY_BY_CODE }; function toPlain(map) { const out = []; @@ -34,75 +40,52 @@ async function run(sdk, query, proof) { function printDocs(label, docs, fields) { console.log(`\n# ${label} (${docs.length})`); for (const d of docs) { - const parts = fields.map((f) => `${f}=${JSON.stringify(d[f] ?? d[`$${f}`] ?? '')}`); + const parts = fields.map((f) => { + const v = d[f] ?? d[`$${f}`] ?? ''; + const name = RESOLVE[f]?.[v]; + return name !== undefined ? `${f}=${v}(${name})` : `${f}=${JSON.stringify(v)}`; + }); console.log(` ${parts.join(' ')}`); } } -async function selfCheck(sdk, contractId, ownerId, netId, limit, proof) { +async function selfCheck(sdk, contractId, ownerId, app, netId, limit, proof) { console.log('Index self-check — each query below requires the named index to succeed.\n'); - - // --- testCase indices: testId (unique), tier, category --- - let docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testCase', - where: [['testId', '==', 'CORE-05']], limit: 1, - }, proof); - printDocs("testCase index 'testId' where testId == CORE-05", docs, ['testId', 'title', 'tier', 'implStatus']); - - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testCase', - where: [['tier', '==', 'Essential']], orderBy: [['tier', 'asc']], limit, - }, proof); - printDocs("testCase index 'tier' where tier == Essential", docs, ['testId', 'tier', 'category']); - - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testCase', - where: [['category', '==', 'Identity']], orderBy: [['category', 'asc']], limit, - }, proof); - printDocs("testCase index 'category' where category == Identity", docs, ['testId', 'category', 'tier']); - - // --- testRun indices (all $ownerId-prefixed) --- - const trFields = ['testId', 'result', 'network', 'buildRef', 'createdAt']; - - // ownerTestNetwork: $ownerId, testId, network - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testRun', - where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['network', '==', netId]], limit, - }, proof); - printDocs(`testRun index 'ownerTestNetwork' $ownerId==owner, testId==CORE-05, network==${netId}`, docs, trFields); - - // ownerTestNetworkCreated: $ownerId, testId, network, $createdAt - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testRun', - where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['network', '==', netId]], - orderBy: [['$createdAt', 'desc']], limit, - }, proof); - printDocs("testRun index 'ownerTestNetworkCreated' + order $createdAt desc", docs, trFields); - - // ownerTestResultCreated: $ownerId, testId, result, $createdAt - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testRun', - where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05'], ['result', '==', 'pass']], - orderBy: [['$createdAt', 'desc']], limit, + const q = (documentTypeName, where, orderBy) => run(sdk, { + dataContractId: contractId, documentTypeName, where, ...(orderBy ? { orderBy } : {}), limit, }, proof); - printDocs("testRun index 'ownerTestResultCreated' $ownerId==owner, testId==CORE-05, result==pass order $createdAt desc", docs, trFields); - // ownerTestCreated: $ownerId, testId, $createdAt - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testRun', - where: [['$ownerId', '==', ownerId], ['testId', '==', 'CORE-05']], - orderBy: [['$createdAt', 'desc']], limit, - }, proof); - printDocs("testRun index 'ownerTestCreated' $ownerId==owner, testId==CORE-05 order $createdAt desc", docs, trFields); - - // buildRefOwner: buildRef, $ownerId - docs = await run(sdk, { - dataContractId: contractId, documentTypeName: 'testRun', - where: [['buildRef', '==', '45fdf33901'], ['$ownerId', '==', ownerId]], limit, - }, proof); - printDocs("testRun index 'buildRefOwner' buildRef==45fdf33901, $ownerId==owner", docs, trFields); + // --- lookup doc types: byCode, byName --- + for (const [type, code, name] of [['app', 0, 'SwiftExampleApp'], ['tier', 0, 'Essential'], ['category', 1, 'Identity']]) { + printDocs(`${type} index 'byCode' where code == ${code}`, await q(type, [['code', '==', code]]), ['code', 'name']); + printDocs(`${type} index 'byName' where name == ${name}`, await q(type, [['name', '==', name]]), ['code', 'name']); + } - console.log('\n✅ All 8 indexed queries returned without error — indices are valid.'); + // --- testCase indices: testIdApp (unique), appTier, appCategory --- + const tcFields = ['testId', 'app', 'tier', 'category', 'title']; + printDocs("testCase index 'testIdApp' where testId == CORE-05, app == 0", + await q('testCase', [['testId', '==', 'CORE-05'], ['app', '==', app]]), tcFields); + printDocs("testCase index 'appTier' where app == 0, tier == 0 (Essential)", + await q('testCase', [['app', '==', app], ['tier', '==', 0]]), tcFields); + printDocs("testCase index 'appCategory' where app == 0, category == 1 (Identity)", + await q('testCase', [['app', '==', app], ['category', '==', 1]]), tcFields); + + // --- testRun indices (all $ownerId-prefixed except buildRefOwner) --- + const trFields = ['testId', 'app', 'result', 'network', 'buildRef', 'createdAt']; + const base = [['$ownerId', '==', ownerId], ['app', '==', app], ['testId', '==', 'CORE-05']]; + const desc = [['$createdAt', 'desc']]; + printDocs(`testRun index 'ownerAppTestNetwork' $ownerId, app==0, testId==CORE-05, network==${netId}`, + await q('testRun', [...base, ['network', '==', netId]]), trFields); + printDocs("testRun index 'ownerAppTestNetworkCreated' + order $createdAt desc", + await q('testRun', [...base, ['network', '==', netId]], desc), trFields); + printDocs("testRun index 'ownerAppTestResultCreated' + result==pass order $createdAt desc", + await q('testRun', [...base, ['result', '==', 'pass']], desc), trFields); + printDocs("testRun index 'ownerAppTestCreated' $ownerId, app==0, testId==CORE-05 order $createdAt desc", + await q('testRun', base, desc), trFields); + printDocs("testRun index 'buildRefOwner' buildRef==45fdf33901, $ownerId==owner", + await q('testRun', [['buildRef', '==', '45fdf33901'], ['$ownerId', '==', ownerId]]), trFields); + + console.log('\n✅ All 14 indexed queries returned without error — indices are valid.'); } async function main() { @@ -110,6 +93,7 @@ async function main() { const { values } = parseArgs({ options: { type: { type: 'string' }, + app: { type: 'string' }, testId: { type: 'string' }, tier: { type: 'string' }, category: { type: 'string' }, @@ -132,31 +116,33 @@ async function main() { const contractId = cfg.contractId; const ownerId = cfg.ownerId; const netId = networkId(network); + const app = appCode(values.app || DEFAULT_APP); console.log(`Connected to ${network}. Contract ${contractId}${values.proof ? ' (proof-verified)' : ''}.`); - if (!values.type) { await selfCheck(sdk, contractId, ownerId, netId, limit, values.proof); return; } + if (!values.type) { await selfCheck(sdk, contractId, ownerId, app, netId, limit, values.proof); return; } const where = []; const orderBy = []; - if (values.type === 'testCase') { + if (['app', 'tier', 'category'].includes(values.type)) { + orderBy.push(['code', 'asc']); // byCode index + } else if (values.type === 'testCase') { + where.push(['app', '==', app]); if (values.testId) where.push(['testId', '==', values.testId]); - if (values.tier) { where.push(['tier', '==', values.tier]); orderBy.push(['tier', 'asc']); } - if (values.category) { where.push(['category', '==', values.category]); orderBy.push(['category', 'asc']); } + if (values.tier) where.push(['tier', '==', tierCode(values.tier)]); + if (values.category) where.push(['category', '==', categoryCode(values.category)]); } else if (values.type === 'testRun') { - // testRun indices are $ownerId-prefixed (except buildRefOwner). Query by buildRef - // alone uses buildRefOwner; otherwise scope to the owner + testId per the - // owner/test/{network,result}/$createdAt indices. if (values.buildRef) { where.push(['buildRef', '==', values.buildRef]); if (ownerId) where.push(['$ownerId', '==', ownerId]); } else { if (ownerId) where.push(['$ownerId', '==', ownerId]); + where.push(['app', '==', app]); if (values.testId) where.push(['testId', '==', values.testId]); if (values.network) where.push(['network', '==', networkId(values.network)]); if (values.result) where.push(['result', '==', values.result]); orderBy.push(['$createdAt', 'desc']); } } else { - throw new Error("--type must be 'testCase' or 'testRun'."); + throw new Error("--type must be one of: app, tier, category, testCase, testRun."); } const query = { dataContractId: contractId, documentTypeName: values.type, limit }; @@ -165,9 +151,13 @@ async function main() { const docs = await run(sdk, query, values.proof); if (values.json) { console.log(JSON.stringify(docs, null, 2)); return; } - const fields = values.type === 'testCase' - ? ['testId', 'title', 'tier', 'category', 'layer', 'implStatus'] - : ['testId', 'result', 'network', 'buildRef', 'device', 'createdAt']; + const fields = { + app: ['code', 'name', 'platform'], + tier: ['code', 'name'], + category: ['code', 'name'], + testCase: ['testId', 'app', 'tier', 'category', 'title', 'layer', 'implStatus'], + testRun: ['testId', 'app', 'result', 'network', 'buildRef', 'device', 'createdAt'], + }[values.type]; printDocs(`${values.type} results`, docs, fields); } diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index 69c2739848f..fc979f60f30 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -1,29 +1,42 @@ -// Seed testCase documents from SwiftExampleApp/TEST_PLAN.md §4 catalog. +// Seed the contract from an app's test plan (the iOS SwiftExampleApp/TEST_PLAN.md +// §4 catalog). Seeds the app/tier/category lookup documents first, then one +// testCase per plan row with integer foreign keys (app/tier/category codes). // -// Idempotent: each row is keyed by its unique testId. Existing testCases are -// skipped by default; pass --update to replace ones whose content changed. +// Idempotent: lookups are keyed by `code`, testCases by the unique (testId, app). +// Existing docs are skipped by default; --update replaces changed testCases. // // Usage: // QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/seed.mjs -// ... node src/seed.mjs --ids CORE-01,ID-04 --update +// ... node src/seed.mjs --app SwiftExampleApp --ids CORE-01,ID-04 --update // ... node src/seed.mjs --tier Essential --category Identity --limit 10 import { parseArgs } from 'node:util'; import { randomBytes } from 'node:crypto'; -import { - loadDotEnv, connect, loadOwnerAuth, readConfig, -} from './sdk.mjs'; +import { loadDotEnv, connect, loadOwnerAuth, readConfig } from './sdk.mjs'; import { parseTestPlan, resolvePlanCommit, DEFAULT_TEST_PLAN } from './parse-test-plan.mjs'; +import { + APPS, TIERS, CATEGORIES, DEFAULT_APP, appCode, tierCode, categoryCode, +} from './codes.mjs'; const CONTENT_FIELDS = [ - 'testId', 'title', 'tier', 'category', 'layer', 'implStatus', + 'testId', 'app', 'tier', 'category', 'title', 'layer', 'implStatus', 'description', 'entryPoint', 'prerequisites', 'planCommit', ]; -function cleanProps(row) { - const props = {}; - for (const f of CONTENT_FIELDS) { - if (row[f] !== undefined && row[f] !== null && row[f] !== '') props[f] = row[f]; +function entropy() { return Uint8Array.from(randomBytes(32)); } + +function testCaseProps(row, app) { + const props = { + testId: row.testId, + app, + tier: tierCode(row.tier), + category: categoryCode(row.category), + title: row.title, + layer: row.layer, + implStatus: row.implStatus, + }; + for (const f of ['description', 'entryPoint', 'prerequisites', 'planCommit']) { + if (row[f]) props[f] = row[f]; } return props; } @@ -34,21 +47,39 @@ function contentEquals(existing, props) { function csv(v) { return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined; } -async function findExisting(sdk, contractId, testId) { +async function findOne(sdk, contractId, documentTypeName, where) { const res = await sdk.documents.query({ - dataContractId: contractId, - documentTypeName: 'testCase', - where: [['testId', '==', testId]], - limit: 1, + dataContractId: contractId, documentTypeName, where, limit: 1, }); for (const doc of res.values()) if (doc) return doc; return undefined; } +// Ensure the app/tier/category lookup documents exist (keyed by `code`). +async function seedLookups(sdk, Document, contractId, ownerId, signer, identityKey) { + let created = 0; let skipped = 0; + for (const [type, rows] of [['app', APPS], ['tier', TIERS], ['category', CATEGORIES]]) { + for (const row of rows) { + if (await findOne(sdk, contractId, type, [['code', '==', row.code]])) { skipped += 1; continue; } + const props = { code: row.code, name: row.name }; + if (row.platform) props.platform = row.platform; + if (row.description) props.description = row.description; + const doc = new Document({ + ownerId, dataContractId: contractId, documentTypeName: type, properties: props, entropy: entropy(), + }); + await sdk.documents.create({ document: doc, identityKey, signer }); + created += 1; + console.log(` + ${type} ${row.code} = ${row.name}`); + } + } + console.log(`Lookups: ${created} created, ${skipped} skipped.`); +} + async function main() { loadDotEnv(); const { values } = parseArgs({ options: { + app: { type: 'string' }, plan: { type: 'string' }, ids: { type: 'string' }, tier: { type: 'string' }, @@ -58,14 +89,20 @@ async function main() { }, }); + const appName = values.app || DEFAULT_APP; + const app = appCode(appName); const planPath = values.plan || DEFAULT_TEST_PLAN; + const { sdk, mod, network } = await connect(); const cfg = readConfig(network); if (!cfg?.contractId) throw new Error(`No contract registered for ${network}. Run register.mjs first.`); const contractId = cfg.contractId; - console.log(`Connected to ${network}. Contract ${contractId}.`); + console.log(`Connected to ${network}. Contract ${contractId}. App '${appName}' (code ${app}).`); const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + const { Document } = mod; + + await seedLookups(sdk, Document, contractId, ownerId, signer, identityKey); const planCommit = resolvePlanCommit(planPath); let rows = parseTestPlan(planPath, planCommit); @@ -86,17 +123,14 @@ async function main() { console.log(`Plan commit ${planCommit ?? 'unknown'}; seeding ${rows.length} testCase row(s).`); - const { Document } = mod; let created = 0; let updated = 0; let skipped = 0; let failed = 0; - for (const row of rows) { - const props = cleanProps(row); + const props = testCaseProps(row, app); try { - const existing = await findExisting(sdk, contractId, row.testId); + const existing = await findOne(sdk, contractId, 'testCase', [['testId', '==', row.testId], ['app', '==', app]]); if (existing) { const existingJson = existing.toJSON(); - if (!values.update) { skipped += 1; continue; } - if (contentEquals(existingJson, props)) { skipped += 1; continue; } + if (!values.update || contentEquals(existingJson, props)) { skipped += 1; continue; } const doc = new Document({ id: String(existingJson.$id), ownerId, @@ -110,11 +144,7 @@ async function main() { console.log(` ~ updated ${row.testId}`); } else { const doc = new Document({ - ownerId, - dataContractId: contractId, - documentTypeName: 'testCase', - properties: props, - entropy: Uint8Array.from(randomBytes(32)), + ownerId, dataContractId: contractId, documentTypeName: 'testCase', properties: props, entropy: entropy(), }); await sdk.documents.create({ document: doc, identityKey, signer }); created += 1; diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs index 323945f2fa9..b7553336375 100644 --- a/qa-contract/src/submit-run.mjs +++ b/qa-contract/src/submit-run.mjs @@ -13,6 +13,7 @@ import { randomBytes } from 'node:crypto'; import { loadDotEnv, connect, loadOwnerAuth, readConfig, networkId, } from './sdk.mjs'; +import { appCode, DEFAULT_APP } from './codes.mjs'; const RESULTS = ['pass', 'fail', 'blocked', 'skipped']; @@ -21,6 +22,7 @@ async function main() { const { values } = parseArgs({ options: { testId: { type: 'string' }, + app: { type: 'string' }, result: { type: 'string' }, buildRef: { type: 'string' }, network: { type: 'string' }, @@ -31,6 +33,8 @@ async function main() { }, }); + const appName = values.app || DEFAULT_APP; + const app = appCode(appName); const testId = values.testId?.trim(); const result = values.result?.trim().toLowerCase(); const buildRef = values.buildRef?.trim(); @@ -53,7 +57,7 @@ async function main() { const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); const properties = { - testId, result, network: networkId(network), buildRef, + testId, app, result, network: networkId(network), buildRef, }; if (values.device) properties.device = values.device; if (values.evidence) properties.evidence = values.evidence; @@ -69,9 +73,9 @@ async function main() { entropy: Uint8Array.from(randomBytes(32)), }); - console.log(`Submitting testRun: ${testId} = ${result} (build ${buildRef}) on ${network} ...`); + console.log(`Submitting testRun: ${appName}/${testId} = ${result} (build ${buildRef}) on ${network} ...`); await sdk.documents.create({ document: doc, identityKey, signer }); - console.log(`✅ testRun recorded for ${testId} (${result}).`); + console.log(`✅ testRun recorded for ${appName}/${testId} (${result}).`); } main().catch((e) => { console.error('submit-run failed:', e?.stack || e); process.exit(1); }); From 30eb6bb415e70b47b2bf577ff65fc797bc1225e8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 01:45:49 +0100 Subject: [PATCH 05/15] =?UTF-8?q?fix(contract):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20seed=20provenance,=20run=20validation,=20key=20hand?= =?UTF-8?q?ling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed.mjs --update no longer drops a previously stamped planCommit (or any optional field): existing content is carried forward and merged with the new row, so re-running --update from a context without git history can't erase provenance. - submit-run.mjs validates that the (testId, app) testCase exists before writing an immutable testRun — a typo'd testId would otherwise create a permanent orphan; pass --force to override. - derive-identity-key.mjs masks the recovered WIF by default (--print to reveal) and chmods the written .env to 0600 (it holds the private key + mnemonic). - README documents the result vocabulary enforcement and that the on-chain result enum is a re-registration follow-up. The carried-forward result-enum suggestion needs a fresh registration (immutable schema); deferred to the next re-register (CLI enforces the set meanwhile). Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 5 +++++ qa-contract/src/derive-identity-key.mjs | 24 ++++++++++++++++++------ qa-contract/src/seed.mjs | 17 +++++++++++++---- qa-contract/src/submit-run.mjs | 18 ++++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index 8754572020f..14db3d501fe 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -113,6 +113,11 @@ a doc with the next `code`, **no contract update needed**. Canonical codes: - "Most recent run first" is done at query time with `orderBy [['$createdAt','desc']]`. - **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): it is an audit log. `additionalProperties: false`. +- `result` is constrained to `pass|fail|blocked|skipped` by `submit-run.mjs`, and + `submit-run.mjs` refuses an unknown `(testId, app)` (the run would be a permanent + orphan) unless `--force`. Folding `enum:[…]` into the schema itself is a planned + follow-up — it needs a re-registration (a contract's schema is immutable), so it + will land with the next one (testnet reset or the next schema change). > **Platform schema constraints baked into this schema:** > - Indexed string properties are capped at `maxLength ≤ 63`, which is why the diff --git a/qa-contract/src/derive-identity-key.mjs b/qa-contract/src/derive-identity-key.mjs index 6cee3dba14b..6550990d382 100644 --- a/qa-contract/src/derive-identity-key.mjs +++ b/qa-contract/src/derive-identity-key.mjs @@ -12,10 +12,15 @@ // signing contract/document transitions. // // Usage: -// QA_MNEMONIC="..." QA_IDENTITY_ID=... node src/derive-identity-key.mjs [--write] +// QA_MNEMONIC="..." QA_IDENTITY_ID=... node src/derive-identity-key.mjs [--write] [--print] +// +// The recovered WIF is masked by default; pass --print to echo it in full. +// --write saves it to .env and chmods the file to 0600. import { parseArgs } from 'node:util'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { + existsSync, readFileSync, writeFileSync, chmodSync, +} from 'node:fs'; import { join } from 'node:path'; import { loadDotEnv, connect, QA_DIR } from './sdk.mjs'; @@ -34,7 +39,12 @@ function normHex(data) { async function main() { loadDotEnv(); - const { values } = parseArgs({ options: { write: { type: 'boolean', default: false } } }); + const { values } = parseArgs({ + options: { + write: { type: 'boolean', default: false }, + print: { type: 'boolean', default: false }, + }, + }); const mnemonic = (process.env.QA_MNEMONIC || '').trim(); const identityId = (process.env.QA_IDENTITY_ID || '').trim(); @@ -81,8 +91,9 @@ async function main() { const pick = signable.find((m) => m.securityLevel === 'HIGH') || signable[0]; if (!pick) throw new Error('No writable HIGH/CRITICAL AUTHENTICATION key matched.'); + const maskedWif = `${pick.wif.slice(0, 4)}…${pick.wif.slice(-4)}`; console.log(`\nSigning key: id=${pick.id} ${pick.securityLevel} AUTHENTICATION`); - console.log(` WIF: ${pick.wif}`); + console.log(` WIF: ${values.print ? pick.wif : maskedWif}${values.print ? '' : ' (masked — pass --print to reveal)'}`); if (values.write) { const envPath = join(QA_DIR, '.env'); @@ -94,9 +105,10 @@ async function main() { setLine('QA_PRIVATE_KEY', pick.wif); if (Number.isFinite(pick.id)) setLine('QA_IDENTITY_KEY_ID', String(pick.id)); writeFileSync(envPath, env.endsWith('\n') ? env : `${env}\n`); - console.log(`\nWrote QA_PRIVATE_KEY${Number.isFinite(pick.id) ? ' + QA_IDENTITY_KEY_ID' : ''} to ${envPath}`); + chmodSync(envPath, 0o600); // contains a private key + mnemonic — owner-only + console.log(`\nWrote QA_PRIVATE_KEY${Number.isFinite(pick.id) ? ' + QA_IDENTITY_KEY_ID' : ''} to ${envPath} (chmod 0600).`); } else { - console.log('\nRe-run with --write to save QA_PRIVATE_KEY + QA_IDENTITY_KEY_ID into .env.'); + console.log('\nRe-run with --write to save QA_PRIVATE_KEY + QA_IDENTITY_KEY_ID into .env (chmod 0600).'); } } diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index fc979f60f30..7e12939903e 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -41,8 +41,14 @@ function testCaseProps(row, app) { return props; } -function contentEquals(existing, props) { - return CONTENT_FIELDS.every((f) => (existing?.[f] ?? undefined) === (props[f] ?? undefined)); +function pickContent(json) { + const out = {}; + for (const f of CONTENT_FIELDS) if (json?.[f] !== undefined) out[f] = json[f]; + return out; +} + +function contentEquals(a, b) { + return CONTENT_FIELDS.every((f) => (a?.[f] ?? undefined) === (b?.[f] ?? undefined)); } function csv(v) { return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined; } @@ -130,13 +136,16 @@ async function main() { const existing = await findOne(sdk, contractId, 'testCase', [['testId', '==', row.testId], ['app', '==', app]]); if (existing) { const existingJson = existing.toJSON(); - if (!values.update || contentEquals(existingJson, props)) { skipped += 1; continue; } + // Carry forward fields the new row doesn't set (e.g. planCommit when git + // history is unavailable) so --update never silently drops provenance. + const merged = { ...pickContent(existingJson), ...props }; + if (!values.update || contentEquals(existingJson, merged)) { skipped += 1; continue; } const doc = new Document({ id: String(existingJson.$id), ownerId, dataContractId: contractId, documentTypeName: 'testCase', - properties: props, + properties: merged, revision: BigInt(existingJson.$revision ?? 1) + 1n, }); await sdk.documents.replace({ document: doc, identityKey, signer }); diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs index b7553336375..abb20f7a3ec 100644 --- a/qa-contract/src/submit-run.mjs +++ b/qa-contract/src/submit-run.mjs @@ -30,6 +30,7 @@ async function main() { evidence: { type: 'string' }, notes: { type: 'string' }, blockerReason: { type: 'string' }, + force: { type: 'boolean', default: false }, }, }); @@ -56,6 +57,23 @@ async function main() { const { ownerId, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + // testRun is immutable + non-deletable, so a typo'd testId would create a + // permanent orphan. Require the matching (testId, app) testCase to exist first. + if (!values.force) { + const res = await sdk.documents.query({ + dataContractId: contractId, + documentTypeName: 'testCase', + where: [['testId', '==', testId], ['app', '==', app]], + limit: 1, + }); + let exists = false; + for (const d of res.values()) if (d) exists = true; + if (!exists) { + throw new Error(`No testCase '${testId}' for app '${appName}' (code ${app}) on ${network}. ` + + 'Seed it first, or pass --force to record the run anyway.'); + } + } + const properties = { testId, app, result, network: networkId(network), buildRef, }; From 2a67c3b69ec7637a6b139495e0034c3c0b2d5bdf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 01:53:25 +0100 Subject: [PATCH 06/15] fix(contract): harden QA schema + guard testRun query CLI (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (deploys at the next re-registration; post-dates the live v3 contract): - app/tier/category lookup rows are now non-deletable (canBeDeleted:false) so a code referenced by testCase/testRun can't be orphaned. - testRun.result constrained to enum [pass,fail,blocked,skipped]; testRun.network bounded to 0..3 — out-of-vocabulary values can't enter the immutable audit log. - Dropped the redundant ownerAppTestNetwork index (a strict prefix of ownerAppTestNetworkCreated, which serves the same equality queries) so each immutable run doesn't pay storage in two overlapping index trees. query.mjs: - testRun CLI now requires --testId unless --buildRef is used, and rejects --buildRef combined with --testId/--result/--network, so it never builds a query no index can serve (was returning an opaque server error). - self-check drops the now-redundant query (13 indexed queries). README documents the hardening and that the committed schema is ahead of the deployed v3 contract until the next re-register. (The planCommit-drop, testId-existence, and WIF/.env-perms findings were already fixed in 30eb6bb415; this review was cut at 11a50f69, before that commit.) Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 26 ++++++++++++------- qa-contract/schema/qa-contract.documents.json | 12 ++++----- qa-contract/src/query.mjs | 17 ++++++++---- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index 14db3d501fe..55e96309538 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -31,8 +31,14 @@ so the ID changes when the contract is re-registered (see > registration with a new id. Consumers pinned to an older id must re-pin. > History: `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` (v1) → > `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` (v2: integer `network` + -> `$ownerId` testRun indices) → **current** (v3: normalized `app`/`tier`/`category` +> `$ownerId` testRun indices) → **deployed** (v3: normalized `app`/`tier`/`category` > lookup types with integer foreign keys, `(testId, app)` unique). +> +> The committed schema additionally carries hardening (non-deletable lookup rows, +> `result` enum, `network` `0..3`, and a dropped redundant `ownerAppTestNetwork` +> index) that **post-dates the deployed v3 contract** and lands at the next +> re-registration; `register.mjs` refuses a no-op re-run while this drift exists +> (run with `--force` to publish it as the next contract). ```jsonc // contract-id.testnet.json (shape) @@ -61,8 +67,10 @@ resolve `code → name` client-side; the canonical codes live in Each is `{ code: integer (unique), name: string (unique) }` (`app` also has optional `platform` + `description`). Indices: `byCode` (unique), `byName` -(unique). Mutable; owner-only creation — add a new tier/category/app by creating -a doc with the next `code`, **no contract update needed**. Canonical codes: +(unique). Mutable but **non-deletable** (`canBeDeleted: false`) so a `code` +referenced by a testCase/testRun can't be orphaned; owner-only creation — add a +new tier/category/app by creating a doc with the next `code`, **no contract update +needed**. Canonical codes: - **app**: `0`=SwiftExampleApp - **tier**: `0`=Essential, `1`=Common, `2`=Thorough, `3`=Uncommon, `4`=Manual, `5`=Unspecified @@ -105,19 +113,17 @@ a doc with the next `code`, **no contract update needed**. Canonical codes: - Indices (all `asc`; `$ownerId`-prefixed so runs are queried per submitter — sets up multi-submitter; `app` pairs with `testId`): - - `ownerAppTestNetwork` — `$ownerId`, `app`, `testId`, `network` - - `ownerAppTestNetworkCreated` — `$ownerId`, `app`, `testId`, `network`, `$createdAt` + - `ownerAppTestNetworkCreated` — `$ownerId`, `app`, `testId`, `network`, `$createdAt` (also serves the equality-only `…, network` prefix) - `ownerAppTestResultCreated` — `$ownerId`, `app`, `testId`, `result`, `$createdAt` - `ownerAppTestCreated` — `$ownerId`, `app`, `testId`, `$createdAt` - `buildRefOwner` — `buildRef`, `$ownerId` - "Most recent run first" is done at query time with `orderBy [['$createdAt','desc']]`. - **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): it is an audit log. `additionalProperties: false`. -- `result` is constrained to `pass|fail|blocked|skipped` by `submit-run.mjs`, and - `submit-run.mjs` refuses an unknown `(testId, app)` (the run would be a permanent - orphan) unless `--force`. Folding `enum:[…]` into the schema itself is a planned - follow-up — it needs a re-registration (a contract's schema is immutable), so it - will land with the next one (testnet reset or the next schema change). +- `result` is constrained on-chain to `enum:[pass,fail,blocked,skipped]` and + `network` to `0..3`, so out-of-vocabulary values can't enter the immutable log. + `submit-run.mjs` additionally refuses an unknown `(testId, app)` (the run would + be a permanent orphan) unless `--force`. > **Platform schema constraints baked into this schema:** > - Indexed string properties are capped at `maxLength ≤ 63`, which is why the diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json index 0f28a6b9876..004b02afa44 100644 --- a/qa-contract/schema/qa-contract.documents.json +++ b/qa-contract/schema/qa-contract.documents.json @@ -2,7 +2,7 @@ "app": { "type": "object", "documentsMutable": true, - "canBeDeleted": true, + "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, @@ -42,7 +42,7 @@ "tier": { "type": "object", "documentsMutable": true, - "canBeDeleted": true, + "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, @@ -70,7 +70,7 @@ "category": { "type": "object", "documentsMutable": true, - "canBeDeleted": true, + "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, @@ -197,10 +197,6 @@ "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ - { - "name": "ownerAppTestNetwork", - "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "network": "asc" }] - }, { "name": "ownerAppTestNetworkCreated", "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "network": "asc" }, { "$createdAt": "asc" }] @@ -236,12 +232,14 @@ "type": "string", "minLength": 1, "maxLength": 16, + "enum": ["pass", "fail", "blocked", "skipped"], "position": 2, "description": "Outcome. One of: pass, fail, blocked, skipped." }, "network": { "type": "integer", "minimum": 0, + "maximum": 3, "position": 3, "description": "Network id the run executed against: 0=mainnet, 1=testnet, 2=devnet, 3=regtest." }, diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs index ce86c780ebd..e08be7291f0 100644 --- a/qa-contract/src/query.mjs +++ b/qa-contract/src/query.mjs @@ -74,9 +74,8 @@ async function selfCheck(sdk, contractId, ownerId, app, netId, limit, proof) { const trFields = ['testId', 'app', 'result', 'network', 'buildRef', 'createdAt']; const base = [['$ownerId', '==', ownerId], ['app', '==', app], ['testId', '==', 'CORE-05']]; const desc = [['$createdAt', 'desc']]; - printDocs(`testRun index 'ownerAppTestNetwork' $ownerId, app==0, testId==CORE-05, network==${netId}`, - await q('testRun', [...base, ['network', '==', netId]]), trFields); - printDocs("testRun index 'ownerAppTestNetworkCreated' + order $createdAt desc", + // ownerAppTestNetworkCreated also serves the equality-only [$ownerId,app,testId,network] prefix. + printDocs(`testRun index 'ownerAppTestNetworkCreated' $ownerId, app==0, testId==CORE-05, network==${netId} order $createdAt desc`, await q('testRun', [...base, ['network', '==', netId]], desc), trFields); printDocs("testRun index 'ownerAppTestResultCreated' + result==pass order $createdAt desc", await q('testRun', [...base, ['result', '==', 'pass']], desc), trFields); @@ -85,7 +84,7 @@ async function selfCheck(sdk, contractId, ownerId, app, netId, limit, proof) { printDocs("testRun index 'buildRefOwner' buildRef==45fdf33901, $ownerId==owner", await q('testRun', [['buildRef', '==', '45fdf33901'], ['$ownerId', '==', ownerId]]), trFields); - console.log('\n✅ All 14 indexed queries returned without error — indices are valid.'); + console.log('\n✅ All 13 indexed queries returned without error — indices are valid.'); } async function main() { @@ -130,13 +129,21 @@ async function main() { if (values.tier) where.push(['tier', '==', tierCode(values.tier)]); if (values.category) where.push(['category', '==', categoryCode(values.category)]); } else if (values.type === 'testRun') { + // testRun indices are either buildRef-led (buildRefOwner) or $ownerId,app,testId-led. + // Reject combinations no index can serve so the failure is a clear CLI error. if (values.buildRef) { + if (values.testId || values.result || values.network) { + throw new Error('--buildRef uses the buildRefOwner index; it cannot be combined with --testId/--result/--network.'); + } where.push(['buildRef', '==', values.buildRef]); if (ownerId) where.push(['$ownerId', '==', ownerId]); } else { + if (!values.testId) { + throw new Error('testRun queries need --testId (with optional --result/--network), or --buildRef.'); + } if (ownerId) where.push(['$ownerId', '==', ownerId]); where.push(['app', '==', app]); - if (values.testId) where.push(['testId', '==', values.testId]); + where.push(['testId', '==', values.testId]); if (values.network) where.push(['network', '==', networkId(values.network)]); if (values.result) where.push(['result', '==', values.result]); orderBy.push(['$createdAt', 'desc']); From 5339e7083c531cd6be6d44c7772a2029fcd2b72c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 04:44:42 +0100 Subject: [PATCH 07/15] chore(contract): deploy hardened v4 QA contract to testnet Re-registered with the hardening from the prior commit applied on-chain: contract 67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF (supersedes v3 4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv). committed schema now == live. Verified on testnet: registered, seeded the lookup docs + testCases, submitted a testRun (result enum + network bound now enforced on-chain), and proof-queried all 13 indices. (Identity topped up from the QA wallet's Core change via the app's two-step asset-lock flow to cover the register.) Consumers pinned to an older id (incl. the dashboard site) must re-pin to v4. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 14 +++++--------- qa-contract/contract-id.testnet.json | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index 55e96309538..801fc2a68fc 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -23,7 +23,7 @@ so the ID changes when the contract is re-registered (see | | | |---|---| -| Contract ID | `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` | +| Contract ID | `67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF` | | Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | | Network | testnet | @@ -31,14 +31,10 @@ so the ID changes when the contract is re-registered (see > registration with a new id. Consumers pinned to an older id must re-pin. > History: `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` (v1) → > `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` (v2: integer `network` + -> `$ownerId` testRun indices) → **deployed** (v3: normalized `app`/`tier`/`category` -> lookup types with integer foreign keys, `(testId, app)` unique). -> -> The committed schema additionally carries hardening (non-deletable lookup rows, -> `result` enum, `network` `0..3`, and a dropped redundant `ownerAppTestNetwork` -> index) that **post-dates the deployed v3 contract** and lands at the next -> re-registration; `register.mjs` refuses a no-op re-run while this drift exists -> (run with `--force` to publish it as the next contract). +> `$ownerId` testRun indices) → `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` +> (v3: normalized `app`/`tier`/`category` lookup types with integer foreign keys, +> `(testId, app)` unique) → **current** (v4: hardening — non-deletable lookup rows, +> `result` enum, `network` `0..3`, redundant `ownerAppTestNetwork` index dropped). ```jsonc // contract-id.testnet.json (shape) diff --git a/qa-contract/contract-id.testnet.json b/qa-contract/contract-id.testnet.json index 86f0757c0e0..37a7b33c9a8 100644 --- a/qa-contract/contract-id.testnet.json +++ b/qa-contract/contract-id.testnet.json @@ -1,6 +1,6 @@ { "network": "testnet", - "contractId": "4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv", + "contractId": "67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF", "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", "documentTypes": [ "app", @@ -9,7 +9,7 @@ "testCase", "testRun" ], - "schemaSha": "de54ab82068fc7d7", + "schemaSha": "28818bca69425d87", "planCommit": "45fdf33901", - "registeredAt": "2026-06-16T00:33:14.109Z" + "registeredAt": "2026-06-16T03:41:27.236Z" } From e77a17c0bf66876f19b2c3f8a3ba8ecccecd106c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 04:51:11 +0100 Subject: [PATCH 08/15] fix(contract): guard unservable query combos + safer register/key-write (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - query.mjs: reject filter combinations no index can serve, with clear CLI errors instead of opaque Platform "no matching index" failures: * testCase — at most one of --testId / --tier / --category (indices pair app with exactly one of them). * testRun — --network and --result can't be combined (separate indices). - register.mjs: a *thrown* contracts.fetch() error is now treated as transient and aborts, instead of being swallowed to undefined and mistaken for "contract gone" (which could publish a duplicate contract on a flaky lookup). Only a genuine undefined (absent contract) triggers a fresh registration. - derive-identity-key.mjs: write .env with { mode: 0o600 } so the private key is never briefly world-readable between create and chmod. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/src/derive-identity-key.mjs | 4 +++- qa-contract/src/query.mjs | 10 +++++++++- qa-contract/src/register.mjs | 11 ++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/qa-contract/src/derive-identity-key.mjs b/qa-contract/src/derive-identity-key.mjs index 6550990d382..d81140773e8 100644 --- a/qa-contract/src/derive-identity-key.mjs +++ b/qa-contract/src/derive-identity-key.mjs @@ -104,7 +104,9 @@ async function main() { }; setLine('QA_PRIVATE_KEY', pick.wif); if (Number.isFinite(pick.id)) setLine('QA_IDENTITY_KEY_ID', String(pick.id)); - writeFileSync(envPath, env.endsWith('\n') ? env : `${env}\n`); + // mode on writeFileSync applies only when creating the file (closes the + // create-at-0644-then-chmod window); chmodSync covers the overwrite case. + writeFileSync(envPath, env.endsWith('\n') ? env : `${env}\n`, { mode: 0o600 }); chmodSync(envPath, 0o600); // contains a private key + mnemonic — owner-only console.log(`\nWrote QA_PRIVATE_KEY${Number.isFinite(pick.id) ? ' + QA_IDENTITY_KEY_ID' : ''} to ${envPath} (chmod 0600).`); } else { diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs index e08be7291f0..70a482d2a0f 100644 --- a/qa-contract/src/query.mjs +++ b/qa-contract/src/query.mjs @@ -124,6 +124,11 @@ async function main() { if (['app', 'tier', 'category'].includes(values.type)) { orderBy.push(['code', 'asc']); // byCode index } else if (values.type === 'testCase') { + // Indices are testIdApp / appTier / appCategory — each pairs app with exactly + // one of testId/tier/category, so reject combinations no index can serve. + if ([values.testId, values.tier, values.category].filter(Boolean).length > 1) { + throw new Error('Use only one of --testId / --tier / --category for testCase (no app+tier+category index).'); + } where.push(['app', '==', app]); if (values.testId) where.push(['testId', '==', values.testId]); if (values.tier) where.push(['tier', '==', tierCode(values.tier)]); @@ -139,7 +144,10 @@ async function main() { if (ownerId) where.push(['$ownerId', '==', ownerId]); } else { if (!values.testId) { - throw new Error('testRun queries need --testId (with optional --result/--network), or --buildRef.'); + throw new Error('testRun queries need --testId (with optional --result OR --network), or --buildRef.'); + } + if (values.network && values.result) { + throw new Error('--network and --result can\'t be combined for testRun (no covering index; use one).'); } if (ownerId) where.push(['$ownerId', '==', ownerId]); where.push(['app', '==', app]); diff --git a/qa-contract/src/register.mjs b/qa-contract/src/register.mjs index 69fbbca2bb2..b706bae2b51 100644 --- a/qa-contract/src/register.mjs +++ b/qa-contract/src/register.mjs @@ -27,7 +27,16 @@ async function main() { // Short-circuit if already registered and still resolvable. const existing = readConfig(network); if (existing?.contractId && !values.force) { - const onChain = await sdk.contracts.fetch(existing.contractId).catch(() => undefined); + // fetch() returns undefined for a genuinely absent contract (e.g. testnet + // reset). A *thrown* error is transient (gRPC/DNS/etc.) and must NOT be + // mistaken for "gone" — that would publish a duplicate contract. Abort instead. + let onChain; + try { + onChain = await sdk.contracts.fetch(existing.contractId); + } catch (e) { + throw new Error(`Could not verify existing contract ${existing.contractId} on ${network}: ${e?.message || e}. ` + + 'Aborting to avoid registering a duplicate — re-run when reachable, or pass --force to deliberately register a fresh contract.'); + } if (onChain) { if (existing.schemaSha && existing.schemaSha !== currentSchemaSha) { throw new Error( From 8d0261bb878898f8ed6d5bd08eb9c0178b6a963b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 04:54:48 +0100 Subject: [PATCH 09/15] fix(contract): make app/tier/category lookups immutable in schema (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers flagged that mutable lookups could relabel historical immutable testRuns (which store only the integer code). Set documentsMutable:false on app/tier/category so they are a fully stable code table — names can't be relabeled under existing audit-log rows; vocabulary changes add a new row with the next code (already the documented pattern). testCase stays mutable (impl-status updates); testRun stays immutable. Applied in-tree (the schema-of-record); it post-dates the deployed v4 contract (67ctgcKJ…) and takes effect at the next re-registration — not re-registering a fifth contract id solely for this, to avoid churning the live id/dashboard. The register drift-guard + README document the pending state. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 16 +++++++++++----- qa-contract/schema/qa-contract.documents.json | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index 801fc2a68fc..de2fa8be671 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -33,8 +33,14 @@ so the ID changes when the contract is re-registered (see > `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` (v2: integer `network` + > `$ownerId` testRun indices) → `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` > (v3: normalized `app`/`tier`/`category` lookup types with integer foreign keys, -> `(testId, app)` unique) → **current** (v4: hardening — non-deletable lookup rows, +> `(testId, app)` unique) → **deployed** (v4: hardening — non-deletable lookup rows, > `result` enum, `network` `0..3`, redundant `ownerAppTestNetwork` index dropped). +> +> The committed schema additionally makes the `app`/`tier`/`category` lookups +> fully **immutable** (`documentsMutable: false`, so a `code`'s name can't be +> relabeled under historical runs). That post-dates the deployed v4 contract and +> applies at the next re-registration; `register.mjs` flags the drift (use +> `--force` to publish it as the next contract). ```jsonc // contract-id.testnet.json (shape) @@ -63,10 +69,10 @@ resolve `code → name` client-side; the canonical codes live in Each is `{ code: integer (unique), name: string (unique) }` (`app` also has optional `platform` + `description`). Indices: `byCode` (unique), `byName` -(unique). Mutable but **non-deletable** (`canBeDeleted: false`) so a `code` -referenced by a testCase/testRun can't be orphaned; owner-only creation — add a -new tier/category/app by creating a doc with the next `code`, **no contract update -needed**. Canonical codes: +(unique). **Immutable** (`documentsMutable: false`, `canBeDeleted: false`) — a +stable code table, so a `code` referenced by an immutable testRun can't be +orphaned *or relabeled*; owner-only creation — add a new tier/category/app by +creating a doc with the next `code`, **no contract update needed**. Canonical codes: - **app**: `0`=SwiftExampleApp - **tier**: `0`=Essential, `1`=Common, `2`=Thorough, `3`=Uncommon, `4`=Manual, `5`=Unspecified diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json index 004b02afa44..8615ee4c2df 100644 --- a/qa-contract/schema/qa-contract.documents.json +++ b/qa-contract/schema/qa-contract.documents.json @@ -1,7 +1,7 @@ { "app": { "type": "object", - "documentsMutable": true, + "documentsMutable": false, "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ @@ -41,7 +41,7 @@ }, "tier": { "type": "object", - "documentsMutable": true, + "documentsMutable": false, "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ @@ -69,7 +69,7 @@ }, "category": { "type": "object", - "documentsMutable": true, + "documentsMutable": false, "canBeDeleted": false, "creationRestrictionMode": 1, "indices": [ From f513178f4e41781bfb4d222bff9476dc2d5da791 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 05:23:56 +0100 Subject: [PATCH 10/15] fix(contract): drop NUL sentinel in parser + correct standalone install doc (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse-test-plan.mjs: replace the `\0` (literal NUL) sentinel used to protect escaped pipes with a regex split (`/(? --- qa-contract/README.md | 6 +++++- qa-contract/src/parse-test-plan.mjs | Bin 4353 -> 4351 bytes 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index de2fa8be671..96d91cbd991 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -203,9 +203,13 @@ mode (required so state-transition responses are proof-verified). Node ≥ 18.18 ```sh cd qa-contract -yarn install # or npm install +npm install ``` +> Use `npm`, not `yarn`, here: `qa-contract` is intentionally *not* a member of +> the repo's Yarn workspaces, and Yarn 4 aborts when run from a non-member dir. +> (Option B builds the workspace SDK instead and needs no install in this dir.) + **Option B — in-repo (workspace build):** build the workspace SDK once and point the scripts at the bundle (no `yarn install` in this dir needed): diff --git a/qa-contract/src/parse-test-plan.mjs b/qa-contract/src/parse-test-plan.mjs index 6e3c8f1b62e72a744611266835955acca2b68388..b459f6bebf31ec9713e78b381f81de6846fbb692 100644 GIT binary patch delta 139 zcmZov`meabmCZA_ASbg#AwN%{AhRH~SfL~%u|y%UC{-aZzeFLmI61K(HAO)qrbbf- zD3zM0P?`r3R4C6Z$;ix8NKH)6P)JVA$&@Mt$=&Hf=5dJoYY{ delta 141 zcmeyb*r>F@mCYt7wYVTPxkMqgI61K(HASHyvmmutK_jL{Qz0|2I5QVsffuPL76pOiYcs4p@bzUU5NAW{HM+ fjk=~@ZeoFkMzW@Yt(`)$9+G;HQJb%@X>$Pp=jJU6 From b2d39890f66856dd09ed2b44cffd39ad8360d928 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 05:39:07 +0100 Subject: [PATCH 11/15] fix(contract): map NETWORK=local to regtest for evo-sdk key APIs (review) evo-sdk's NetworkLike parser accepts mainnet/testnet/devnet/regtest, not our 'local' alias, so NETWORK=local crashed PrivateKey.fromHex (buildSigner) and made deriveKeyFromSeedWithPath skip every candidate (misleading "No derived key matched"). Add a shared sdkNetwork() helper (local -> regtest, matching the existing NETWORK_IDS alias) and use it in both spots so the documented local workflow works. testnet/mainnet/devnet unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/src/derive-identity-key.mjs | 4 ++-- qa-contract/src/sdk.mjs | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/qa-contract/src/derive-identity-key.mjs b/qa-contract/src/derive-identity-key.mjs index d81140773e8..137437753c9 100644 --- a/qa-contract/src/derive-identity-key.mjs +++ b/qa-contract/src/derive-identity-key.mjs @@ -22,7 +22,7 @@ import { existsSync, readFileSync, writeFileSync, chmodSync, } from 'node:fs'; import { join } from 'node:path'; -import { loadDotEnv, connect, QA_DIR } from './sdk.mjs'; +import { loadDotEnv, connect, sdkNetwork, QA_DIR } from './sdk.mjs'; const COIN = (net) => (net === 'mainnet' ? 5 : 1); const SEC_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; @@ -67,7 +67,7 @@ async function main() { for (let ki = 0; ki <= 6; ki += 1) { const path = `m/9'/${coin}'/5'/0'/${kt}'/${idIdx}'/${ki}'`; let d; - try { d = await mod.wallet.deriveKeyFromSeedWithPath({ mnemonic, path, network }); } catch { continue; } + try { d = await mod.wallet.deriveKeyFromSeedWithPath({ mnemonic, path, network: sdkNetwork(network) }); } catch { continue; } const pub = d.publicKey.toLowerCase(); const hit = onChain.find((k) => k.hex === pub); if (hit) matches.push({ ...hit, path, wif: d.privateKeyWif }); diff --git a/qa-contract/src/sdk.mjs b/qa-contract/src/sdk.mjs index ff062054144..152aa1641d9 100644 --- a/qa-contract/src/sdk.mjs +++ b/qa-contract/src/sdk.mjs @@ -68,6 +68,11 @@ export function networkId(name = getNetwork()) { return id; } +// evo-sdk key/address APIs accept a NetworkLike of mainnet/testnet/devnet/regtest +// (not our 'local' alias). Map it so PrivateKey.fromHex / deriveKeyFromSeedWithPath +// work when NETWORK=local (a dashmate regtest node). +export const sdkNetwork = (name = getNetwork()) => (String(name).toLowerCase() === 'local' ? 'regtest' : name); + // Connect a trusted SDK (trusted mode is required so state-transition responses // are proof-verified). Returns { sdk, mod, network }. export async function connect() { @@ -94,7 +99,7 @@ export function buildSigner(mod, keyString, network) { if (!trimmed) throw new Error('Missing private key (set QA_PRIVATE_KEY).'); const isHex = /^[0-9a-fA-F]{64}$/.test(trimmed); const privateKey = isHex - ? PrivateKey.fromHex(trimmed, network) + ? PrivateKey.fromHex(trimmed, sdkNetwork(network)) : PrivateKey.fromWIF(trimmed); const signer = new IdentitySigner(); signer.addKey(privateKey); From f9e3395ad4ecb7076652ddbce147f4afe590568e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 17:27:47 +0100 Subject: [PATCH 12/15] =?UTF-8?q?fix(contract):=20skip=20retired=20(?= =?UTF-8?q?=E2=9E=96)=20test-plan=20rows=20when=20seeding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retired rows are historical markers in the plan, not runnable tests. Seeding them created a confusing "Unspecified / Unknown, no runs" testCase on the dashboard (e.g. DOC-09, whose local-demo mock was folded into the real broadcast flow — see DOC-02). seed.mjs now drops ➖ rows up front (logging which) so the on-chain catalog omits them; the plan keeps the row for traceability. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/src/seed.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index 7e12939903e..b0df059f17a 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -113,6 +113,16 @@ async function main() { const planCommit = resolvePlanCommit(planPath); let rows = parseTestPlan(planPath, planCommit); + // Retired (➖) rows are historical markers in the plan, not runnable tests — + // skip them so the on-chain catalog (and the dashboard) never carries a + // confusing "Unspecified / Unknown, no runs" entry. E.g. DOC-09, whose + // local-demo mock was folded into the real broadcast flow (see DOC-02). + const retired = rows.filter((r) => (r.implStatus || '').trim() === '➖'); + if (retired.length) { + console.log(`Skipping ${retired.length} retired (➖) row(s): ${retired.map((r) => r.testId).join(', ')}`); + rows = rows.filter((r) => (r.implStatus || '').trim() !== '➖'); + } + const idFilter = csv(values.ids); const tierFilter = csv(values.tier)?.map((s) => s.toLowerCase()); const catFilter = csv(values.category)?.map((s) => s.toLowerCase()); From 2fb83faec47eb364cf3d9065bc4b39dfb5a56dbe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 18 Jun 2026 07:17:16 +0300 Subject: [PATCH 13/15] =?UTF-8?q?fix(contract):=20delete=20on-chain=20reti?= =?UTF-8?q?red=20(=E2=9E=96)=20testCases=20when=20seeding=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retired-row filter only prevented fresh seeding; rows retired after they were already seeded (e.g. a future DOC-09) would linger on-chain as the exact "Unspecified / Unknown, no runs" entry the comment promised to avoid. testCase is deletable, so seed.mjs now deletes any existing testCase for a retired (testId, app) instead of merely skipping it. Selection filters (--ids/--tier/--category) are applied before the retired split so cleanup is scoped to the same selection. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/src/seed.mjs | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index b0df059f17a..8c6633b4fb7 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -113,22 +113,42 @@ async function main() { const planCommit = resolvePlanCommit(planPath); let rows = parseTestPlan(planPath, planCommit); - // Retired (➖) rows are historical markers in the plan, not runnable tests — - // skip them so the on-chain catalog (and the dashboard) never carries a - // confusing "Unspecified / Unknown, no runs" entry. E.g. DOC-09, whose - // local-demo mock was folded into the real broadcast flow (see DOC-02). - const retired = rows.filter((r) => (r.implStatus || '').trim() === '➖'); - if (retired.length) { - console.log(`Skipping ${retired.length} retired (➖) row(s): ${retired.map((r) => r.testId).join(', ')}`); - rows = rows.filter((r) => (r.implStatus || '').trim() !== '➖'); - } - + // Apply selection filters first so retired-cleanup and seeding act on the same scope. const idFilter = csv(values.ids); const tierFilter = csv(values.tier)?.map((s) => s.toLowerCase()); const catFilter = csv(values.category)?.map((s) => s.toLowerCase()); if (idFilter) rows = rows.filter((r) => idFilter.includes(r.testId)); if (tierFilter) rows = rows.filter((r) => tierFilter.includes(r.tier.toLowerCase())); if (catFilter) rows = rows.filter((r) => catFilter.includes(r.category.toLowerCase())); + + // Retired (➖) rows are historical markers in the plan, not runnable tests. Drop + // them from the upsert AND delete any already-seeded testCase for that + // (testId, app), so the on-chain catalog / dashboard never carries a confusing + // "Unspecified / Unknown, no runs" entry (e.g. DOC-09, folded into DOC-02). + const retired = rows.filter((r) => (r.implStatus || '').trim() === '➖'); + rows = rows.filter((r) => (r.implStatus || '').trim() !== '➖'); + let deleted = 0; + for (const r of retired) { + try { + const existing = await findOne(sdk, contractId, 'testCase', [['testId', '==', r.testId], ['app', '==', app]]); + if (!existing) continue; + await sdk.documents.delete({ + document: { + id: String(existing.toJSON().$id), ownerId, dataContractId: contractId, documentTypeName: 'testCase', + }, + identityKey, + signer, + }); + deleted += 1; + console.log(` - retired ${r.testId} (deleted on-chain)`); + } catch (e) { + console.error(` ! retired ${r.testId} delete failed: ${e?.message || e}`); + } + } + if (retired.length) { + console.log(`Retired (➖): ${retired.length} in scope (${retired.map((r) => r.testId).join(', ')}); ${deleted} deleted on-chain.`); + } + if (values.limit !== undefined) { const limit = Number(values.limit); if (!Number.isInteger(limit) || limit <= 0) { From bbf17104e826f524f57448264069306cd5a3062b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 18 Jun 2026 07:31:37 +0300 Subject: [PATCH 14/15] fix(contract): count retired-delete failures in seed exit status (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed retired-row delete was logged but didn't affect the exit code, so a transient blip could leave stale retired testCases on-chain while seed.mjs still exited 0 / "0 failed" — masking the regression from CI/re-run automation. Now tracked in retiredFailed: surfaced in the summary (retired-deleted + combined failed count) and gates the non-zero exit alongside upsert failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/src/seed.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index 8c6633b4fb7..11a3983cef7 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -127,7 +127,7 @@ async function main() { // "Unspecified / Unknown, no runs" entry (e.g. DOC-09, folded into DOC-02). const retired = rows.filter((r) => (r.implStatus || '').trim() === '➖'); rows = rows.filter((r) => (r.implStatus || '').trim() !== '➖'); - let deleted = 0; + let deleted = 0; let retiredFailed = 0; for (const r of retired) { try { const existing = await findOne(sdk, contractId, 'testCase', [['testId', '==', r.testId], ['app', '==', app]]); @@ -142,11 +142,12 @@ async function main() { deleted += 1; console.log(` - retired ${r.testId} (deleted on-chain)`); } catch (e) { + retiredFailed += 1; console.error(` ! retired ${r.testId} delete failed: ${e?.message || e}`); } } if (retired.length) { - console.log(`Retired (➖): ${retired.length} in scope (${retired.map((r) => r.testId).join(', ')}); ${deleted} deleted on-chain.`); + console.log(`Retired (➖): ${retired.length} in scope (${retired.map((r) => r.testId).join(', ')}); ${deleted} deleted, ${retiredFailed} failed.`); } if (values.limit !== undefined) { @@ -195,8 +196,9 @@ async function main() { } } - console.log(`\nSeed complete: ${created} created, ${updated} updated, ${skipped} skipped, ${failed} failed.`); - if (failed) process.exit(1); + console.log(`\nSeed complete: ${created} created, ${updated} updated, ${skipped} skipped, ` + + `${deleted} retired-deleted, ${failed + retiredFailed} failed.`); + if (failed || retiredFailed) process.exit(1); } main().catch((e) => { console.error('seed failed:', e?.stack || e); process.exit(1); }); From fd469b74340854d577086fbe2c715d88141509a9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 18 Jun 2026 07:49:14 +0300 Subject: [PATCH 15/15] =?UTF-8?q?docs(contract):=20fix=20v2=20testRun-open?= =?UTF-8?q?=20path=20=E2=80=94=20needs=20fresh=20registration,=20not=20upd?= =?UTF-8?q?ate=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DPP's validate_update unconditionally rejects changing a document type's creationRestrictionMode (DocumentTypeUpdateError), so the README's advice to open testRun creation "via a data-contract update" would fail on-chain. Reworded to require a fresh contract registration (before first register, or at the next re-register / testnet reset), which mints a new id consumers must re-pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa-contract/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/qa-contract/README.md b/qa-contract/README.md index 96d91cbd991..a1b4bef2057 100644 --- a/qa-contract/README.md +++ b/qa-contract/README.md @@ -183,12 +183,14 @@ matches them to the on-chain public keys, and writes `QA_PRIVATE_KEY` + ### Extending to per-team-member `testRun` submission (v2) -To let any identity submit runs (while keeping `testCase` owner-controlled), -change **`testRun`** only: - -- set `creationRestrictionMode: 0` (NoRestrictions) on `testRun`, and -- register the change via a data-contract **update** (or re-register on the next - testnet reset). +To let any identity submit runs (while keeping `testCase` owner-controlled), set +`creationRestrictionMode: 0` (NoRestrictions) on **`testRun`** only. + +⚠️ This must be done in a **fresh contract registration**, *not* a data-contract +update: DPP rejects any change to a document type's `creationRestrictionMode` on +update (`DocumentTypeUpdateError`, see `validate_update`). So apply it before the +first registration, or fold it into the next re-register (e.g. a testnet reset) — +which mints a new contract id consumers must re-pin. `testRun` is already immutable + owner-stamped (`$ownerId`/`$createdAt` are system fields), so opening creation keeps every run attributable and tamper-proof.