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..a1b4bef2057 --- /dev/null +++ b/qa-contract/README.md @@ -0,0 +1,299 @@ +# 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 | `67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF` | +| Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | +| Network | testnet | + +> 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) → `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` +> (v3: normalized `app`/`tier`/`category` lookup types with integer foreign keys, +> `(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) +{ + "network": "testnet", + "contractId": "", + "ownerId": "", + "documentTypes": ["app", "tier", "category", "testCase", "testRun"], + "schemaSha": "", + "planCommit": "", + "registeredAt": "" +} +``` + +## Schema + +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). + +### Lookup tables: `app`, `tier`, `category` + +Each is `{ code: integer (unique), name: string (unique) }` (`app` also has +optional `platform` + `description`). Indices: `byCode` (unique), `byName` +(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 +- **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 **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 | +| `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) | source-plan commit this row was seeded from | + +- 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) | 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 multi-submitter; `app` pairs with `testId`): + - `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 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 +> 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. +All five document types use `creationRestrictionMode: 1` (**OwnerOnly**), so only +that identity can create 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), 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. +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 +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): + +```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 + +```text +qa-contract/ +├── 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, 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 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 +├── .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` 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 new file mode 100644 index 00000000000..37a7b33c9a8 --- /dev/null +++ b/qa-contract/contract-id.testnet.json @@ -0,0 +1,15 @@ +{ + "network": "testnet", + "contractId": "67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF", + "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", + "documentTypes": [ + "app", + "tier", + "category", + "testCase", + "testRun" + ], + "schemaSha": "28818bca69425d87", + "planCommit": "45fdf33901", + "registeredAt": "2026-06-16T03:41:27.236Z" +} 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..8615ee4c2df --- /dev/null +++ b/qa-contract/schema/qa-contract.documents.json @@ -0,0 +1,282 @@ +{ + "app": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "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": false, + "canBeDeleted": false, + "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": false, + "canBeDeleted": false, + "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, + "canBeDeleted": true, + "creationRestrictionMode": 1, + "indices": [ + { + "name": "testIdApp", + "properties": [{ "testId": "asc" }, { "app": "asc" }], + "unique": true + }, + { + "name": "appTier", + "properties": [{ "app": "asc" }, { "tier": "asc" }] + }, + { + "name": "appCategory", + "properties": [{ "app": "asc" }, { "category": "asc" }] + } + ], + "properties": { + "testId": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 0, + "description": "Test identifier from the test plan (e.g. CORE-05). Unique per app." + }, + "app": { + "type": "integer", + "minimum": 0, + "position": 1, + "description": "App this test belongs to (foreign key -> app.code)." + }, + "tier": { + "type": "integer", + "minimum": 0, + "position": 2, + "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": 255, + "position": 4, + "description": "Human-readable action being tested (the plan's Action column)." + }, + "layer": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "position": 5, + "description": "Stack layer: Core, Platform, Cross, Shielded." + }, + "implStatus": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 6, + "description": "Implementation status glyph (✅ 🧪 ⚠️ 🔌 🚫)." + }, + "description": { + "type": "string", + "maxLength": 2048, + "position": 7, + "description": "Entry point & test notes for the action." + }, + "entryPoint": { + "type": "string", + "maxLength": 512, + "position": 8, + "description": "Primary code entry point (view / FFI function)." + }, + "prerequisites": { + "type": "string", + "maxLength": 1024, + "position": 9, + "description": "Fixtures/preconditions required before this test can run." + }, + "planCommit": { + "type": "string", + "maxLength": 64, + "position": 10, + "description": "git commit of the source plan this testCase was seeded from." + } + }, + "required": ["testId", "app", "tier", "category", "title", "layer", "implStatus"], + "additionalProperties": false, + "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", + "documentsMutable": false, + "canBeDeleted": false, + "creationRestrictionMode": 1, + "indices": [ + { + "name": "ownerAppTestNetworkCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "network": "asc" }, { "$createdAt": "asc" }] + }, + { + "name": "ownerAppTestResultCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "result": "asc" }, { "$createdAt": "asc" }] + }, + { + "name": "ownerAppTestCreated", + "properties": [{ "$ownerId": "asc" }, { "app": "asc" }, { "testId": "asc" }, { "$createdAt": "asc" }] + }, + { + "name": "buildRefOwner", + "properties": [{ "buildRef": "asc" }, { "$ownerId": "asc" }] + } + ], + "properties": { + "testId": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "position": 0, + "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, + "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." + }, + "buildRef": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 4, + "description": "Build under test (commit SHA, branch+sha, or app build number)." + }, + "device": { + "type": "string", + "maxLength": 128, + "position": 5, + "description": "Device / simulator the run executed on." + }, + "evidence": { + "type": "string", + "maxLength": 512, + "position": 6, + "description": "Pointer to evidence (txid, on-chain id, screenshot path, or URL)." + }, + "notes": { + "type": "string", + "maxLength": 2048, + "position": 7, + "description": "Free-form notes about the run." + }, + "blockerReason": { + "type": "string", + "maxLength": 512, + "position": 8, + "description": "Why the run was blocked/skipped." + } + }, + "required": ["testId", "app", "result", "network", "buildRef", "$createdAt"], + "additionalProperties": false, + "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/derive-identity-key.mjs b/qa-contract/src/derive-identity-key.mjs new file mode 100644 index 00000000000..137437753c9 --- /dev/null +++ b/qa-contract/src/derive-identity-key.mjs @@ -0,0 +1,117 @@ +// 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] [--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, chmodSync, +} from 'node:fs'; +import { join } from 'node:path'; +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 }; + +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 }, + print: { 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: 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 }); + } + } + } + + 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.'); + + const maskedWif = `${pick.wif.slice(0, 4)}…${pick.wif.slice(-4)}`; + console.log(`\nSigning key: id=${pick.id} ${pick.securityLevel} AUTHENTICATION`); + console.log(` WIF: ${values.print ? pick.wif : maskedWif}${values.print ? '' : ' (masked — pass --print to reveal)'}`); + + 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)); + // 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 { + console.log('\nRe-run with --write to save QA_PRIVATE_KEY + QA_IDENTITY_KEY_ID into .env (chmod 0600).'); + } +} + +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 00000000000..b459f6bebf3 --- /dev/null +++ b/qa-contract/src/parse-test-plan.mjs @@ -0,0 +1,122 @@ +// Parse the §4 catalog tables of SwiftExampleApp/TEST_PLAN.md into testCase rows. +// +// Each catalog row looks like: +// | CORE-05 | Send Core L1 transaction | Core | Essential | ✅ | `SendTransactionView` ... | +// Columns: ID | Action(title) | Layer | Tier | Status(implStatus) | Entry point & notes +// The Category/Domain is NOT a column — it comes from the section header, e.g. +// ### 4.1 Core / Wallet — `Domain=Core` +// Only content between "## 4." and "## 5." is parsed. + +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { QA_DIR } from './sdk.mjs'; + +export const REPO_ROOT = resolve(QA_DIR, '..'); +export const DEFAULT_TEST_PLAN = join( + REPO_ROOT, + 'packages', + 'swift-sdk', + 'SwiftExampleApp', + 'TEST_PLAN.md', +); + +// Resolve the TEST_PLAN commit to stamp on records: $PLAN_COMMIT, else the git +// short-sha of the plan file, else undefined. +export function resolvePlanCommit(planPath = DEFAULT_TEST_PLAN) { + if (process.env.PLAN_COMMIT) return process.env.PLAN_COMMIT.trim(); + try { + return execFileSync('git', ['log', '-1', '--format=%h', '--', planPath], { + cwd: REPO_ROOT, encoding: 'utf8', + }).trim() || undefined; + } catch { return undefined; } +} + +const TIERS = ['Essential', 'Common', 'Thorough', 'Uncommon', 'Manual']; +const ID_RE = /^[A-Z][A-Z0-9]*-\d+$/; + +function normalizeTier(raw) { + const t = (raw || '').trim(); + const hit = TIERS.find((x) => x.toLowerCase() === t.toLowerCase()); + return hit || 'Unspecified'; +} + +function splitRow(line) { + // Split on pipes that are not escaped (\|), then unescape within each cell. + const cells = line.split(/(? c.replaceAll('\\|', '|').trim()); + // A markdown row starts and ends with '|', producing empty first/last cells. + if (cells.length && cells[0] === '') cells.shift(); + if (cells.length && cells[cells.length - 1] === '') cells.pop(); + return cells; +} + +function isSeparator(cells) { + return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c)); +} + +function firstCodeToken(notes) { + const m = notes.match(/`([^`]+)`/); + return m ? m[1] : ''; +} + +function truncate(s, max) { + if (s == null) return undefined; + const str = String(s); + if (str.length <= max) return str; + return `${str.slice(0, max - 1)}…`; +} + +export function parseTestPlan(planPath = DEFAULT_TEST_PLAN, planCommit) { + if (!existsSync(planPath)) throw new Error(`TEST_PLAN not found at ${planPath}`); + const lines = readFileSync(planPath, 'utf8').split('\n'); + + const rows = []; + let inCatalog = false; + let currentCategory; + + for (const line of lines) { + const trimmed = line.trim(); + + // Section bounds: enter at "## 4.", leave at "## 5.". + if (/^##\s+4\./.test(trimmed)) { inCatalog = true; continue; } + if (/^##\s+5\./.test(trimmed)) { inCatalog = false; continue; } + if (!inCatalog) continue; + + // Track the current Domain/Category from section headers. + const dom = trimmed.match(/Domain\s*=\s*([A-Za-z]+)/); + if (dom) { currentCategory = dom[1]; continue; } + if (trimmed.startsWith('#')) continue; + + if (!trimmed.startsWith('|')) continue; + const cells = splitRow(trimmed); + if (cells.length < 6) continue; + if (isSeparator(cells)) continue; + + const [testId, title, layer, tier, status, ...rest] = cells; + if (!ID_RE.test(testId)) continue; // header row or non-catalog row + + const notes = rest.join(' | ').trim(); + rows.push({ + testId, + title: truncate(title, 255), + tier: normalizeTier(tier), + category: currentCategory || 'Unknown', + layer: layer || 'Unknown', + implStatus: truncate(status || '?', 32), + description: truncate(notes, 2048), + entryPoint: truncate(firstCodeToken(notes), 512) || undefined, + ...(planCommit ? { planCommit: truncate(planCommit, 64) } : {}), + }); + } + return rows; +} + +// Allow running directly for a quick sanity check: `node src/parse-test-plan.mjs` +if (import.meta.url === `file://${process.argv[1]}`) { + const rows = parseTestPlan(); + console.log(`Parsed ${rows.length} catalog rows.`); + const byCat = {}; + for (const r of rows) byCat[r.category] = (byCat[r.category] || 0) + 1; + console.log('By category:', byCat); + console.log('First 3:', JSON.stringify(rows.slice(0, 3), null, 2)); +} diff --git a/qa-contract/src/query.mjs b/qa-contract/src/query.mjs new file mode 100644 index 00000000000..70a482d2a0f --- /dev/null +++ b/qa-contract/src/query.mjs @@ -0,0 +1,179 @@ +// 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 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 --buildRef 45fdf33901 +// 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 { + 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 = []; + for (const doc of map.values()) if (doc) out.push(doc.toJSON()); + return out; +} + +async function run(sdk, query, proof) { + const res = proof + ? (await sdk.documents.queryWithProof(query)).data + : await sdk.documents.query(query); + return toPlain(res); +} + +function printDocs(label, docs, fields) { + console.log(`\n# ${label} (${docs.length})`); + for (const d of docs) { + 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, app, netId, limit, proof) { + console.log('Index self-check — each query below requires the named index to succeed.\n'); + const q = (documentTypeName, where, orderBy) => run(sdk, { + dataContractId: contractId, documentTypeName, where, ...(orderBy ? { orderBy } : {}), limit, + }, proof); + + // --- 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']); + } + + // --- 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']]; + // 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); + 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 13 indexed queries returned without error — indices are valid.'); +} + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + type: { type: 'string' }, + app: { type: 'string' }, + testId: { type: 'string' }, + 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 }, + json: { type: 'boolean', default: false }, + }, + }); + 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); + 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); + 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, app, netId, limit, values.proof); return; } + + const where = []; const orderBy = []; + 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)]); + 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 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]); + 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 one of: app, tier, category, testCase, 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 = { + 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); +} + +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..b706bae2b51 --- /dev/null +++ b/qa-contract/src/register.mjs @@ -0,0 +1,89 @@ +// 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}.`); + + const currentSchemaSha = schemaSha(); + + // Short-circuit if already registered and still resolvable. + const existing = readConfig(network); + if (existing?.contractId && !values.force) { + // 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( + `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; + } + 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: currentSchemaSha, + 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..152aa1641d9 --- /dev/null +++ b/qa-contract/src/sdk.mjs @@ -0,0 +1,197 @@ +// 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(); +} + +// 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; +} + +// 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() { + 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, sdkNetwork(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..11a3983cef7 --- /dev/null +++ b/qa-contract/src/seed.mjs @@ -0,0 +1,204 @@ +// 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: 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 --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 { 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', 'app', 'tier', 'category', 'title', 'layer', 'implStatus', + 'description', 'entryPoint', 'prerequisites', 'planCommit', +]; + +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; +} + +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; } + +async function findOne(sdk, contractId, documentTypeName, where) { + const res = await sdk.documents.query({ + 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' }, + category: { type: 'string' }, + limit: { type: 'string' }, + update: { type: 'boolean', default: false }, + }, + }); + + 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}. 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); + + // 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; let retiredFailed = 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) { + 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, ${retiredFailed} failed.`); + } + + 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).`); + + let created = 0; let updated = 0; let skipped = 0; let failed = 0; + for (const row of rows) { + const props = testCaseProps(row, app); + try { + const existing = await findOne(sdk, contractId, 'testCase', [['testId', '==', row.testId], ['app', '==', app]]); + if (existing) { + const existingJson = existing.toJSON(); + // 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: merged, + 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: entropy(), + }); + 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, ` + + `${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); }); diff --git a/qa-contract/src/submit-run.mjs b/qa-contract/src/submit-run.mjs new file mode 100644 index 00000000000..abb20f7a3ec --- /dev/null +++ b/qa-contract/src/submit-run.mjs @@ -0,0 +1,99 @@ +// 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, networkId, +} from './sdk.mjs'; +import { appCode, DEFAULT_APP } from './codes.mjs'; + +const RESULTS = ['pass', 'fail', 'blocked', 'skipped']; + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + testId: { type: 'string' }, + app: { type: 'string' }, + result: { type: 'string' }, + buildRef: { type: 'string' }, + network: { type: 'string' }, + device: { type: 'string' }, + evidence: { type: 'string' }, + notes: { type: 'string' }, + blockerReason: { type: 'string' }, + force: { type: 'boolean', default: false }, + }, + }); + + 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(); + 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}').`); + } + + // --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.`); + const contractId = cfg.contractId; + + 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, + }; + 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: ${appName}/${testId} = ${result} (build ${buildRef}) on ${network} ...`); + await sdk.documents.create({ document: doc, identityKey, signer }); + console.log(`✅ testRun recorded for ${appName}/${testId} (${result}).`); +} + +main().catch((e) => { console.error('submit-run failed:', e?.stack || e); process.exit(1); });