|
| 1 | +# QA Contract — on-chain QA framework storage layer |
| 2 | + |
| 3 | +A dedicated Dash Platform data contract that stores **test definitions** and |
| 4 | +**test-run results** on-chain, so QA status is queryable and proof-verifiable |
| 5 | +and a website can render it. Complements GitHub issue |
| 6 | +[#3897](https://github.com/dashpay/platform/issues/3897) and the iOS test plan at |
| 7 | +[`packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md`](../packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md). |
| 8 | + |
| 9 | +This package is the **storage layer only**: the schema, a register script, a |
| 10 | +seed script (kept in sync with `TEST_PLAN.md`), a submit-run helper, and a |
| 11 | +read/verify tool. The website that consumes the contract is a separate task and |
| 12 | +only needs the contract ID below. |
| 13 | + |
| 14 | +## Contract ID |
| 15 | + |
| 16 | +The live contract ID for each network is committed in |
| 17 | +`contract-id.<network>.json` (e.g. [`contract-id.testnet.json`](contract-id.testnet.json)), |
| 18 | +written by the register script. Read it from there — testnet resets periodically, |
| 19 | +so the ID changes when the contract is re-registered (see |
| 20 | +[Testnet reset / re-seed](#testnet-reset--re-seed)). |
| 21 | + |
| 22 | +**Live testnet deployment** (as of registration): |
| 23 | + |
| 24 | +| | | |
| 25 | +|---|---| |
| 26 | +| Contract ID | `67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF` | |
| 27 | +| Owner (QA identity) | `85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH` | |
| 28 | +| Network | testnet | |
| 29 | + |
| 30 | +> A data contract's schema is immutable, so each schema change is a fresh |
| 31 | +> registration with a new id. Consumers pinned to an older id must re-pin. |
| 32 | +> History: `2qEVUbg4znNgNRs3FJQ4kof4NKpB8q4fGtYa7qBouLzw` (v1) → |
| 33 | +> `2gevmsNEaWnWQURQpuWeN5QnLfC2ufrZG4SXkVMqeUgZ` (v2: integer `network` + |
| 34 | +> `$ownerId` testRun indices) → `4PtPYwYJcjuPXgKigkficzcrpKLG9yucqkNKKK9UVmiv` |
| 35 | +> (v3: normalized `app`/`tier`/`category` lookup types with integer foreign keys, |
| 36 | +> `(testId, app)` unique) → **deployed** (v4: hardening — non-deletable lookup rows, |
| 37 | +> `result` enum, `network` `0..3`, redundant `ownerAppTestNetwork` index dropped). |
| 38 | +> |
| 39 | +> The committed schema additionally makes the `app`/`tier`/`category` lookups |
| 40 | +> fully **immutable** (`documentsMutable: false`, so a `code`'s name can't be |
| 41 | +> relabeled under historical runs). That post-dates the deployed v4 contract and |
| 42 | +> applies at the next re-registration; `register.mjs` flags the drift (use |
| 43 | +> `--force` to publish it as the next contract). |
| 44 | +
|
| 45 | +```jsonc |
| 46 | +// contract-id.testnet.json (shape) |
| 47 | +{ |
| 48 | + "network": "testnet", |
| 49 | + "contractId": "<base58 contract id>", |
| 50 | + "ownerId": "<base58 QA identity id>", |
| 51 | + "documentTypes": ["app", "tier", "category", "testCase", "testRun"], |
| 52 | + "schemaSha": "<sha256 prefix of the schema>", |
| 53 | + "planCommit": "<TEST_PLAN.md git short-sha at register time>", |
| 54 | + "registeredAt": "<ISO timestamp>" |
| 55 | +} |
| 56 | +``` |
| 57 | + |
| 58 | +## Schema |
| 59 | + |
| 60 | +Five document types (full schema in |
| 61 | +[`schema/qa-contract.documents.json`](schema/qa-contract.documents.json)). The |
| 62 | +catalog is **normalized**: `app`, `tier`, and `category` are lookup tables, and |
| 63 | +`testCase`/`testRun` reference them by an integer **foreign key** (`code`). Dash |
| 64 | +Platform has no joins, so consumers fetch the (tiny) lookup tables once and |
| 65 | +resolve `code → name` client-side; the canonical codes live in |
| 66 | +[`src/codes.mjs`](src/codes.mjs). |
| 67 | + |
| 68 | +### Lookup tables: `app`, `tier`, `category` |
| 69 | + |
| 70 | +Each is `{ code: integer (unique), name: string (unique) }` (`app` also has |
| 71 | +optional `platform` + `description`). Indices: `byCode` (unique), `byName` |
| 72 | +(unique). **Immutable** (`documentsMutable: false`, `canBeDeleted: false`) — a |
| 73 | +stable code table, so a `code` referenced by an immutable testRun can't be |
| 74 | +orphaned *or relabeled*; owner-only creation — add a new tier/category/app by |
| 75 | +creating a doc with the next `code`, **no contract update needed**. Canonical codes: |
| 76 | + |
| 77 | +- **app**: `0`=SwiftExampleApp |
| 78 | +- **tier**: `0`=Essential, `1`=Common, `2`=Thorough, `3`=Uncommon, `4`=Manual, `5`=Unspecified |
| 79 | +- **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 |
| 80 | + |
| 81 | +### `testCase` — a test definition (mirrors one test-plan row) |
| 82 | + |
| 83 | +| Field | Type | Notes | |
| 84 | +|---|---|---| |
| 85 | +| `testId` | string (≤32) | e.g. `CORE-05`. Unique **per app**. | |
| 86 | +| `app` | integer | FK → `app.code`. **Indexed.** | |
| 87 | +| `tier` | integer | FK → `tier.code`. **Indexed.** | |
| 88 | +| `category` | integer | FK → `category.code`. **Indexed.** | |
| 89 | +| `title` | string (≤255) | the plan's *Action* column | |
| 90 | +| `layer` | string (≤16) | Core / Platform / Cross / Shielded | |
| 91 | +| `implStatus` | string (≤32) | status glyph (✅ 🧪 ⚠️ 🔌 🚫) | |
| 92 | +| `description` | string (≤2048) | entry point & test notes (last plan column) | |
| 93 | +| `entryPoint` | string (≤512) | primary view / FFI entry point | |
| 94 | +| `prerequisites` | string (≤1024) | fixtures/preconditions | |
| 95 | +| `planCommit` | string (≤64) | source-plan commit this row was seeded from | |
| 96 | + |
| 97 | +- Indices: **`(testId, app)` unique** · `(app, tier)` · `(app, category)`. |
| 98 | +- **Mutable** + deletable (impl-status / entry-point updates; removing dropped rows). |
| 99 | +- `additionalProperties: false`. |
| 100 | + |
| 101 | +### `testRun` — an append-only run record |
| 102 | + |
| 103 | +| Field | Type | Notes | |
| 104 | +|---|---|---| |
| 105 | +| `testId` | string (≤32) | the run's test (pairs with `app`). **Indexed.** | |
| 106 | +| `app` | integer | FK → `app.code`. **Indexed.** | |
| 107 | +| `result` | string (≤16) | `pass` / `fail` / `blocked` / `skipped`. **Indexed.** | |
| 108 | +| `network` | integer | `0`=mainnet, `1`=testnet, `2`=devnet, `3`=regtest. **Indexed.** | |
| 109 | +| `buildRef` | string (≤63) | build under test. **Indexed.** | |
| 110 | +| `device` | string (≤128) | device / simulator | |
| 111 | +| `evidence` | string (≤512) | txid / on-chain id / screenshot path / URL | |
| 112 | +| `notes` | string (≤2048) | free-form notes | |
| 113 | +| `blockerReason` | string (≤512) | why blocked/skipped | |
| 114 | +| `$createdAt` | system | **run time**, stamped by the platform; required + indexed | |
| 115 | + |
| 116 | +- Indices (all `asc`; `$ownerId`-prefixed so runs are queried per submitter — |
| 117 | + sets up multi-submitter; `app` pairs with `testId`): |
| 118 | + - `ownerAppTestNetworkCreated` — `$ownerId`, `app`, `testId`, `network`, `$createdAt` (also serves the equality-only `…, network` prefix) |
| 119 | + - `ownerAppTestResultCreated` — `$ownerId`, `app`, `testId`, `result`, `$createdAt` |
| 120 | + - `ownerAppTestCreated` — `$ownerId`, `app`, `testId`, `$createdAt` |
| 121 | + - `buildRefOwner` — `buildRef`, `$ownerId` |
| 122 | +- "Most recent run first" is done at query time with `orderBy [['$createdAt','desc']]`. |
| 123 | +- **Immutable + non-deletable** (`documentsMutable: false`, `canBeDeleted: false`): |
| 124 | + it is an audit log. `additionalProperties: false`. |
| 125 | +- `result` is constrained on-chain to `enum:[pass,fail,blocked,skipped]` and |
| 126 | + `network` to `0..3`, so out-of-vocabulary values can't enter the immutable log. |
| 127 | + `submit-run.mjs` additionally refuses an unknown `(testId, app)` (the run would |
| 128 | + be a permanent orphan) unless `--force`. |
| 129 | + |
| 130 | +> **Platform schema constraints baked into this schema:** |
| 131 | +> - Indexed string properties are capped at `maxLength ≤ 63`, which is why the |
| 132 | +> indexed fields are short. |
| 133 | +> - Index property sort direction must be **`asc`** in the contract definition |
| 134 | +> (drive-abci rejects `desc` with `JsonSchemaError: "desc" is not one of ["asc"]`). |
| 135 | +> Descending order is requested at *query* time instead — the index is traversed |
| 136 | +> in reverse — so `testRun` queries still return newest-first via |
| 137 | +> `orderBy [['$createdAt','desc']]`. |
| 138 | +> |
| 139 | +> The schema is validated against `rs-dpp` (`new DataContract({ …, fullValidation: true })`) |
| 140 | +> before broadcast; note that local DPP validation does **not** catch the asc-only |
| 141 | +> index rule — drive-abci does, at register time. |
| 142 | +
|
| 143 | +## QA identity (v1 ownership) |
| 144 | + |
| 145 | +In v1 a **single QA identity** owns the contract and creates every document. |
| 146 | +All five document types use `creationRestrictionMode: 1` (**OwnerOnly**), so only |
| 147 | +that identity can create documents. |
| 148 | + |
| 149 | +You need: |
| 150 | + |
| 151 | +1. A registered testnet identity with a **credit balance** (registration + each |
| 152 | + document create costs credits). Create/fund one with the SwiftExampleApp |
| 153 | + (`ID-01`) or any Platform wallet. |
| 154 | +2. The **private key** of an `AUTHENTICATION` key on that identity with **HIGH or |
| 155 | + CRITICAL** security level (WIF or 64-char hex). The register/seed/submit |
| 156 | + scripts sign with it. |
| 157 | + |
| 158 | +Provide them via env vars or a gitignored `.env` (copy `.env.example`): |
| 159 | + |
| 160 | +```sh |
| 161 | +export NETWORK=testnet |
| 162 | +export QA_IDENTITY_ID=<base58 identity id> |
| 163 | +export QA_PRIVATE_KEY=<WIF or hex> # testnet key only |
| 164 | +# optional: export QA_IDENTITY_KEY_ID=2 # pin the signing key id |
| 165 | +``` |
| 166 | + |
| 167 | +The scripts auto-detect which identity key matches `QA_PRIVATE_KEY`; if detection |
| 168 | +fails they print the identity's keys so you can set `QA_IDENTITY_KEY_ID`. |
| 169 | + |
| 170 | +**Recovering the key from a wallet mnemonic.** If the identity was registered by a |
| 171 | +wallet whose mnemonic you control (e.g. created/restored in SwiftExampleApp, which |
| 172 | +mints identities via the Core asset-lock flow that the JS SDK can't do on its own), |
| 173 | +set `QA_MNEMONIC` + `QA_IDENTITY_ID` and run: |
| 174 | + |
| 175 | +```sh |
| 176 | +node src/derive-identity-key.mjs --write |
| 177 | +``` |
| 178 | + |
| 179 | +It fetches the identity, derives candidate keys from the mnemonic at the |
| 180 | +platform-wallet DIP13 path `m/9'/<coin>'/5'/0'/<keyType>'/<identityIndex>'/<keyIndex>'`, |
| 181 | +matches them to the on-chain public keys, and writes `QA_PRIVATE_KEY` + |
| 182 | +`QA_IDENTITY_KEY_ID` into `.env`. |
| 183 | + |
| 184 | +### Extending to per-team-member `testRun` submission (v2) |
| 185 | + |
| 186 | +To let any identity submit runs (while keeping `testCase` owner-controlled), set |
| 187 | +`creationRestrictionMode: 0` (NoRestrictions) on **`testRun`** only. |
| 188 | + |
| 189 | +⚠️ This must be done in a **fresh contract registration**, *not* a data-contract |
| 190 | +update: DPP rejects any change to a document type's `creationRestrictionMode` on |
| 191 | +update (`DocumentTypeUpdateError`, see `validate_update`). So apply it before the |
| 192 | +first registration, or fold it into the next re-register (e.g. a testnet reset) — |
| 193 | +which mints a new contract id consumers must re-pin. |
| 194 | + |
| 195 | +`testRun` is already immutable + owner-stamped (`$ownerId`/`$createdAt` are |
| 196 | +system fields), so opening creation keeps every run attributable and tamper-proof. |
| 197 | +Leave `testCase` as OwnerOnly so the canonical catalog stays curated. |
| 198 | + |
| 199 | +## Install & run |
| 200 | + |
| 201 | +The scripts use the recommended **`@dashevo/evo-sdk`** (js-evo-sdk) in trusted |
| 202 | +mode (required so state-transition responses are proof-verified). Node ≥ 18.18. |
| 203 | + |
| 204 | +**Option A — standalone (published SDK):** |
| 205 | + |
| 206 | +```sh |
| 207 | +cd qa-contract |
| 208 | +npm install |
| 209 | +``` |
| 210 | + |
| 211 | +> Use `npm`, not `yarn`, here: `qa-contract` is intentionally *not* a member of |
| 212 | +> the repo's Yarn workspaces, and Yarn 4 aborts when run from a non-member dir. |
| 213 | +> (Option B builds the workspace SDK instead and needs no install in this dir.) |
| 214 | +
|
| 215 | +**Option B — in-repo (workspace build):** build the workspace SDK once and point |
| 216 | +the scripts at the bundle (no `yarn install` in this dir needed): |
| 217 | + |
| 218 | +```sh |
| 219 | +yarn workspace @dashevo/wasm-sdk build && yarn workspace @dashevo/evo-sdk build |
| 220 | +export EVO_SDK_BUNDLE="$PWD/../packages/js-evo-sdk/dist/evo-sdk.module.js" |
| 221 | +``` |
| 222 | + |
| 223 | +Then: |
| 224 | + |
| 225 | +```sh |
| 226 | +# 1. Register the contract on testnet (writes contract-id.testnet.json) |
| 227 | +node src/register.mjs # --force to re-register a fresh contract |
| 228 | + |
| 229 | +# 2. Seed testCases from TEST_PLAN.md (idempotent; skips existing) |
| 230 | +node src/seed.mjs # all rows |
| 231 | +node src/seed.mjs --ids CORE-01,ID-04 # a subset |
| 232 | +node src/seed.mjs --tier Essential # filter by tier / --category / --limit |
| 233 | +node src/seed.mjs --update # push changed rows (replace) |
| 234 | + |
| 235 | +# 3. Submit a test-run result |
| 236 | +node src/submit-run.mjs --testId CORE-05 --result pass --buildRef 45fdf33901 \ |
| 237 | + --device "iPhone 16 (iOS 18.2)" --evidence "txid:30010050…17f840fc" --notes "2-output send credited both recipients" |
| 238 | + |
| 239 | +# 4. Read back / verify indices (read-only; no key needed) |
| 240 | +node src/query.mjs # self-check: exercises every index |
| 241 | +node src/query.mjs --type testCase --tier Essential |
| 242 | +node src/query.mjs --type testRun --testId CORE-05 --proof |
| 243 | +``` |
| 244 | + |
| 245 | +`--result` must be one of `pass | fail | blocked | skipped`. Add `--proof` to any |
| 246 | +`query.mjs` call to fetch with a verified Platform proof, `--json` for raw output. |
| 247 | + |
| 248 | +### Files |
| 249 | + |
| 250 | +```text |
| 251 | +qa-contract/ |
| 252 | +├── schema/qa-contract.documents.json # the five document types (the contract schema) |
| 253 | +├── contract-id.testnet.json # committed: live contract ID per network |
| 254 | +├── src/ |
| 255 | +│ ├── sdk.mjs # SDK load, connect, signer, identity-key, networkId, config |
| 256 | +│ ├── codes.mjs # canonical app/tier/category integer codes |
| 257 | +│ ├── parse-test-plan.mjs # TEST_PLAN.md §4 catalog parser |
| 258 | +│ ├── register.mjs # register the contract |
| 259 | +│ ├── seed.mjs # seed lookups + testCases from the plan (idempotent) |
| 260 | +│ ├── submit-run.mjs # create one testRun |
| 261 | +│ ├── query.mjs # read back + verify indices |
| 262 | +│ └── derive-identity-key.mjs # recover signing key from a wallet mnemonic |
| 263 | +├── .env.example |
| 264 | +└── README.md |
| 265 | +``` |
| 266 | + |
| 267 | +## Testnet reset / re-seed |
| 268 | + |
| 269 | +Public testnet resets periodically; the old contract ID stops resolving and all |
| 270 | +documents are gone. To rebuild: |
| 271 | + |
| 272 | +```sh |
| 273 | +# 1. Re-register (auto-detected: register.mjs re-registers when the committed |
| 274 | +# contractId no longer resolves; --force to force it). Overwrites contract-id.testnet.json. |
| 275 | +node src/register.mjs |
| 276 | + |
| 277 | +# 2. Re-seed every testCase from the current plan |
| 278 | +node src/seed.mjs |
| 279 | + |
| 280 | +# 3. (testRun history does not survive a reset — it is re-accumulated as runs happen.) |
| 281 | +``` |
| 282 | + |
| 283 | +Re-running `register.mjs` while the committed contract still resolves is a no-op |
| 284 | +(it prints the existing ID). Re-running `seed.mjs` skips testCases that already |
| 285 | +exist, so both scripts are safe to run repeatedly. Commit the updated |
| 286 | +`contract-id.testnet.json` after a re-register so consumers (the website) pick up |
| 287 | +the new ID. |
| 288 | + |
| 289 | +## How it maps to the test plan |
| 290 | + |
| 291 | +`seed.mjs` first ensures the `app`/`tier`/`category` lookup docs exist (codes from |
| 292 | +[`src/codes.mjs`](src/codes.mjs)), then parses the §4 catalog tables of |
| 293 | +`TEST_PLAN.md` — `ID`, `Action`, `Layer`, `Tier`, `Status` columns plus the |
| 294 | +section's `Domain=` (→ `category`) — and creates one `testCase` per row (126 rows |
| 295 | +at the current plan commit) under app `SwiftExampleApp`, mapping tier/category |
| 296 | +names to their integer codes. Seed another app's plan with `--app <name>` (add the |
| 297 | +app to `src/codes.mjs` first). The `simulator-control` QA runs then post results |
| 298 | +with `submit-run.mjs` (`--app` defaults to SwiftExampleApp), so the on-chain |
| 299 | +`testRun` log mirrors what the automated QA agent actually executed. |
0 commit comments