Skip to content

Commit 40c1126

Browse files
feat(contract): on-chain QA framework storage layer (testCase + testRun) (#3910)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 96cba16 commit 40c1126

14 files changed

Lines changed: 1711 additions & 0 deletions

qa-contract/.env.example

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copy to `.env` and fill in. The scripts read these (loadDotEnv) or you can
2+
# export them in the shell. `.env` is gitignored — never commit a private key.
3+
4+
# Target network (testnet | mainnet | local). Defaults to testnet.
5+
NETWORK=testnet
6+
7+
# The QA identity that owns the contract and creates all documents (v1).
8+
# base58 identity id.
9+
QA_IDENTITY_ID=
10+
11+
# Private key for an AUTHENTICATION key on QA_IDENTITY_ID with HIGH or CRITICAL
12+
# security level. WIF or 64-char hex. TESTNET KEY ONLY.
13+
QA_PRIVATE_KEY=
14+
15+
# Optional: pin the signing key by id instead of auto-detecting it from the key above.
16+
# QA_IDENTITY_KEY_ID=2
17+
18+
# Optional: override the TEST_PLAN commit stamped onto seeded testCases.
19+
# Defaults to the git short-sha of TEST_PLAN.md.
20+
# PLAN_COMMIT=
21+
22+
# Optional: import the SDK from a prebuilt bundle instead of the installed
23+
# @dashevo/evo-sdk package (handy in-repo when the workspace package is built
24+
# elsewhere). Absolute path to dist/evo-sdk.module.js.
25+
# EVO_SDK_BUNDLE=/abs/path/to/packages/js-evo-sdk/dist/evo-sdk.module.js

qa-contract/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
.env
3+
*.log

qa-contract/README.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"network": "testnet",
3+
"contractId": "67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF",
4+
"ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH",
5+
"documentTypes": [
6+
"app",
7+
"tier",
8+
"category",
9+
"testCase",
10+
"testRun"
11+
],
12+
"schemaSha": "28818bca69425d87",
13+
"planCommit": "45fdf33901",
14+
"registeredAt": "2026-06-16T03:41:27.236Z"
15+
}

qa-contract/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "qa-contract",
3+
"private": true,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"description": "On-chain QA framework storage layer (data contract + scripts) for Dash Platform testnet.",
7+
"engines": {
8+
"node": ">=18.18"
9+
},
10+
"scripts": {
11+
"register": "node src/register.mjs",
12+
"seed": "node src/seed.mjs",
13+
"submit-run": "node src/submit-run.mjs",
14+
"query": "node src/query.mjs",
15+
"parse": "node src/parse-test-plan.mjs",
16+
"derive-key": "node src/derive-identity-key.mjs"
17+
},
18+
"dependencies": {
19+
"@dashevo/evo-sdk": "^4.0.0-rc.2"
20+
}
21+
}

0 commit comments

Comments
 (0)