From 0328b45115fba0247b2f837e6b361911cf762510 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 7 Jul 2026 02:01:26 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(contract):=20qa-contract=20v5=20?= =?UTF-8?q?=E2=80=94=20tags,=20dissolve=20MultiWallet/Group,=20run=20migra?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v5 starts a fresh QA-results contract that dissolves the MultiWallet and Group *categories* into cross-cutting **tags** (a test's domain is its category; its modality is a tag). DPP rejects typed arrays, so tags are stored as a comma-separated string. - schema: add `testCase.tags`. - codes.mjs: categories minus MultiWallet/Group + canonical TAGS vocab + v4→v5 TESTID_REMAP (MW-*/GRP-* folded into their domain sequence). - seed.mjs / parse-test-plan.mjs: parse + validate the Tags column. - migrate-runs.mjs: re-point historical testRun.testId, reconnecting on nonce drift. - register.mjs: verbose error logging. - contract-id.testnet.json: canonical v5 contract 9tshSfq5 (owner 2YBjQKEP), which the QA dashboard reads. This lifts the tooling that registered the live testnet QA contract off a single local worktree and into review. No consensus/protocol code. Co-Authored-By: Claude Opus 4.8 --- qa-contract/contract-id.testnet.json | 10 +- qa-contract/schema/qa-contract.documents.json | 10 +- qa-contract/src/codes.mjs | 79 +++++++- qa-contract/src/migrate-runs.mjs | 171 ++++++++++++++++++ qa-contract/src/parse-test-plan.mjs | 12 +- qa-contract/src/register.mjs | 9 +- qa-contract/src/seed.mjs | 8 +- 7 files changed, 280 insertions(+), 19 deletions(-) create mode 100644 qa-contract/src/migrate-runs.mjs diff --git a/qa-contract/contract-id.testnet.json b/qa-contract/contract-id.testnet.json index 37a7b33c9a8..dedd190468a 100644 --- a/qa-contract/contract-id.testnet.json +++ b/qa-contract/contract-id.testnet.json @@ -1,7 +1,7 @@ { "network": "testnet", - "contractId": "67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF", - "ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH", + "contractId": "9tshSfq5TU3gAEJCVa6KWxmb7cS7kkzRSmSSTmteKGcU", + "ownerId": "2YBjQKEP6APyu9JADk46yoELL2aKAcRW7yCLTCV2d4dD", "documentTypes": [ "app", "tier", @@ -9,7 +9,7 @@ "testCase", "testRun" ], - "schemaSha": "28818bca69425d87", - "planCommit": "45fdf33901", - "registeredAt": "2026-06-16T03:41:27.236Z" + "schemaSha": "ed751b6e708d0089", + "planCommit": "fb3cfd2740", + "registeredAt": "2026-07-05T05:30:13.704Z" } diff --git a/qa-contract/schema/qa-contract.documents.json b/qa-contract/schema/qa-contract.documents.json index 8615ee4c2df..7b2df66083e 100644 --- a/qa-contract/schema/qa-contract.documents.json +++ b/qa-contract/schema/qa-contract.documents.json @@ -88,7 +88,7 @@ "minLength": 1, "maxLength": 32, "position": 1, - "description": "Feature-area / Domain name (Core, Identity, DPNS, Token, Shielded, MultiWallet, ...)." + "description": "Feature-area / Domain name (Core, Identity, DPNS, Token, Shielded, DashPay, System, ...). Modalities like multi-wallet / group are tags, not categories." } }, "required": ["code", "name"], @@ -185,11 +185,17 @@ "maxLength": 64, "position": 10, "description": "git commit of the source plan this testCase was seeded from." + }, + "tags": { + "type": "string", + "maxLength": 200, + "position": 11, + "description": "Comma-separated cross-cutting labels orthogonal to category (e.g. \"multiwallet,contested\"). Drawn from the canonical TAGS vocabulary in src/codes.mjs; stored as a string because DPP document schemas only support byte arrays, not typed arrays. The dashboard splits on ',' and filters client-side." } }, "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)." + "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; tags are cross-cutting string labels. Mutable; owner-only creation (single QA-owner identity)." }, "testRun": { "type": "object", diff --git a/qa-contract/src/codes.mjs b/qa-contract/src/codes.mjs index 54e21440a60..afd929ab40e 100644 --- a/qa-contract/src/codes.mjs +++ b/qa-contract/src/codes.mjs @@ -1,7 +1,12 @@ -// Canonical integer codes for the app / tier / category lookup document types. +// Canonical integer codes for the app / tier / category lookup document types, +// plus the canonical `tags` vocabulary and the v4 -> v5 testId remap. +// // 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). +// testRun (app). v5 starts a FRESH contract, so the category codes were +// renumbered after dissolving MultiWallet and Group into the `tags` vocabulary +// (a test's *domain* is its category; its *modality* is a tag). Within v5 these +// 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).' }, @@ -16,6 +21,9 @@ export const TIERS = [ { code: 5, name: 'Unspecified' }, ]; +// Feature-area domains. MultiWallet and Group are NOT categories in v5 — they are +// tags (see TAGS): a multi-wallet document test lives under `Document` + the +// `multiwallet` tag; a group-authorized token action under `Token` + `group`. export const CATEGORIES = [ { code: 0, name: 'Core' }, { code: 1, name: 'Identity' }, @@ -27,11 +35,53 @@ export const CATEGORIES = [ { code: 7, name: 'Token' }, { code: 8, name: 'Shielded' }, { code: 9, name: 'DashPay' }, - { code: 10, name: 'Group' }, - { code: 11, name: 'System' }, - { code: 12, name: 'MultiWallet' }, + { code: 10, name: 'System' }, ]; +// Canonical cross-cutting tag vocabulary stored on testCase.tags (a string array, +// orthogonal to category). Keep lowercase + hyphenated. Add an entry here before +// using it in TEST_PLAN.md — checkTags() rejects unknown tags at seed time. +export const TAGS = [ + 'multiwallet', // exercises >=2 on-device wallets / identities + 'group', // multi-party group-authorized action (propose / co-sign) + 'contested', // contested resource (premium DPNS name, masternode vote, race) + 'withdrawal', // moves value off-platform to an L1 Core address + 'distribution', // token distribution mechanism (pre-programmed / perpetual / claim) + 'aggregation', // count / sum / average / group_by query + 'read-only', // pure query, no state-transition broadcast + 'regression', // pins a specific previously-fixed bug + 'proof', // specifically validates cryptographic proof verification + 'freeze', // freeze / unfreeze / destroy-frozen balances + 'funding', // requires asset-lock / faucet funding to run + 'masternode', // requires a masternode key +]; + +// v4 -> v5 testId remap. MultiWallet (MW-*) and Group (GRP-*) ids were folded into +// their domain category's sequence. GRP-03 (token group propose / co-sign) is +// MERGED into the existing TOK-15 / TOK-16 pair, so its historical runs attach to +// TOK-15. Ids absent here are unchanged (identity mapping). Used by +// migrate-runs.mjs to re-point historical testRun.testId; testCase ids are +// renumbered directly in TEST_PLAN.md. +export const TESTID_REMAP = { + 'MW-01': 'ID-14', + 'MW-02': 'TOK-17', + 'MW-03': 'DP-11', // DashPay grew to DP-10 (label / QR / contactInfo / backfill); the loop lands at DP-11 + 'MW-04': 'DOC-15', + 'MW-05': 'DPNS-08', + 'MW-06': 'SH-14', + 'MW-07': 'SH-15', + 'MW-08': 'SYS-07', + 'MW-09': 'SYS-08', + 'MW-10': 'ID-15', + 'MW-11': 'SH-16', + 'GRP-01': 'TOK-18', + 'GRP-02': 'TOK-19', + 'GRP-03': 'TOK-15', // merged into the TOK-15 / TOK-16 group-action pair + 'GRP-04': 'TOK-20', +}; + +export const remapTestId = (id) => TESTID_REMAP[id] || id; + // The app the iOS TEST_PLAN.md belongs to. export const DEFAULT_APP = 'SwiftExampleApp'; @@ -41,6 +91,7 @@ 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); +const TAG_SET = new Set(TAGS); export const APP_BY_CODE = byCodeMap(APPS); export const TIER_BY_CODE = byCodeMap(TIERS); @@ -57,3 +108,19 @@ function lookup(map, kind, name) { 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); + +// Validate + de-dupe a list of tags against the canonical vocabulary. Throws on +// an unknown tag so a typo in TEST_PLAN.md fails the seed loudly rather than +// silently storing an unfilterable tag. +export function checkTags(tags, testId = '') { + const out = []; + for (const raw of tags || []) { + const t = String(raw).trim().toLowerCase(); + if (!t) continue; + if (!TAG_SET.has(t)) { + throw new Error(`Unknown tag '${t}'${testId ? ` on ${testId}` : ''}. Add it to TAGS in src/codes.mjs.`); + } + if (!out.includes(t)) out.push(t); + } + return out; +} diff --git a/qa-contract/src/migrate-runs.mjs b/qa-contract/src/migrate-runs.mjs new file mode 100644 index 00000000000..7e57ea45178 --- /dev/null +++ b/qa-contract/src/migrate-runs.mjs @@ -0,0 +1,171 @@ +// Migrate historical testRun records from the previous QA contract (v4) onto the +// freshly-registered v5 contract, remapping testId via the v4->v5 table in +// codes.mjs (MultiWallet / Group ids folded into their domain categories; GRP-03 +// merged into TOK-15). +// +// Reading the old contract is public (no key); writing the new runs uses the v5 +// owner from .env (QA_IDENTITY_ID / QA_PRIVATE_KEY). Migrated runs get a FRESH +// $createdAt — the original run time is intentionally not preserved. +// +// Run AFTER register.mjs + seed.mjs (the v5 testCases must already exist). +// Idempotent: a run already present on v5 for the same (testId, result, buildRef) +// is skipped, so re-running won't duplicate. +// +// Usage: +// QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/migrate-runs.mjs [--dry-run] +// [--from ] [--from-owner ] [--app SwiftExampleApp] + +import { parseArgs } from 'node:util'; +import { randomBytes } from 'node:crypto'; +import { loadDotEnv, connect, loadOwnerAuth, readConfig } from './sdk.mjs'; +import { appCode, DEFAULT_APP, remapTestId } from './codes.mjs'; + +// Previous (v4) QA contract on testnet + its owner identity (public, read-only). +const DEFAULT_OLD_CONTRACT = '67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF'; +const DEFAULT_OLD_OWNER = '85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH'; + +// v4 also had Group (10) and MultiWallet (12) category codes that no longer exist +// in v5; enumerate old testCases across a generous code range so none are missed +// regardless of the old numbering. +const OLD_CATEGORY_CODES = Array.from({ length: 16 }, (_, i) => i); + +function entropy() { return Uint8Array.from(randomBytes(32)); } + +async function queryAll(sdk, dataContractId, documentTypeName, where, orderBy) { + const res = await sdk.documents.query({ + dataContractId, documentTypeName, where, ...(orderBy ? { orderBy } : {}), limit: 100, + }); + const out = []; + for (const doc of res.values()) if (doc) out.push(doc.toJSON()); + return out; +} + +async function main() { + loadDotEnv(); + const { values } = parseArgs({ + options: { + 'dry-run': { type: 'boolean', default: false }, + from: { type: 'string' }, + 'from-owner': { type: 'string' }, + app: { type: 'string' }, + }, + }); + + const dryRun = values['dry-run']; + const appName = values.app || DEFAULT_APP; + const app = appCode(appName); + const oldContractId = (values.from || process.env.OLD_CONTRACT_ID || DEFAULT_OLD_CONTRACT).trim(); + + let { sdk, mod, network } = await connect(); + if (network !== 'testnet') { + console.warn(`Warning: network is '${network}', but the default old contract is a testnet id.`); + } + + const cfg = readConfig(network); + if (!cfg?.contractId) throw new Error(`No v5 contract registered for ${network}. Run register.mjs first.`); + const newContractId = cfg.contractId; + if (newContractId === oldContractId) { + throw new Error(`Refusing to migrate: the registered contract (${newContractId}) equals --from. Register the v5 contract first.`); + } + + // Resolve the old owner (testRun indices are $ownerId-prefixed). + let oldOwner = (values['from-owner'] || process.env.OLD_OWNER_ID || '').trim(); + if (!oldOwner) { + try { + const oldContract = await sdk.contracts.fetch(oldContractId); + oldOwner = oldContract ? String(oldContract.ownerId) : ''; + } catch { /* fall through to default */ } + } + if (!oldOwner) oldOwner = DEFAULT_OLD_OWNER; + + let { ownerId: newOwner, signer, identityKey } = await loadOwnerAuth(sdk, mod, network); + let { Document } = mod; + + // Reconnect a fresh SDK + re-derive auth. A fresh instance re-fetches the + // identity-contract nonce from chain, recovering from the SDK's + // "identity nonce too far in future" drift that cascades during bulk creates. + async function reconnect() { + ({ sdk, mod, network } = await connect()); + ({ ownerId: newOwner, signer, identityKey } = await loadOwnerAuth(sdk, mod, network)); + ({ Document } = mod); + } + + console.log('Migrating runs:'); + console.log(` from ${oldContractId} (owner ${oldOwner})`); + console.log(` to ${newContractId} (owner ${newOwner})`); + console.log(` app '${appName}' (code ${app})${dryRun ? ' [DRY RUN]' : ''}`); + + // 1. Enumerate old testIds (across every old category code so MW/GRP are caught). + const oldTestIds = new Set(); + for (const c of OLD_CATEGORY_CODES) { + const cases = await queryAll(sdk, oldContractId, 'testCase', [['app', '==', app], ['category', '==', c]]); + for (const tc of cases) if (tc.testId) oldTestIds.add(tc.testId); + } + console.log(`Found ${oldTestIds.size} old testCase ids.`); + + // 2. For each old testId, read its runs and re-create them under the new id. + let migrated = 0; let skipped = 0; let failed = 0; let runsSeen = 0; + for (const oldId of oldTestIds) { + const newId = remapTestId(oldId); + const oldRuns = await queryAll( + sdk, oldContractId, 'testRun', + [['$ownerId', '==', oldOwner], ['app', '==', app], ['testId', '==', oldId]], + [['$createdAt', 'asc']], + ); + if (!oldRuns.length) continue; + + // Idempotency: skip a run already present on v5 for (newId, result, buildRef). + const existing = await queryAll( + sdk, newContractId, 'testRun', + [['$ownerId', '==', newOwner], ['app', '==', app], ['testId', '==', newId]], + [['$createdAt', 'asc']], + ); + const present = new Set(existing.map((r) => `${r.result}|${r.buildRef}`)); + + for (const run of oldRuns) { + runsSeen += 1; + const label = oldId === newId ? newId : `${oldId}->${newId}`; + if (present.has(`${run.result}|${run.buildRef}`)) { skipped += 1; continue; } + + const props = { testId: newId, app, result: run.result, network: run.network, buildRef: run.buildRef }; + for (const f of ['device', 'evidence', 'notes', 'blockerReason']) { + if (run[f] !== undefined && run[f] !== null && run[f] !== '') props[f] = run[f]; + } + if (dryRun) { + console.log(` [dry] ${label} ${run.result} ${run.buildRef}`); + migrated += 1; + continue; + } + for (let attempt = 1; attempt <= 6; attempt += 1) { + try { + const doc = new Document({ + ownerId: newOwner, dataContractId: newContractId, documentTypeName: 'testRun', properties: props, entropy: entropy(), + }); + await sdk.documents.create({ document: doc, identityKey, signer }); + migrated += 1; + console.log(` + ${label} ${run.result} ${run.buildRef}`); + break; + } catch (e) { + const msg = e?.message || String(e); + // Recover from identity-contract-nonce drift: a fresh SDK re-syncs + // the nonce from chain. Retry non-fatally up to a few times. + if (/nonce/i.test(msg) && attempt < 6) { + console.error(` ~ ${label} nonce desync (attempt ${attempt}) — reconnecting + retrying`); + await new Promise((r) => setTimeout(r, 2500)); + await reconnect(); + continue; + } + failed += 1; + console.error(` ! ${label} (${run.result}/${run.buildRef}) failed: ${msg}`); + break; + } + } + } + } + + console.log(`\nRun migration ${dryRun ? '(dry run) ' : ''}complete: ${runsSeen} source runs; ` + + `${migrated} ${dryRun ? 'would migrate' : 'migrated'}, ${skipped} skipped (already present), ${failed} failed.`); + if (failed) process.exit(1); +} + +main().catch((e) => { console.error('migrate-runs 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 index b459f6bebf3..e75f486a22e 100644 --- a/qa-contract/src/parse-test-plan.mjs +++ b/qa-contract/src/parse-test-plan.mjs @@ -1,8 +1,9 @@ // 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 +// | CORE-05 | Send Core L1 transaction | Core | Essential | ✅ | | `SendTransactionView` ... | +// Columns: ID | Action(title) | Layer | Tier | Status(implStatus) | Tags | Entry point & notes +// Tags is a comma-separated, lowercase cell (often empty); attached to the row only when non-empty. // 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. @@ -92,9 +93,13 @@ export function parseTestPlan(planPath = DEFAULT_TEST_PLAN, planCommit) { if (cells.length < 6) continue; if (isSeparator(cells)) continue; - const [testId, title, layer, tier, status, ...rest] = cells; + const [testId, title, layer, tier, status, tagsCell, ...rest] = cells; if (!ID_RE.test(testId)) continue; // header row or non-catalog row + const tags = (tagsCell || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); const notes = rest.join(' | ').trim(); rows.push({ testId, @@ -105,6 +110,7 @@ export function parseTestPlan(planPath = DEFAULT_TEST_PLAN, planCommit) { implStatus: truncate(status || '?', 32), description: truncate(notes, 2048), entryPoint: truncate(firstCodeToken(notes), 512) || undefined, + ...(tags.length ? { tags } : {}), ...(planCommit ? { planCommit: truncate(planCommit, 64) } : {}), }); } diff --git a/qa-contract/src/register.mjs b/qa-contract/src/register.mjs index b706bae2b51..803dab56d51 100644 --- a/qa-contract/src/register.mjs +++ b/qa-contract/src/register.mjs @@ -86,4 +86,11 @@ async function main() { console.log(` wrote: ${path}`); } -main().catch((e) => { console.error('register failed:', e?.stack || e); process.exit(1); }); +main().catch((e) => { + console.error('register failed:'); + try { console.error(' message :', e?.message); } catch {} + try { console.error(' toString:', e?.toString?.()); } catch {} + try { console.error(' String :', String(e)); } catch {} + try { console.error(' stack :', e?.stack); } catch {} + process.exit(1); +}); diff --git a/qa-contract/src/seed.mjs b/qa-contract/src/seed.mjs index 11a3983cef7..05f47dd93c8 100644 --- a/qa-contract/src/seed.mjs +++ b/qa-contract/src/seed.mjs @@ -15,12 +15,12 @@ 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, + APPS, TIERS, CATEGORIES, DEFAULT_APP, appCode, tierCode, categoryCode, checkTags, } from './codes.mjs'; const CONTENT_FIELDS = [ 'testId', 'app', 'tier', 'category', 'title', 'layer', 'implStatus', - 'description', 'entryPoint', 'prerequisites', 'planCommit', + 'description', 'entryPoint', 'prerequisites', 'planCommit', 'tags', ]; function entropy() { return Uint8Array.from(randomBytes(32)); } @@ -38,6 +38,10 @@ function testCaseProps(row, app) { for (const f of ['description', 'entryPoint', 'prerequisites', 'planCommit']) { if (row[f]) props[f] = row[f]; } + // DPP document schemas don't support typed arrays, so tags are stored as a + // comma-separated string (the dashboard splits on ','). + const tags = checkTags(row.tags, row.testId); + if (tags.length) props.tags = tags.join(','); return props; } From 9f160c5df54afcf911c7136848d6b9c7092b2e55 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 7 Jul 2026 02:12:56 +0700 Subject: [PATCH 2/3] docs(swift-example-app): dissolve MultiWallet/Group into tags in TEST_PLAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the QA test plan onto the v5 tags taxonomy so it matches the qa-contract tooling, while preserving current v4.1-dev content: - Add a **Tags** column + tag index; dissolve the MultiWallet (§4.13) and Group (§4.11) sections — their rows fold into their home categories (MW-* → ID-14/15, TOK-17, DPNS-08, DOC-15, SH-14/15/16, SYS-07/08; GRP-* → TOK-18/19/20, GRP-03 merged into TOK-15) tagged `multiwallet` / `group`. - Keep v4.1-dev's newer rows: DashPay DP-01..10 (Add Contact / Ignore / QR / contactInfo / backfill), ADDR-02/04 production sheets, and the fixed ADDR-09 no-double-credit regression guard. The cross-wallet DashPay loop lands at DP-11 (DashPay grew past the branch's DP-07). - Add ADDR-07 (BLAST platform-address sync) and ADDR-08 (clear & resync). - Recompute the §5 tier/layer counts and refresh the §1/§6 selectors. Co-Authored-By: Claude Opus 4.8 --- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 416 +++++++++--------- 1 file changed, 209 insertions(+), 207 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index f3672310629..c810f50f350 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -19,24 +19,26 @@ Every catalog row carries four orthogonal, machine-filterable fields. Select tes - **Tier** ∈ `Essential` · `Common` · `Thorough` · `Uncommon` · `Manual` - **Layer** ∈ `Core` · `Platform` · `Cross` · `Shielded` - **Status** ∈ `✅` · `🧪` · `⚠️` · `🔌` · `🚫` · `➖` -- **Category** ∈ `Core` · `Identity` · `Address` · `DPNS` · `Voting` · `Contract` · `Document` · `Token` · `Shielded` · `DashPay` · `Group` · `System` · `MultiWallet` (the feature area; shown as `Domain=…` on each §4 section header — "Category" and "Domain" are the same axis) +- **Category** ∈ `Core` · `Identity` · `Address` · `DPNS` · `Voting` · `Contract` · `Document` · `Token` · `Shielded` · `DashPay` · `System` (the feature area; shown as `Domain=…` on each §4 section header — "Category" and "Domain" are the same axis) +- **Tags** ∈ `multiwallet` · `group` · `contested` · `withdrawal` · `distribution` · `aggregation` · `read-only` · `regression` · `proof` · `freeze` · `funding` · `masternode` (orthogonal cross-cutting labels in the **Tags** column; a row may carry several, comma-separated. `multiwallet` and `group` used to be categories — they are now tags, so a multi-wallet token test lives in **Token** and is found with `Tag=multiwallet`.) A test is **automatable now** only if Status is `✅`, `🧪`, or `⚠️` (reachable and drivable in the simulator) **and** `Tier ≠ Manual`. `Tier=Manual` marks implemented features that need a human on a physical device (e.g. a camera) — the automated QA agent must **skip and flag them for manual testing**, never mark them failed. `🔌`/`🚫` rows are listed for completeness — skip them unless asked to confirm absence. -A row's **primary** category is the §4 section it lives in. Some tests are **cross-cutting** — e.g. `MW-02` (token transfer between two wallets) lives in the MultiWallet section but is also a **Token** test, and `CORE-21` is also **Shielded**. Resolve any `Category=…` selection through **§6 Category index**, which lists every member ID per category (primary + cross-cutting), so "run all Token tests" catches `MW-02` and `GRP-03` too. This is the axis behind requests like *"run all non-Uncommon Token tests."* +A row's **category** is the §4 section it lives in — every row now lives in its true feature section. Cross-cutting concerns that used to be a separate section (multi-wallet, group) are expressed as **tags** instead: e.g. `TOK-17` (token transfer between two wallets) lives in **Token** and carries `Tag=multiwallet`, and `CORE-21` is `Tag=multiwallet` on the Core/Shielded side. Select cross-cutting sets with the Tags column — `Tag=multiwallet`, `Tag=group`, `Tag=read-only`, etc. — intersected with `Tier`/`Layer`/`Status`/`Category` as needed. This is the axis behind requests like *"run all multi-wallet token tests"* (`Category=Token AND Tag=multiwallet`). **Worked examples of a request → selection:** | Request | Filter | Resolves to | |---|---|---| -| "test Essential, Platform-only" | `Tier=Essential AND Layer=Platform` | `ID-02, ID-03, ID-04, DPNS-01, DPNS-02, DPNS-03, DPNS-04` | -| "test all Essential" | `Tier=Essential` | the core experience: `CORE-01..07`, `ID-01/02/03/04`, `DPNS-01/02/03/04`, `SH-01..06` | +| "test Essential, Platform-only" | `Tier=Essential AND Layer=Platform` | `ID-02, ID-03, ID-04, ADDR-07, DPNS-01, DPNS-02, DPNS-03, DPNS-04` | +| "test all Essential" | `Tier=Essential` | the core experience: `CORE-01..07`, `ID-01/02/03/04`, `ADDR-07`, `DPNS-01/02/03/04`, `SH-01..06` | | "list the manual tests" | `Tier=Manual` | `CORE-08`, `DP-10` (skip in automation; run on a physical device) | -| "smoke test the wallet" | `Category=Core AND Status=✅` | `CORE-01..CORE-09` | -| "test all non-Uncommon Token tests" | `Category=Token AND Tier≠Uncommon` | `TOK-01..07`, `MW-02` (via §6 index) | -| "exercise every token admin action" | `Category=Token AND Tier=Uncommon` | `TOK-08..TOK-16` | -| "run all Shielded tests" | `Category=Shielded` | `SH-01..13`, `CORE-21`, `MW-06/07/11` (via §6 index) | -| "run all read queries" | Appendix A | the gRPC read-RPC coverage table | +| "smoke test the wallet" | `Category=Core AND Status=✅` | `CORE-01..CORE-10` | +| "test all non-Uncommon Token tests" | `Category=Token AND Tier≠Uncommon` | `TOK-01..07`, `TOK-17`, `TOK-18`, `TOK-19` | +| "exercise every token admin action" | `Category=Token AND Tier=Uncommon` | `TOK-08..TOK-16`, `TOK-20` | +| "run all Shielded tests" | `Category=Shielded` | `SH-01..16`, `CORE-21` | +| "run all multi-wallet tests" | `Tag=multiwallet` | `CORE-14..23`, `ID-14/15`, `TOK-17`, `DPNS-08`, `DP-11`, `DOC-15`, `SH-14/15/16`, `SYS-07/08` | +| "run all read queries" | `Tag=read-only` (or Appendix A) | the read-only rows + the gRPC read-RPC coverage table | ### Generic pass criteria (apply per action type unless a row overrides) @@ -59,7 +61,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | **Network selected** (testnet / devnet) | everything | Devnet has the format/version caveats noted in memory; mainnet blocks the pool-seeding util. Confirm the SDK protocol-version floor is applied. | | **Funded Core wallet** | all `Layer=Core` and `Layer=Cross` | A wallet with confirmed, mature, spendable UTXOs. Asset-lock funding needs InstantSend/ChainLock, so masternode sync must complete. | | **A registered identity with credit balance** | almost all `Layer=Platform` | Created via `ID-01`. Many transitions also need a specific **key purpose/security level** present on the identity. | -| **A loaded data contract** (with a token + a document type) | `Domain=Token`, `Domain=Document`, group | Token actions are gated by the contract's on-chain permission rules — a "coming soon" placeholder means *disallowed by rule*, not unimplemented. | +| **A loaded data contract** (with a token + a document type) | `Domain=Token`, `Domain=Document`, `Tag=group` | Token actions are gated by the contract's on-chain permission rules — a "coming soon" placeholder means *disallowed by rule*, not unimplemented. | | **A contested-name scenario** | `DPNS-05`, `VOTE-*` | Register a premium/contested name to create a live vote poll. | | **Masternode / evonode voting key** | `VOTE-01` | Casting a contested-resource vote requires masternode voting credentials. Standard app QA on a non-masternode identity **cannot** exercise the actual vote broadcast — treat as environment-limited unless a masternode identity is configured. | | **Shielded pool: configured + bound + prover warmed + synced** | `Domain=Shielded` | `SH-01` sync + `SH-09` prover warm-up are preconditions for any shielded spend. The pool also needs a non-trivial anonymity set (`SH-10` on devnet/testnet) for realistic spends. | @@ -107,214 +109,201 @@ Most Platform actions have hard preconditions. Establish these fixtures before s ### 4.1 Core / Wallet — `Domain=Core` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| CORE-01 | Create wallet (new mnemonic) | Core | Essential | ✅ | `CreateWalletView`. New 12/24-word phrase shown; wallet appears in Wallets tab. | -| CORE-02 | Restore wallet (existing mnemonic) | Core | Essential | ✅ | `CreateWalletView` (Import Existing toggle). After sync, derived addresses + balance populate. | -| CORE-03 | Backup / view recovery phrase | Core | Essential | ✅ | `SeedBackupView`. Phrase matches creation. | -| CORE-04 | Receive (derive address + QR) | Core | Essential | ✅ | `ReceiveAddressView` → `core_wallet_next_receive_address`. Fresh external address + scannable QR. | -| CORE-05 | Send Core L1 transaction | Core | Essential | ✅ | Send flow (`SendTransactionView`, mode Core→Core) → `core_wallet_send_to_addresses`. Tx broadcasts; balance drops; appears in history. *Anchor: the canonical Essential action.* | -| CORE-06 | View balance / tx history / UTXOs | Core | Essential | ✅ | `WalletDetailView`, `TransactionListView`, `AccountDetailView` (SwiftData). | -| CORE-07 | SPV sync (start / stop / progress) | Core | Essential | ✅ | Global sync indicator (`ContentView`) → `platform_wallet_manager_spv_*`. Headers/filters/masternodes advance to tip. | -| CORE-08 | QR scan recipient | Core | Manual | ✅ | `QRScannerView`, reachable in the Send flow — but scanning needs a real camera the simulator doesn't have, so it can't be automated (`Tier=Manual`). On a device: Send → QR-scan button → point at a Dash address QR → recipient field populates. | -| CORE-09 | Multiple HD accounts (within one wallet) | Core | Common | ✅ | Account selection / `AccountDetailView`; balances per `account_index`. Distinct from holding multiple *wallets* — see CORE-14+. | -| CORE-10 | Multi-recipient Core send | Core | Common | ✅ | Send flow (`SendTransactionView`, Core→Core) → "Add recipient" appends extra address/amount rows → `SendViewModel.coreRecipients` → `core_wallet_send_to_addresses` (parallel arrays; Rust coin-selects + builds the multi-output tx). One tx with N outputs; balance drops by sum+fee. Verified: 2-output testnet send (txid `30010050…17f840fc`, txlock, 3 vouts) credited both recipients. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| CORE-01 | Create wallet (new mnemonic) | Core | Essential | ✅ | | `CreateWalletView`. New 12/24-word phrase shown; wallet appears in Wallets tab. | +| CORE-02 | Restore wallet (existing mnemonic) | Core | Essential | ✅ | | `CreateWalletView` (Import Existing toggle). After sync, derived addresses + balance populate. | +| CORE-03 | Backup / view recovery phrase | Core | Essential | ✅ | | `SeedBackupView`. Phrase matches creation. | +| CORE-04 | Receive (derive address + QR) | Core | Essential | ✅ | | `ReceiveAddressView` → `core_wallet_next_receive_address`. Fresh external address + scannable QR. | +| CORE-05 | Send Core L1 transaction | Core | Essential | ✅ | | Send flow (`SendTransactionView`, mode Core→Core) → `core_wallet_send_to_addresses`. Tx broadcasts; balance drops; appears in history. *Anchor: the canonical Essential action.* | +| CORE-06 | View balance / tx history / UTXOs | Core | Essential | ✅ | read-only | `WalletDetailView`, `TransactionListView`, `AccountDetailView` (SwiftData). | +| CORE-07 | SPV sync (start / stop / progress) | Core | Essential | ✅ | | Global sync indicator (`ContentView`) → `platform_wallet_manager_spv_*`. Headers/filters/masternodes advance to tip. | +| CORE-08 | QR scan recipient | Core | Manual | ✅ | | `QRScannerView`, reachable in the Send flow — but scanning needs a real camera the simulator doesn't have, so it can't be automated (`Tier=Manual`). On a device: Send → QR-scan button → point at a Dash address QR → recipient field populates. | +| CORE-09 | Multiple HD accounts (within one wallet) | Core | Common | ✅ | | Account selection / `AccountDetailView`; balances per `account_index`. Distinct from holding multiple *wallets* — see CORE-14+. | +| CORE-10 | Multi-recipient Core send | Core | Common | ✅ | | Send flow (`SendTransactionView`, Core→Core) → "Add recipient" appends extra address/amount rows → `SendViewModel.coreRecipients` → `core_wallet_send_to_addresses` (parallel arrays; Rust coin-selects + builds the multi-output tx). One tx with N outputs; balance drops by sum+fee. Verified: 2-output testnet send (txid `30010050…17f840fc`, txlock, 3 vouts) credited both recipients. | #### Multiple wallets on one device The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets concurrently (keyed by `wallet_id`), one SPV runtime per network. Most rows elsewhere in this plan are written for a single active wallet — these rows cover the multi-wallet dimension explicitly. (Distinct from CORE-09, which is multiple *accounts* inside one wallet.) -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| CORE-14 | Hold multiple wallets at once (wallet list) | Core | Thorough | ✅ | `WalletsContentView` lists every wallet for the current network; `PlatformWalletManager.wallets` holds N keyed by `wallet_id`. | -| CORE-15 | Create / import a second wallet (alongside existing) | Core | Thorough | ✅ | Wallets tab → "Add Wallet" → `CreateWalletView`. New wallet coexists; must not replace or corrupt the first. | -| CORE-16 | Switch active wallet | Core | Thorough | ✅ | Tap a wallet row → `WalletDetailView` scopes all `@Query`s to that `walletId`. Navigation-based — there is **no** global wallet picker, so "switching" = opening another wallet's detail. | -| CORE-17 | Remove / delete a wallet | Core | Uncommon | ✅ | `WalletDetailView` → Delete Wallet → `platform_wallet_manager_remove_wallet`; cascades Keychain mnemonic + that wallet's identities + SwiftData rows. Verify the other wallets are untouched. | -| CORE-18 | Per-wallet isolation (identities / addresses / balances / shielded) | Core | Thorough | ✅ | Confirm wallet A's identities, addresses, Core/Platform balances and shielded state never surface under wallet B (`@Query` predicates filtered by `walletId`). Key correctness check for multi-wallet. | -| CORE-19 | Send between two on-device wallets | Core | Thorough | ✅ | Normal send from wallet A to wallet B's receive address (no intra-app picker — you must paste/scan B's address). B's balance increases after sync. Variants: identity→identity (`ID-04`) or shielded between two local wallets. | -| CORE-20 | Concurrent SPV sync across all wallets | Core | Thorough | ✅ | One SPV runtime per network filters every wallet's addresses; `spvProgress` is manager-global, not per-wallet. With 2+ wallets, confirm each reaches the tip and detects its own funds. | -| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. UI (`ShieldedService.boundWalletId`) displays one wallet's shielded state at a time — switching should swap cleanly, not merge balances. | -| CORE-22 | Re-add a previously deleted wallet (same network) | Core | Uncommon | ✅ | After `CORE-17`, re-import the same mnemonic on the same network. Re-derives the same (network-scoped) `wallet_id`, re-creates the wallet, and must re-discover identities/addresses/balances cleanly — no stale Keychain keys or orphaned SwiftData rows left over from the delete. Verify the wallet is fully functional again, not a half-restored duplicate. | -| CORE-23 | Re-add a deleted wallet that also exists on another network | Core | Uncommon | ✅ | Same mnemonic present as a wallet on two networks (e.g. testnet + devnet) → **distinct** network-scoped `wallet_id`s, each with its own Keychain mnemonic copy. Delete it on network X (`CORE-17`) and verify the network-Y wallet is untouched (still listed, mnemonic intact, functional); then re-add on X and confirm both coexist. Exercises the `walletRowCountAcrossNetworks` cross-network mnemonic-purge guard in `PlatformWalletManager.deleteWallet`. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| CORE-14 | Hold multiple wallets at once (wallet list) | Core | Thorough | ✅ | multiwallet | `WalletsContentView` lists every wallet for the current network; `PlatformWalletManager.wallets` holds N keyed by `wallet_id`. | +| CORE-15 | Create / import a second wallet (alongside existing) | Core | Thorough | ✅ | multiwallet | Wallets tab → "Add Wallet" → `CreateWalletView`. New wallet coexists; must not replace or corrupt the first. | +| CORE-16 | Switch active wallet | Core | Thorough | ✅ | multiwallet | Tap a wallet row → `WalletDetailView` scopes all `@Query`s to that `walletId`. Navigation-based — there is **no** global wallet picker, so "switching" = opening another wallet's detail. | +| CORE-17 | Remove / delete a wallet | Core | Uncommon | ✅ | multiwallet | `WalletDetailView` → Delete Wallet → `platform_wallet_manager_remove_wallet`; cascades Keychain mnemonic + that wallet's identities + SwiftData rows. Verify the other wallets are untouched. | +| CORE-18 | Per-wallet isolation (identities / addresses / balances / shielded) | Core | Thorough | ✅ | multiwallet | Confirm wallet A's identities, addresses, Core/Platform balances and shielded state never surface under wallet B (`@Query` predicates filtered by `walletId`). Key correctness check for multi-wallet. | +| CORE-19 | Send between two on-device wallets | Core | Thorough | ✅ | multiwallet | Normal send from wallet A to wallet B's receive address (no intra-app picker — you must paste/scan B's address). B's balance increases after sync. Variants: identity→identity (`ID-04`) or shielded between two local wallets. | +| CORE-20 | Concurrent SPV sync across all wallets | Core | Thorough | ✅ | multiwallet | One SPV runtime per network filters every wallet's addresses; `spvProgress` is manager-global, not per-wallet. With 2+ wallets, confirm each reaches the tip and detects its own funds. | +| CORE-21 | Multiple wallets bound to the shielded pool concurrently | Shielded | Uncommon | ✅ | multiwallet | `platform_wallet_manager_bind_shielded` is per `wallet_id`; the manager syncs all bound wallets. UI (`ShieldedService.boundWalletId`) displays one wallet's shielded state at a time — switching should swap cleanly, not merge balances. | +| CORE-22 | Re-add a previously deleted wallet (same network) | Core | Uncommon | ✅ | multiwallet | After `CORE-17`, re-import the same mnemonic on the same network. Re-derives the same (network-scoped) `wallet_id`, re-creates the wallet, and must re-discover identities/addresses/balances cleanly — no stale Keychain keys or orphaned SwiftData rows left over from the delete. Verify the wallet is fully functional again, not a half-restored duplicate. | +| CORE-23 | Re-add a deleted wallet that also exists on another network | Core | Uncommon | ✅ | multiwallet | Same mnemonic present as a wallet on two networks (e.g. testnet + devnet) → **distinct** network-scoped `wallet_id`s, each with its own Keychain mnemonic copy. Delete it on network X (`CORE-17`) and verify the network-Y wallet is untouched (still listed, mnemonic intact, functional); then re-add on X and confirm both coexist. Exercises the `walletRowCountAcrossNetworks` cross-network mnemonic-purge guard in `PlatformWalletManager.deleteWallet`. | ### 4.2 Identity — `Domain=Identity` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| ID-01 | Create identity (Core-funded asset lock) | Cross | Essential | ✅ | `CreateIdentityView` / `IdentityRegistrationController` → `platform_wallet_register_identity_with_signer`. New identity + credit balance appear. *Gateway to all Platform tests.* | -| ID-02 | Load / discover identity from wallet | Platform | Essential | ✅ | `LoadIdentityView` / `SearchWalletsForIdentitiesView` → `platform_wallet_discover_identities`. | -| ID-03 | View identity (info / balance / revision / keys) | Platform | Essential | ✅ | `IdentityDetailView`, `KeysListView`, `KeyDetailView`. | -| ID-04 | Transfer credits identity → identity | Platform | Essential | ✅ | `IdentityDetailView` → **Transfer Credits** (sheet, `TransferCreditsView`) → `wallet.transferCredits` → `platform_wallet_transfer_credits_with_signer` (keychain-signed). Recipient entered via `RecipientPickerView` (local identity / paste base58 id / DPNS name). *Anchor: the "platform-to-platform" Essential action.* Recipient balance increases; sender's drops. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Transfer* builder → `dash_sdk_identity_transfer_credits`.) | -| ID-05 | Top up identity (asset lock) | Cross | Common | ✅ | `TopUpIdentityView` (sheet from `IdentityDetailView`). *Anchor: top-up = Common.* | -| ID-06 | Top up identity (from Platform addresses) | Cross | Common | ✅ | `AddressQueriesView` → TopUpIdentityFromAddresses → `dash_sdk_identity_top_up_from_addresses`. | -| ID-07 | Update identity — add public key | Platform | Common | ✅ | `AddIdentityKeyView` (from `KeysListView`) → `updateIdentity(addPublicKeys:)`. | -| ID-08 | Create identity (from Platform addresses) | Cross | Common | ✅ | `AddressQueriesView` → CreateIdentityFromAddresses → `dash_sdk_identity_create_from_addresses`. | -| ID-09 | Set / edit local alias | Platform | Common | ✅ | `IdentityDetailView` (Add Alias). Local only — persists across relaunch; no broadcast. | -| ID-10 | Withdraw credits → Dash L1 address | Cross | Common | ✅ | `IdentityDetailView` → **Withdraw Credits** (sheet, `WithdrawCreditsView`) → `wallet.withdrawCredits` → `platform_wallet_withdraw_credits_with_signer` (keychain-signed). Destination L1 address typed in + validated against the wallet's network; amount validated against balance. Identity credit balance drops by amount + fee; L1 payout is pooled and processed asynchronously by the network (no immediate txid). Requires the identity to have a TRANSFER/CRITICAL key — newly-derived identities get one (keyId 3); older identities may need one added first via `ID-07`. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Withdrawal* builder → `dash_sdk_identity_withdraw` with a test signer.) | -| ID-11 | Transfer credits → Platform addresses | Platform | Common | ✅ | `AddressQueriesView` → TransferIdentityToAddresses → `dash_sdk_identity_transfer_credits_to_addresses`. | -| ID-12 | Update identity — disable key | Platform | Thorough | ✅ | `KeyDetailView` (drill into a key from `KeysListView`) → **Key Status → Disable Key** → confirm (permanent / irreversible) → `wallet.updateIdentity(disablePublicKeyIds:)` → `platform_wallet_update_identity_with_signer` (keychain-signed). The button is gated to match consensus: it's hidden/disabled for master-level keys, the last enabled authentication key, and the last enabled transfer key (each shows an inline reason), and already-disabled keys show a read-only "Disabled" row. On success the identity's keys are re-fetched so the disabled badge appears, then the view pops back. A swipe-to-Disable shortcut on each eligible row in `KeysListView` routes into the same confirm + submit (reaches keys whose row tap opens `PrivateKeyView` instead of the detail). (Also reachable via *Settings → Platform State Transitions → Identity Update* (disable path) → `executeIdentityUpdate` with a test signer.) | -| ID-13 | Top up identity (builder path) | Cross | — | ➖ | Retired — builder entry is a stub (`notImplemented`); identity top-up is covered by `ID-05`/`ID-06`. Kept here to document the stub; not seeded to the QA catalog. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| ID-01 | Create identity (Core-funded asset lock) | Cross | Essential | ✅ | | `CreateIdentityView` / `IdentityRegistrationController` → `platform_wallet_register_identity_with_signer`. New identity + credit balance appear. *Gateway to all Platform tests.* | +| ID-02 | Load / discover identity from wallet | Platform | Essential | ✅ | | `LoadIdentityView` / `SearchWalletsForIdentitiesView` → `platform_wallet_discover_identities`. | +| ID-03 | View identity (info / balance / revision / keys) | Platform | Essential | ✅ | read-only | `IdentityDetailView`, `KeysListView`, `KeyDetailView`. | +| ID-04 | Transfer credits identity → identity | Platform | Essential | ✅ | | `IdentityDetailView` → **Transfer Credits** (sheet, `TransferCreditsView`) → `wallet.transferCredits` → `platform_wallet_transfer_credits_with_signer` (keychain-signed). Recipient entered via `RecipientPickerView` (local identity / paste base58 id / DPNS name). *Anchor: the "platform-to-platform" Essential action.* Recipient balance increases; sender's drops. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Transfer* builder → `dash_sdk_identity_transfer_credits`.) | +| ID-05 | Top up identity (asset lock) | Cross | Common | ✅ | | `TopUpIdentityView` (sheet from `IdentityDetailView`). *Anchor: top-up = Common.* | +| ID-06 | Top up identity (from Platform addresses) | Cross | Common | ✅ | | `AddressQueriesView` → TopUpIdentityFromAddresses → `dash_sdk_identity_top_up_from_addresses`. | +| ID-07 | Update identity — add public key | Platform | Common | ✅ | | `AddIdentityKeyView` (from `KeysListView`) → `updateIdentity(addPublicKeys:)`. | +| ID-08 | Create identity (from Platform addresses) | Cross | Common | ✅ | | `AddressQueriesView` → CreateIdentityFromAddresses → `dash_sdk_identity_create_from_addresses`. | +| ID-09 | Set / edit local alias | Platform | Common | ✅ | | `IdentityDetailView` (Add Alias). Local only — persists across relaunch; no broadcast. | +| ID-10 | Withdraw credits → Dash L1 address | Cross | Common | ✅ | withdrawal | `IdentityDetailView` → **Withdraw Credits** (sheet, `WithdrawCreditsView`) → `wallet.withdrawCredits` → `platform_wallet_withdraw_credits_with_signer` (keychain-signed). Destination L1 address typed in + validated against the wallet's network; amount validated against balance. Identity credit balance drops by amount + fee; L1 payout is pooled and processed asynchronously by the network (no immediate txid). Requires the identity to have a TRANSFER/CRITICAL key — newly-derived identities get one (keyId 3); older identities may need one added first via `ID-07`. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Withdrawal* builder → `dash_sdk_identity_withdraw` with a test signer.) | +| ID-11 | Transfer credits → Platform addresses | Platform | Common | ✅ | | `AddressQueriesView` → TransferIdentityToAddresses → `dash_sdk_identity_transfer_credits_to_addresses`. | +| ID-12 | Update identity — disable key | Platform | Thorough | ✅ | | `KeyDetailView` (drill into a key from `KeysListView`) → **Key Status → Disable Key** → confirm (permanent / irreversible) → `wallet.updateIdentity(disablePublicKeyIds:)` → `platform_wallet_update_identity_with_signer` (keychain-signed). The button is gated to match consensus: it's hidden/disabled for master-level keys, the last enabled authentication key, and the last enabled transfer key (each shows an inline reason), and already-disabled keys show a read-only "Disabled" row. On success the identity's keys are re-fetched so the disabled badge appears, then the view pops back. A swipe-to-Disable shortcut on each eligible row in `KeysListView` routes into the same confirm + submit (reaches keys whose row tap opens `PrivateKeyView` instead of the detail). (Also reachable via *Settings → Platform State Transitions → Identity Update* (disable path) → `executeIdentityUpdate` with a test signer.) | +| ID-13 | Top up identity (builder path) | Cross | — | ➖ | | Retired — builder entry is a stub (`notImplemented`); identity top-up is covered by `ID-05`/`ID-06`. Kept here to document the stub; not seeded to the QA catalog. | +| ID-14 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | ✅ | multiwallet | `IdentityDetailView` → **Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPickerView` — local / paste id / DPNS). Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. | +| ID-15 | Same identity restored into two wallets (duplicate seed) | Platform | Uncommon | ✅ | multiwallet | Importing the same mnemonic as a second wallet derives the **same** identity; verify state stays consistent and balances are not double-counted or conflicting across the two wallets. | ### 4.3 Platform Addresses (DIP-17 credit addresses) — `Domain=Address` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| ADDR-01 | Query address info / multiple infos | Platform | Common | ✅ | `GetAddressInfoViewModel` / `GetAddressesInfosViewModel` → `dash_sdk_address_fetch_info(s)`. | -| ADDR-02 | Transfer credits address → address | Platform | Thorough | ✅ | `WalletDetailView` → Platform Balance row **⋯ menu → Transfer Credits** (sheet, `TransferPlatformAddressView`) → `ManagedPlatformAddressWallet.transfer` → `platform_address_wallet_transfer` (keychain-signed). Source = DIP-17 platform-payment account picker; destination = own-wallet address picker or pasted 20-byte P2PKH hash. Input selection (Auto), the `Σ inputs == Σ outputs` balancing, fee strategy, and nonce all happen Rust-side — surplus stays on the source addresses (credit-balance model), so there's no change address to pick, and no private-key entry. Submit gated on amount + fee ≤ account balance and recipient ∉ funded source inputs. On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Transfer Address Funds (raw)* → `dash_sdk_address_transfer_funds`, which pastes a raw 64-char private key.) | -| ADDR-03 | Top up address from asset lock | Cross | Thorough | ✅ | `FundFromAssetLockPlatformAddressView` → `dash_sdk_address_top_up_from_asset_lock`. | -| ADDR-04 | Withdraw address credits → Core L1 | Cross | Thorough | ✅ | `WalletDetailView` → Platform Balance row **⋯ menu → Withdraw to Core** (sheet, `WithdrawPlatformAddressView`) → `ManagedPlatformAddressWallet.withdraw` → `platform_address_wallet_withdraw_to_address` (keychain-signed). Source = DIP-17 platform-payment account picker; the **full** account balance is withdrawn (no per-address amount, no change). Core L1 destination = own wallet (`core_wallet_next_receive_address`) or pasted external address, network-checked Rust-side. `coreFeePerByte` defaults to 1. Gated on the Core (SPV) wallet being initialized — shows a "Core not ready" state otherwise. Identity/address credit balance drops; L1 payout is pooled and processed asynchronously (no immediate txid). On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Withdraw Address Funds (raw)* → `dash_sdk_address_withdraw_funds`, which pastes a raw 64-char private key.) | -| ADDR-06 | Display / share your Platform receive address | Platform | Common | ✅ | "Receive Dash" sheet → **Platform** tab (`ReceiveAddressView`, `ReceiveAddressTab.platform`, "Your Platform Address"): QR + bech32m DIP-17 address + Copy. The receive counterpart to the credit-transfer / top-up funding paths. | -| ADDR-09 | Top-up balance reflects exactly once (no double-credit) | Cross | Thorough | ✅ | Regression guard for the top-up double-credit bug. Run `ADDR-03` ("Top Up from Core", `FundFromAssetLockPlatformAddressView`, `dash_sdk_address_top_up_from_asset_lock`) on a Core-funded wallet, then **wait through at least one automatic BLAST platform-address sync (~15s)** and re-read the `WalletDetailView` Platform Balance. **Pass:** the balance increases by the topped-up amount **exactly once** and stays there — through further automatic syncs, a manual Sync-tab "Clear"+"Sync Now", and an app restart. Cross-check the topped-up address against on-chain truth (`sdk.addresses.getWithProof`) when in doubt. **Fail (the bug):** the topped-up address shows ~2× its on-chain balance. Root cause: the funding credit is recorded on-chain as an `AddBalanceToAddress` **delta** (`AddToCredits`) in Drive's recent-address-balance-changes tree, and the sync replayed that delta **on top of** an absolute balance that already included it (the ST-proof reconcile write, or a full scan's trunk absolute). An earlier watermark-invalidation fix (#4004/#4005) forced a full rescan but could not stop the rescan itself from re-applying the delta — behavioral QA 2026-07-06 showed a durable 2× that survived Clear+resync and restart. Fixed properly by the **balance height pin**: every absolute carries `AddressFunds::as_of_height` (the proof/scan height it is current *as of*); the sync's recent/compacted apply loops drop any delta at or below the pin, and reconcile freshness is decided by pin ordering (a later pin wins even when it revises the balance downward — which also self-heals rows poisoned by the old bug). The pin round-trips through persistence as `PersistentPlatformAddress.lastSeenHeight`. Needs a **Funded Core wallet** fixture; the wallet's Core (SPV) balance must be non-zero to build the asset lock. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| ADDR-01 | Query address info / multiple infos | Platform | Common | ✅ | read-only | `GetAddressInfoViewModel` / `GetAddressesInfosViewModel` → `dash_sdk_address_fetch_info(s)`. | +| ADDR-02 | Transfer credits address → address | Platform | Thorough | ✅ | | `WalletDetailView` → Platform Balance row **⋯ menu → Transfer Credits** (sheet, `TransferPlatformAddressView`) → `ManagedPlatformAddressWallet.transfer` → `platform_address_wallet_transfer` (keychain-signed). Source = DIP-17 platform-payment account picker; destination = own-wallet address picker or pasted 20-byte P2PKH hash. Input selection (Auto), the `Σ inputs == Σ outputs` balancing, fee strategy, and nonce all happen Rust-side — surplus stays on the source addresses (credit-balance model), so there's no change address to pick, and no private-key entry. Submit gated on amount + fee ≤ account balance and recipient ∉ funded source inputs. On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Transfer Address Funds (raw)* → `dash_sdk_address_transfer_funds`, which pastes a raw 64-char private key.) | +| ADDR-03 | Top up address from asset lock | Cross | Thorough | ✅ | | `FundFromAssetLockPlatformAddressView` → `dash_sdk_address_top_up_from_asset_lock`. | +| ADDR-04 | Withdraw address credits → Core L1 | Cross | Thorough | ✅ | withdrawal | `WalletDetailView` → Platform Balance row **⋯ menu → Withdraw to Core** (sheet, `WithdrawPlatformAddressView`) → `ManagedPlatformAddressWallet.withdraw` → `platform_address_wallet_withdraw_to_address` (keychain-signed). Source = DIP-17 platform-payment account picker; the **full** account balance is withdrawn (no per-address amount, no change). Core L1 destination = own wallet (`core_wallet_next_receive_address`) or pasted external address, network-checked Rust-side. `coreFeePerByte` defaults to 1. Gated on the Core (SPV) wallet being initialized — shows a "Core not ready" state otherwise. Identity/address credit balance drops; L1 payout is pooled and processed asynchronously (no immediate txid). On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Withdraw Address Funds (raw)* → `dash_sdk_address_withdraw_funds`, which pastes a raw 64-char private key.) | +| ADDR-06 | Display / share your Platform receive address | Platform | Common | ✅ | read-only | "Receive Dash" sheet → **Platform** tab (`ReceiveAddressView`, `ReceiveAddressTab.platform`, "Your Platform Address"): QR + bech32m DIP-17 address + Copy. The receive counterpart to the credit-transfer / top-up funding paths. | +| ADDR-07 | Platform address balance sync (BLAST) — start / progress; address balances populate to tip | Platform | Essential | ✅ | | Sync tab → **Platform Sync Status**: `PlatformBalanceSyncService` runs the BLAST platform-address balance sync (`sync_address_balances` — trunk/branch tree scan + per-block catch-up). After a sync, the platform address balances + Platform Balance + Chain Tip Height populate to chain tip. The Platform-layer analogue of `CORE-07` (Core SPV sync) and `SH-01` (Shielded sync) — the core "platform balances sync to tip" happy path every user relies on. | +| ADDR-08 | Clear & resync platform address balances | Platform | Common | ✅ | | Sync tab → **Platform Sync Status** → **Clear**, then **Sync Now**: drops the cached platform-address balance state and re-runs the BLAST sync (`sync_address_balances`) from scratch. Verifies balances repopulate correctly after a clear — the recovery path for stale / wrong cached platform balances (e.g. the stale-balance display seen during heavy funding). | +| ADDR-09 | Top-up balance reflects exactly once (no double-credit) | Cross | Thorough | ✅ | regression | Regression guard for the top-up double-credit bug. Run `ADDR-03` ("Top Up from Core", `FundFromAssetLockPlatformAddressView`, `dash_sdk_address_top_up_from_asset_lock`) on a Core-funded wallet, then **wait through at least one automatic BLAST platform-address sync (~15s)** and re-read the `WalletDetailView` Platform Balance. **Pass:** the balance increases by the topped-up amount **exactly once** and stays there — through further automatic syncs, a manual Sync-tab "Clear"+"Sync Now", and an app restart. Cross-check the topped-up address against on-chain truth (`sdk.addresses.getWithProof`) when in doubt. **Fail (the bug):** the topped-up address shows ~2× its on-chain balance. Root cause: the funding credit is recorded on-chain as an `AddBalanceToAddress` **delta** (`AddToCredits`) in Drive's recent-address-balance-changes tree, and the sync replayed that delta **on top of** an absolute balance that already included it (the ST-proof reconcile write, or a full scan's trunk absolute). An earlier watermark-invalidation fix (#4004/#4005) forced a full rescan but could not stop the rescan itself from re-applying the delta — behavioral QA 2026-07-06 showed a durable 2× that survived Clear+resync and restart. Fixed properly by the **balance height pin**: every absolute carries `AddressFunds::as_of_height` (the proof/scan height it is current *as of*); the sync's recent/compacted apply loops drop any delta at or below the pin, and reconcile freshness is decided by pin ordering (a later pin wins even when it revises the balance downward — which also self-heals rows poisoned by the old bug). The pin round-trips through persistence as `PersistentPlatformAddress.lastSeenHeight`. Needs a **Funded Core wallet** fixture; the wallet's Core (SPV) balance must be non-zero to build the asset lock. | ### 4.4 DPNS (usernames) — `Domain=DPNS` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| DPNS-01 | Register username (normal) | Platform | Essential | ✅ | `RegisterNameView` → `platform_wallet_register_dpns_name_with_signer`. Name resolves to the identity afterward. | -| DPNS-02 | Check availability / validate / normalize | Platform | Essential | ✅ | `RegisterNameView` / `DPNSTestView`. | -| DPNS-03 | Resolve name → identity | Platform | Essential | ✅ | `PlatformQueriesView` (dpnsResolve) / `DPNSTestView`. | -| DPNS-04 | Get usernames for an identity | Platform | Essential | ✅ | `IdentityDetailView` DPNS section. | -| DPNS-05 | Register username (contested / premium) | Platform | Common | ✅ | `RegisterNameView` (auto-detects contested via `dash_sdk_dpns_is_contested_username`). *Anchor: contested name = Common.* Creates a live vote poll. | -| DPNS-06 | Select main / primary name | Platform | Common | ✅ | `SelectMainNameView` (sheet from `IdentityDetailView`). | -| DPNS-07 | Search names by prefix | Platform | Common | ✅ | `PlatformQueriesView` (dpnsSearch) / `DPNSTestView`. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| DPNS-01 | Register username (normal) | Platform | Essential | ✅ | | `RegisterNameView` → `platform_wallet_register_dpns_name_with_signer`. Name resolves to the identity afterward. | +| DPNS-02 | Check availability / validate / normalize | Platform | Essential | ✅ | read-only | `RegisterNameView` / `DPNSTestView`. | +| DPNS-03 | Resolve name → identity | Platform | Essential | ✅ | read-only | `PlatformQueriesView` (dpnsResolve) / `DPNSTestView`. | +| DPNS-04 | Get usernames for an identity | Platform | Essential | ✅ | read-only | `IdentityDetailView` DPNS section. | +| DPNS-05 | Register username (contested / premium) | Platform | Common | ✅ | contested | `RegisterNameView` (auto-detects contested via `dash_sdk_dpns_is_contested_username`). *Anchor: contested name = Common.* Creates a live vote poll. | +| DPNS-06 | Select main / primary name | Platform | Common | ✅ | | `SelectMainNameView` (sheet from `IdentityDetailView`). | +| DPNS-07 | Search names by prefix | Platform | Common | ✅ | read-only | `PlatformQueriesView` (dpnsSearch) / `DPNSTestView`. | +| DPNS-08 | Contested DPNS race between two on-device identities | Platform | Uncommon | ✅ | multiwallet, contested | A and B (different wallets) both register the same premium/contested name (`DPNS-05`) → produces a contest observable end-to-end on-device via `VOTE-02`/`VOTE-03`. | ### 4.5 Voting / Contested Resources — `Domain=Voting` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| VOTE-01 | Vote on contested DPNS username (masternode vote) | Platform | Thorough | ✅ | `ContestDetailView` cast-vote (from per-name link in `IdentityDetailView`) → `dash_sdk_contested_resource_cast_vote`. *Anchor: voting = Thorough.* **Requires masternode voting credentials** — environment-limited otherwise. | -| VOTE-02 | Query contested resources | Platform | Thorough | ✅ | `PlatformQueriesView` (getContestedResources). | -| VOTE-03 | Query contested-resource vote state | Platform | Thorough | ✅ | `PlatformQueriesView` (getContestedResourceVoteState) — contenders + abstain/lock tallies. | -| VOTE-04 | Query voters for a contestant identity | Platform | Thorough | ✅ | `PlatformQueriesView` (getContestedResourceVotersForIdentity). | -| VOTE-05 | Query an identity's votes | Platform | Thorough | ✅ | `PlatformQueriesView` (getContestedResourceIdentityVotes). | -| VOTE-06 | Query vote polls by end date | Platform | Thorough | ✅ | `PlatformQueriesView` (getVotePollsByEndDate). | -| VOTE-07 | Masternode vote (generic builder entry) | Platform | — | ➖ | Retired — builder entry is a stub (`default → notImplemented`); masternode voting is covered by `VOTE-01`. Kept here to document the stub; not seeded to the QA catalog. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| VOTE-01 | Vote on contested DPNS username (masternode vote) | Platform | Thorough | ✅ | contested, masternode | `ContestDetailView` cast-vote (from per-name link in `IdentityDetailView`) → `dash_sdk_contested_resource_cast_vote`. *Anchor: voting = Thorough.* **Requires masternode voting credentials** — environment-limited otherwise. | +| VOTE-02 | Query contested resources | Platform | Thorough | ✅ | contested, read-only | `PlatformQueriesView` (getContestedResources). | +| VOTE-03 | Query contested-resource vote state | Platform | Thorough | ✅ | contested, read-only | `PlatformQueriesView` (getContestedResourceVoteState) — contenders + abstain/lock tallies. | +| VOTE-04 | Query voters for a contestant identity | Platform | Thorough | ✅ | contested, read-only | `PlatformQueriesView` (getContestedResourceVotersForIdentity). | +| VOTE-05 | Query an identity's votes | Platform | Thorough | ✅ | contested, read-only | `PlatformQueriesView` (getContestedResourceIdentityVotes). | +| VOTE-06 | Query vote polls by end date | Platform | Thorough | ✅ | contested, read-only | `PlatformQueriesView` (getVotePollsByEndDate). | +| VOTE-07 | Masternode vote (generic builder entry) | Platform | — | ➖ | contested | Retired — builder entry is a stub (`default → notImplemented`); masternode voting is covered by `VOTE-01`. Kept here to document the stub; not seeded to the QA catalog. | ### 4.6 Data Contracts — `Domain=Contract` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| DC-01 | Register / load contract from network | Platform | Common | ✅ | `RegisterContractSourceView` / `LocalDataContractsView` / `ContractsTabView`. | -| DC-02 | View contract / schema / doc types / history | Platform | Common | ✅ | `DataContractDetailsView`, `DocumentTypeDetailsView`. | -| DC-03 | Create data contract | Platform | Common | ✅ | `QuickBasicTokenView` and *Settings builder → Data Contract Create* → `platform_wallet_create_data_contract_with_signer`. | -| DC-04 | Update data contract | Platform | Thorough | 🧪 | *Settings builder → Data Contract Update* → `platform_wallet_update_data_contract_with_signer`. (Note the consensus-sensitive byteArray-widening case in memory when authoring updates.) | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| DC-01 | Register / load contract from network | Platform | Common | ✅ | | `RegisterContractSourceView` / `LocalDataContractsView` / `ContractsTabView`. | +| DC-02 | View contract / schema / doc types / history | Platform | Common | ✅ | read-only | `DataContractDetailsView`, `DocumentTypeDetailsView`. | +| DC-03 | Create data contract | Platform | Common | ✅ | | `QuickBasicTokenView` and *Settings builder → Data Contract Create* → `platform_wallet_create_data_contract_with_signer`. | +| DC-04 | Update data contract | Platform | Thorough | 🧪 | | *Settings builder → Data Contract Update* → `platform_wallet_update_data_contract_with_signer`. (Note the consensus-sensitive byteArray-widening case in memory when authoring updates.) | ### 4.7 Documents — `Domain=Document` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| DOC-01 | Query documents / single document | Platform | Common | ✅ | `DocumentsView` / `PlatformQueriesView` → `dash_sdk_document_search` / `_fetch`. | -| DOC-02 | Create document (broadcast) | Platform | Common | ✅ | Production UI: Contracts → contract → document type → **New Document** (`DocumentTypeDetailsView` / schema-driven `CreateDocumentView`) → `platform_wallet_create_document_with_signer` (routes through `rs-platform-wallet` `IdentityWallet::create_document_with_signer` → SDK `put_to_platform_and_wait_for_response`, signed by the wallet's keychain signer). Driven end-to-end: created a `preorder` doc (`saltedDomainHash`) on `GWRSAV…S31Ec` from funded idx1 — network-confirmed, doc id `7i1hJgvVt8fJms26kGwkEZ6jVZxrfd3BrqfmAfpqXMoG`, persisted & appears in the documents list. *(Settings builder → Document Create / `dash_sdk_document_create` remains as a test-signer alternative.)* | -| DOC-03 | Replace document | Platform | Thorough | ✅ | Production UI: Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (`documentAction.menu`, ownership-gated) → **Replace…** → `ReplaceDocumentView` → `platform_wallet_document_replace` (routes through `rs-platform-wallet` `IdentityWallet::replace_document_with_signer`: schema-sanitizes the new properties, bumps the revision, signs with the wallet keychain signer on the 8 MB worker stack). Driven end-to-end on testnet: replaced the `card` doc `FgVSYG6sTZZ9…` on `5jpKat9U82PG` (`attack` 7→42, `name`→"As-replaced"), revision 1→2, broadcast confirmed + canonical JSON persisted. *(Settings builder → `dash_sdk_document_replace_on_platform` remains as a test-signer alternative.)* | -| DOC-04 | Delete document | Platform | Thorough | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Delete…** → `DeleteDocumentView` → `platform_wallet_document_delete` (`IdentityWallet::delete_document_with_signer`, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: deleted the `card` doc `FgVSYG6sTZZ9…`, broadcast confirmed, local row removed. *(Settings builder → `dash_sdk_document_delete` remains as a test-signer alternative.)* | -| DOC-05 | Transfer document | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Transfer…** (shown when the doc type is `documentsTransferable`) → `TransferDocumentView` (recipient base58) → `platform_wallet_document_transfer` (`IdentityWallet::transfer_document_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: transferred the `card` doc `FgVSYG6sTZZ9…` from `BjJz3hdmg5Ec…` → `8267geu4…` (QA2), revision →4, owner changed + persisted. *(Settings builder → `dash_sdk_document_transfer_to_identity` remains as a test-signer alternative.)* | -| DOC-06 | Update document price | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Set Price…** (shown when the doc type has a `tradeMode`) → `SetDocumentPriceView` (price in credits) → `platform_wallet_document_set_price` (`IdentityWallet::set_document_price_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: priced the `card` doc `FgVSYG6sTZZ9…` at 1,000,000 credits, revision →3, `$price` present in persisted JSON. *(Settings builder / `DocumentWithPriceView` → `dash_sdk_document_update_price_of_document` remains as a test-signer alternative.)* | -| DOC-07 | Purchase document | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Purchase…** → `PurchaseDocumentView` → `platform_wallet_document_purchase` (`IdentityWallet::purchase_document_with_signer`; the **purchaser** signs, revision bumped, 8 MB worker stack). Gating verified: Purchase is surfaced **only** when the owner is *not* a wallet-controlled identity **and** the doc type has a `tradeMode` — i.e. buyer ≠ owner, which consensus requires (it rejects self-purchase). Shares the identical broadcast/persist path proven this session by DOC-03/04/05/06 on `FgVSYG6sTZZ9…`; the menu correctly **omitted** Purchase for every wallet-owned doc. A fresh end-to-end buy needs a for-sale doc owned by a counterparty the wallet doesn't control (can't self-buy); the on-chain purchase path was previously confirmed on testnet. *(Settings builder → `dash_sdk_document_purchase` remains as a test-signer alternative.)* | -| DOC-08 | Document aggregation (umbrella) | Platform | Uncommon | ➖ | Split into the rows below — `DOC-10` (count total), `DOC-11` (count filtered), `DOC-12` (count grouped), `DOC-13` (sum), `DOC-14` (average). Kept as a pointer only; select the specific row. | -| DOC-09 | Create document (local demo) | Platform | — | ➖ | Retired. The old `DocumentsView` local-only mock was replaced by the real broadcast flow (`CreateDocumentView`); see `DOC-02`. | -| DOC-10 | Aggregation — count documents (total) | Platform | Uncommon | 🧪 | **Count Documents** read view → Swift wrapper over FFI `dash_sdk_document_count` (proof-verified). Total count is `counts[""]` in the `{counts:{hexKey:u64}}` result. Requires a contract whose doc type sets `documentsCountable: true` (e.g. the `countable` QA fixture). | -| DOC-11 | Aggregation — count documents, filtered (`where`) | Platform | Uncommon | 🧪 | Same Count view with a `where` clause → `dash_sdk_document_count(where_json=…)`. The filtered field must be a `countable` index. | -| DOC-12 | Aggregation — count documents, grouped (`group_by`) | Platform | Uncommon | 🧪 | Same Count view with a `group_by` field → `dash_sdk_document_count(group_by_json=…)`; returns one count per group (hex-encoded group key → `u64`). | -| DOC-13 | Aggregation — sum of a numeric property | Platform | Uncommon | 🧪 | **Sum / Average Documents** read view (op selector → **Sum**) → Swift wrapper over FFI `dash_sdk_document_sum` (proof-verified). Total sum is `sums[""]` in the `{sums:{hexKey:i64}}` result; a `where`/`group_by` filter and the required numeric `sum property` are entered in the same view. Needs a contract doc type with a `summable` index on the numeric property. | -| DOC-14 | Aggregation — average of a numeric property | Platform | Uncommon | 🧪 | Same **Sum / Average Documents** read view (op selector → **Average**) → Swift wrapper over FFI `dash_sdk_document_average` (proof-verified) → `{averages:{hexKey:{count,sum}}}`; the view divides `sum/count` for display. Needs a doc type with a `summable` index on the numeric property. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| DOC-01 | Query documents / single document | Platform | Common | ✅ | read-only | `DocumentsView` / `PlatformQueriesView` → `dash_sdk_document_search` / `_fetch`. | +| DOC-02 | Create document (broadcast) | Platform | Common | ✅ | | Production UI: Contracts → contract → document type → **New Document** (`DocumentTypeDetailsView` / schema-driven `CreateDocumentView`) → `platform_wallet_create_document_with_signer` (routes through `rs-platform-wallet` `IdentityWallet::create_document_with_signer` → SDK `put_to_platform_and_wait_for_response`, signed by the wallet's keychain signer). Driven end-to-end: created a `preorder` doc (`saltedDomainHash`) on `GWRSAV…S31Ec` from funded idx1 — network-confirmed, doc id `7i1hJgvVt8fJms26kGwkEZ6jVZxrfd3BrqfmAfpqXMoG`, persisted & appears in the documents list. *(Settings builder → Document Create / `dash_sdk_document_create` remains as a test-signer alternative.)* | +| DOC-03 | Replace document | Platform | Thorough | ✅ | | Production UI: Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (`documentAction.menu`, ownership-gated) → **Replace…** → `ReplaceDocumentView` → `platform_wallet_document_replace` (routes through `rs-platform-wallet` `IdentityWallet::replace_document_with_signer`: schema-sanitizes the new properties, bumps the revision, signs with the wallet keychain signer on the 8 MB worker stack). Driven end-to-end on testnet: replaced the `card` doc `FgVSYG6sTZZ9…` on `5jpKat9U82PG` (`attack` 7→42, `name`→"As-replaced"), revision 1→2, broadcast confirmed + canonical JSON persisted. *(Settings builder → `dash_sdk_document_replace_on_platform` remains as a test-signer alternative.)* | +| DOC-04 | Delete document | Platform | Thorough | ✅ | | Production UI: **Browse Documents** → document → **⋯** → **Delete…** → `DeleteDocumentView` → `platform_wallet_document_delete` (`IdentityWallet::delete_document_with_signer`, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: deleted the `card` doc `FgVSYG6sTZZ9…`, broadcast confirmed, local row removed. *(Settings builder → `dash_sdk_document_delete` remains as a test-signer alternative.)* | +| DOC-05 | Transfer document | Platform | Uncommon | ✅ | | Production UI: **Browse Documents** → document → **⋯** → **Transfer…** (shown when the doc type is `documentsTransferable`) → `TransferDocumentView` (recipient base58) → `platform_wallet_document_transfer` (`IdentityWallet::transfer_document_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: transferred the `card` doc `FgVSYG6sTZZ9…` from `BjJz3hdmg5Ec…` → `8267geu4…` (QA2), revision →4, owner changed + persisted. *(Settings builder → `dash_sdk_document_transfer_to_identity` remains as a test-signer alternative.)* | +| DOC-06 | Update document price | Platform | Uncommon | ✅ | | Production UI: **Browse Documents** → document → **⋯** → **Set Price…** (shown when the doc type has a `tradeMode`) → `SetDocumentPriceView` (price in credits) → `platform_wallet_document_set_price` (`IdentityWallet::set_document_price_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: priced the `card` doc `FgVSYG6sTZZ9…` at 1,000,000 credits, revision →3, `$price` present in persisted JSON. *(Settings builder / `DocumentWithPriceView` → `dash_sdk_document_update_price_of_document` remains as a test-signer alternative.)* | +| DOC-07 | Purchase document | Platform | Uncommon | ✅ | | Production UI: **Browse Documents** → document → **⋯** → **Purchase…** → `PurchaseDocumentView` → `platform_wallet_document_purchase` (`IdentityWallet::purchase_document_with_signer`; the **purchaser** signs, revision bumped, 8 MB worker stack). Gating verified: Purchase is surfaced **only** when the owner is *not* a wallet-controlled identity **and** the doc type has a `tradeMode` — i.e. buyer ≠ owner, which consensus requires (it rejects self-purchase). Shares the identical broadcast/persist path proven this session by DOC-03/04/05/06 on `FgVSYG6sTZZ9…`; the menu correctly **omitted** Purchase for every wallet-owned doc. A fresh end-to-end buy needs a for-sale doc owned by a counterparty the wallet doesn't control (can't self-buy); the on-chain purchase path was previously confirmed on testnet. *(Settings builder → `dash_sdk_document_purchase` remains as a test-signer alternative.)* | +| DOC-08 | Document aggregation (umbrella) | Platform | Uncommon | ➖ | aggregation | Split into the rows below — `DOC-10` (count total), `DOC-11` (count filtered), `DOC-12` (count grouped), `DOC-13` (sum), `DOC-14` (average). Kept as a pointer only; select the specific row. | +| DOC-09 | Create document (local demo) | Platform | — | ➖ | | Retired. The old `DocumentsView` local-only mock was replaced by the real broadcast flow (`CreateDocumentView`); see `DOC-02`. | +| DOC-10 | Aggregation — count documents (total) | Platform | Uncommon | 🧪 | aggregation, read-only | **Count Documents** read view → Swift wrapper over FFI `dash_sdk_document_count` (proof-verified). Total count is `counts[""]` in the `{counts:{hexKey:u64}}` result. Requires a contract whose doc type sets `documentsCountable: true` (e.g. the `countable` QA fixture). | +| DOC-11 | Aggregation — count documents, filtered (`where`) | Platform | Uncommon | 🧪 | aggregation, read-only | Same Count view with a `where` clause → `dash_sdk_document_count(where_json=…)`. The filtered field must be a `countable` index. | +| DOC-12 | Aggregation — count documents, grouped (`group_by`) | Platform | Uncommon | 🧪 | aggregation, read-only | Same Count view with a `group_by` field → `dash_sdk_document_count(group_by_json=…)`; returns one count per group (hex-encoded group key → `u64`). | +| DOC-13 | Aggregation — sum of a numeric property | Platform | Uncommon | 🧪 | aggregation, read-only | **Sum / Average Documents** read view (op selector → **Sum**) → Swift wrapper over FFI `dash_sdk_document_sum` (proof-verified). Total sum is `sums[""]` in the `{sums:{hexKey:i64}}` result; a `where`/`group_by` filter and the required numeric `sum property` are entered in the same view. Needs a contract doc type with a `summable` index on the numeric property. | +| DOC-14 | Aggregation — average of a numeric property | Platform | Uncommon | 🧪 | aggregation, read-only | Same **Sum / Average Documents** read view (op selector → **Average**) → Swift wrapper over FFI `dash_sdk_document_average` (proof-verified) → `{averages:{hexKey:{count,sum}}}`; the view divides `sum/count` for display. Needs a doc type with a `summable` index on the numeric property. | +| DOC-15 | Document transfer / purchase across wallets | Platform | Uncommon | ✅ | multiwallet | A creates + lists a document (`DOC-02`/`DOC-06`); B transfers/purchases it (`DOC-05`/`DOC-07`). Ownership and credits move between A and B. Transfer half driven end-to-end this session via the production UI: the `card` doc `FgVSYG6sTZZ9…` moved from identity `BjJz3hdmg5Ec…` to a different seed-controlled identity `8267geu4…` (QA2), with ownership re-persisted. Purchase half shares `DOC-07`'s status (a fresh buy needs a counterparty-owned for-sale listing). | ### 4.8 Tokens — `Domain=Token` All token actions support single-signer **and** group (propose / co-sign) modes via `platform-wallet`. Reachability is gated by the contract's on-chain permission rules (a "coming soon" placeholder = disallowed by rule, not unimplemented). -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| TOK-01 | View token balances / details / search | Platform | Common | ✅ | `TokenDetailsView`, `TokensView`, `TokenSearchView`. | -| TOK-02 | Transfer token | Platform | Common | ✅ | `TokenTransferActionView` → `wallet.tokenTransfer`. | -| TOK-03 | Direct purchase token | Platform | Common | ✅ | `TokenPurchaseActionView` → `wallet.tokenPurchase`. | -| TOK-04 | Token queries (statuses / prices / contract info / supply / distributions) | Platform | Common | ✅ | `PlatformQueriesView` token category → `dash_sdk_token_get_*`. | -| TOK-05 | Mint (issuance) | Platform | Thorough | ✅ | `TokenMintActionView` → `wallet.tokenMint`. | -| TOK-06 | Burn | Platform | Thorough | ✅ | `TokenBurnActionView` → `wallet.tokenBurn`. | -| TOK-07 | Claim distribution (perpetual / pre-programmed) | Platform | Thorough | ✅ | `TokenClaimActionView` → `wallet.tokenClaim`. | -| TOK-08 | Freeze an identity's balance | Platform | Uncommon | ✅ | `TokenFreezeActionView` → `wallet.tokenFreeze`. | -| TOK-09 | Unfreeze a balance | Platform | Uncommon | ✅ | `TokenUnfreezeActionView` → `wallet.tokenUnfreeze`. | -| TOK-10 | Destroy frozen funds | Platform | Uncommon | ✅ | `TokenDestroyFrozenFundsActionView`. | -| TOK-11 | Set / clear direct-purchase price | Platform | Uncommon | ✅ | `TokenSetPriceActionView` → `wallet.tokenSetPrice`. | -| TOK-12 | Emergency action — Pause | Platform | Uncommon | ✅ | `TokenPauseActionView` → `platform_wallet_token_pause`. | -| TOK-13 | Emergency action — Resume | Platform | Uncommon | ✅ | `TokenResumeActionView` → `platform_wallet_token_resume`. | -| TOK-14 | Config update / max supply | Platform | Uncommon | ✅ | `TokenUpdateMaxSupplyActionView` → `wallet.tokenUpdateConfig` (one `TokenConfigurationChangeItem` per tx). | -| TOK-15 | Group action — propose | Platform | Uncommon | ✅ | Token action in `.propose` mode (`CoSignProposalView`). Applies to Mint/Burn/Freeze/Unfreeze/DestroyFrozen/Emergency/Config/SetPrice. | -| TOK-16 | Group action — co-sign existing | Platform | Uncommon | ✅ | `PendingGroupActionsView` / `CoSignProposalView`. Action executes when accumulated signer power ≥ required. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| TOK-01 | View token balances / details / search | Platform | Common | ✅ | read-only | `TokenDetailsView`, `TokensView`, `TokenSearchView`. | +| TOK-02 | Transfer token | Platform | Common | ✅ | | `TokenTransferActionView` → `wallet.tokenTransfer`. | +| TOK-03 | Direct purchase token | Platform | Common | ✅ | | `TokenPurchaseActionView` → `wallet.tokenPurchase`. | +| TOK-04 | Token queries (statuses / prices / contract info / supply / distributions) | Platform | Common | ✅ | read-only | `PlatformQueriesView` token category → `dash_sdk_token_get_*`. | +| TOK-05 | Mint (issuance) | Platform | Thorough | ✅ | | `TokenMintActionView` → `wallet.tokenMint`. | +| TOK-06 | Burn | Platform | Thorough | ✅ | | `TokenBurnActionView` → `wallet.tokenBurn`. | +| TOK-07 | Claim distribution (perpetual / pre-programmed) | Platform | Thorough | ✅ | distribution | `TokenClaimActionView` → `wallet.tokenClaim`. | +| TOK-08 | Freeze an identity's balance | Platform | Uncommon | ✅ | freeze | `TokenFreezeActionView` → `wallet.tokenFreeze`. | +| TOK-09 | Unfreeze a balance | Platform | Uncommon | ✅ | freeze | `TokenUnfreezeActionView` → `wallet.tokenUnfreeze`. | +| TOK-10 | Destroy frozen funds | Platform | Uncommon | ✅ | | `TokenDestroyFrozenFundsActionView`. | +| TOK-11 | Set / clear direct-purchase price | Platform | Uncommon | ✅ | | `TokenSetPriceActionView` → `wallet.tokenSetPrice`. | +| TOK-12 | Emergency action — Pause | Platform | Uncommon | ✅ | | `TokenPauseActionView` → `platform_wallet_token_pause`. | +| TOK-13 | Emergency action — Resume | Platform | Uncommon | ✅ | | `TokenResumeActionView` → `platform_wallet_token_resume`. | +| TOK-14 | Config update / max supply | Platform | Uncommon | ✅ | | `TokenUpdateMaxSupplyActionView` → `wallet.tokenUpdateConfig` (one `TokenConfigurationChangeItem` per tx). | +| TOK-15 | Group action — propose | Platform | Uncommon | ✅ | group | Token action in `.propose` mode (`CoSignProposalView`). Applies to Mint/Burn/Freeze/Unfreeze/DestroyFrozen/Emergency/Config/SetPrice. (Folds in GRP-03: token group propose/co-sign.) | +| TOK-16 | Group action — co-sign existing | Platform | Uncommon | ✅ | group | `PendingGroupActionsView` / `CoSignProposalView`. Action executes when accumulated signer power ≥ required. | +| TOK-17 | Token transfer between two on-device identities | Platform | Thorough | ✅ | multiwallet, regression | `TOK-02`, recipient = wallet B's identity. Switch to B; verify the token balance arrived. | +| TOK-18 | View group info / members | Platform | Thorough | ✅ | group, read-only | `GroupDetailView` (drill into member identities). | +| TOK-19 | Group queries (info / infos / actions / signers) | Platform | Thorough | ✅ | group, read-only | `PlatformQueriesView` group category → `dash_sdk_group_get_*`. | +| TOK-20 | Standalone group lifecycle management | Platform | — | ➖ | group | Retired from the catalog — not implemented anywhere and never will be a standalone test: there is no group-create/membership transition; groups exist only as a token access-control construct (read queries `TOK-18`/`TOK-19`; actions fold into `TOK-15`/`TOK-16`). Kept here to document the absence; not seeded to the QA catalog. | ### 4.9 Shielded Pool (Orchard) — `Domain=Shielded` Shielded notes/balance/activity have **no read-side FFI** by design — Rust pushes them to SwiftData via `on_persist_shielded_*` callbacks; the app reads SwiftData. Verify shielded reads against SwiftData, not a query. -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| SH-01 | Shielded sync (start / stop / now) | Shielded | Essential | ✅ | `PlatformWalletManagerShieldedSync` → `platform_wallet_manager_shielded_sync_*`. Precondition for any shielded balance/spend. | -| SH-02 | View shielded activity / notes / balance | Shielded | Essential | ✅ | `ShieldedActivityView` (SwiftData @Query). | -| SH-03 | Shield from Platform balance (Type 15) | Shielded | Essential | ✅ | Send flow (Platform→Shielded) → `walletManager.shieldedShield`. *Anchor: shielded tx = Essential.* | -| SH-04 | Shield from Core L1 balance | Shielded | Essential | ✅ | Send flow (Core→Shielded). | -| SH-05 | Shielded → shielded transfer (Type 16) | Shielded | Essential | ✅ | Send flow (Shielded→Shielded) → `walletManager.shieldedTransfer`. Optional ≤32-byte memo. | -| SH-06 | Unshield → Platform address (Type 17) | Shielded | Essential | ✅ | Send flow (Shielded→Platform) → `walletManager.shieldedUnshield`. | -| SH-07 | Shield from asset lock (Type 18) | Cross | Common | ✅ | `ShieldedFundFromAssetLockView` (from `WalletDetailView`) → `platform_wallet_manager_shielded_fund_from_asset_lock`. | -| SH-08 | Shielded withdraw → Core L1 (Type 19) | Cross | Common | ✅ | Send flow (Shielded→Core) → `walletManager.shieldedWithdraw` (custom `core_fee_per_byte`). | -| SH-09 | Prover warm-up / readiness | Shielded | Common | ✅ | `warmUpShieldedProver` / `shieldedProverIsReady` (~30s Halo2 key build; precondition for spends). | -| SH-10 | Seed shielded pool (anonymity set) | Shielded | Uncommon | ✅ | `SeedShieldedPoolView` → `platform_wallet_manager_shielded_seed_pool_notes`. **Devnet/testnet only** — hard-errors on mainnet. | -| SH-11 | Create identity from shielded pool (Type 20) | Cross | Common | ✅ | `CreateIdentityView` → funding source **Shielded balance** (fixed denominations 0.1 / 0.3 / 0.5 / 1.0 DASH, gated on the bound pool's balance) → `IdentityRegistrationController` (`.shieldedPool`) → `shieldedIdentityCreateFromPool` → `platform_wallet_manager_shielded_identity_create_from_pool`. Requires a synced shielded pool with sufficient balance. | -| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | "Clear" button on the Sync tab (`CoreContentView` → `ShieldedService.clearLocalState` → `clearShielded`). Stops sync, wipes every wallet's shielded notes + sync state, zeroes the Swift mirror; bind credentials are kept so "Sync Now" rebinds and re-scans. (On-disk SQLite tree is intentionally retained.) Verify balance/activity reset, then restore after Sync Now. | -| SH-13 | Display / share your shielded receive address | Shielded | Common | ✅ | "Receive Dash" sheet → **Shielded** tab (`ReceiveAddressView`, `ReceiveAddressTab.shielded`): QR + full `tdash1…`/`dash1…` bech32m address + Copy Address. Hand your shielded address to a payer, or grab wallet B's address for `MW-06`. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| SH-01 | Shielded sync (start / stop / now) | Shielded | Essential | ✅ | | `PlatformWalletManagerShieldedSync` → `platform_wallet_manager_shielded_sync_*`. Precondition for any shielded balance/spend. | +| SH-02 | View shielded activity / notes / balance | Shielded | Essential | ✅ | read-only | `ShieldedActivityView` (SwiftData @Query). | +| SH-03 | Shield from Platform balance (Type 15) | Shielded | Essential | ✅ | | Send flow (Platform→Shielded) → `walletManager.shieldedShield`. *Anchor: shielded tx = Essential.* | +| SH-04 | Shield from Core L1 balance | Shielded | Essential | ✅ | | Send flow (Core→Shielded). | +| SH-05 | Shielded → shielded transfer (Type 16) | Shielded | Essential | ✅ | | Send flow (Shielded→Shielded) → `walletManager.shieldedTransfer`. Optional ≤32-byte memo. | +| SH-06 | Unshield → Platform address (Type 17) | Shielded | Essential | ✅ | | Send flow (Shielded→Platform) → `walletManager.shieldedUnshield`. | +| SH-07 | Shield from asset lock (Type 18) | Cross | Common | ✅ | | `ShieldedFundFromAssetLockView` (from `WalletDetailView`) → `platform_wallet_manager_shielded_fund_from_asset_lock`. | +| SH-08 | Shielded withdraw → Core L1 (Type 19) | Cross | Common | ✅ | withdrawal | Send flow (Shielded→Core) → `walletManager.shieldedWithdraw` (custom `core_fee_per_byte`). | +| SH-09 | Prover warm-up / readiness | Shielded | Common | ✅ | | `warmUpShieldedProver` / `shieldedProverIsReady` (~30s Halo2 key build; precondition for spends). | +| SH-10 | Seed shielded pool (anonymity set) | Shielded | Uncommon | ✅ | | `SeedShieldedPoolView` → `platform_wallet_manager_shielded_seed_pool_notes`. **Devnet/testnet only** — hard-errors on mainnet. | +| SH-11 | Create identity from shielded pool (Type 20) | Cross | Common | ✅ | | `CreateIdentityView` → funding source **Shielded balance** (fixed denominations 0.1 / 0.3 / 0.5 / 1.0 DASH, gated on the bound pool's balance) → `IdentityRegistrationController` (`.shieldedPool`) → `shieldedIdentityCreateFromPool` → `platform_wallet_manager_shielded_identity_create_from_pool`. Requires a synced shielded pool with sufficient balance. | +| SH-12 | Clear shielded state (wipe notes + re-sync) | Shielded | Uncommon | ✅ | | "Clear" button on the Sync tab (`CoreContentView` → `ShieldedService.clearLocalState` → `clearShielded`). Stops sync, wipes every wallet's shielded notes + sync state, zeroes the Swift mirror; bind credentials are kept so "Sync Now" rebinds and re-scans. (On-disk SQLite tree is intentionally retained.) Verify balance/activity reset, then restore after Sync Now. | +| SH-13 | Display / share your shielded receive address | Shielded | Common | ✅ | read-only | "Receive Dash" sheet → **Shielded** tab (`ReceiveAddressView`, `ReceiveAddressTab.shielded`): QR + full `tdash1…`/`dash1…` bech32m address + Copy Address. Hand your shielded address to a payer, or grab wallet B's address for `SH-14`. | +| SH-14 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | multiwallet | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from its Receive → Shielded tab (`SH-13`). Both wallets must be bound + synced (`CORE-21`, `SH-01`); after syncing B, its shielded balance rises. NB only one wallet's shielded state is displayed at a time. | +| SH-15 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | multiwallet | A unshields (`SH-06`) to a Platform address belonging to wallet B; verify B receives the credits (subject to the `SYS-07` sync caveat). | +| SH-16 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | multiwallet, withdrawal | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Completes the cross-wallet shielded exit set (→ shielded `SH-14`, → Platform `SH-15`, → Core `SH-16`). Verify B's Core balance rises after SPV sync. | ### 4.10 DashPay — `Domain=DashPay` -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| DP-01 | Send contact request | Platform | Common | ✅ | DashPay tab → **Add Contact** (toolbar button `dashpay.addContact`) → `AddContactView` (mode toggle `dashpay.addContact.mode`): by Identity ID (base58, 32-byte gated) or DPNS username (≥2-char live prefix search; not-found offers clear-and-retry `dashpay.addContact.retry`) → `platform_wallet_send_contact_request_with_signer`. Optimistic-send overlay until the request appears. | -| DP-02 | Accept contact request | Platform | Common | ✅ | `ContactRequestsView` (Incoming) → **Accept** (`dashpay.request.accept`) → `platform_wallet_accept_contact_request_with_signer`. Contact moves to `ContactsView`; the bidirectional contact persists (both request-direction rows present for the pair). | -| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | `ContactDetailView` → **Send Dash** (`dashpay.detail.sendDash`) → `SendDashPayPaymentSheet` → `platform_wallet_send_dashpay_payment`. A DashPay payment is an **L1 transaction**, so it requires the **Core SPV client running** (`CORE-07`) — with SPV stopped the broadcast fails with "SPV error: SPV Client not started". Payment appears in the contact's Payments (txid). **Verify both directions** — once a contact is established the channel is symmetric, so each party can pay the other (A→B *and* B→A); the recipient derives the sender's payment address from the xpubs exchanged at establishment. Each sender needs its own funded Core wallet + running SPV, and both endpoints must be on the **same network**. Send is disabled while the payment channel is broken — flagged on a permanent channel failure (e.g. the contact rotated their payment keys/addresses, or a request decrypt/validation failure) — showing "ask the contact to send a new request"; it re-enables when a fresh request arrives. | -| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | `DashPayProfileView` → **Edit** (`dashpay.profile.edit`) → `DashPayProfileEditorView` (`dashpay.profile.displayName` / `.publicMessage` / `.avatarUrl`) → `platform_wallet_create_or_update_dashpay_profile_with_signer`. Non-destructive update; avatar renders via `DashPayAvatarView` and the re-fetched profile carries the computed `avatarHash` + 8-byte dHash `avatarFingerprint`. | -| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | DashPay tab: `ContactsView` (established + search `dashpay.search`), `ContactRequestsView` (incoming/outgoing), `ContactDetailView`, `DashPayProfileView` — backed by `PersistentDashpayContactRequest` (SwiftData); established contacts are derived in-memory by joining each pair's incoming + outgoing request rows. | -| DP-06 | Ignore a contact request (reversible local mute) | Platform | Thorough | ✅ | `ContactRequestsView` → **Ignore** (`dashpay.request.ignore`) → `wallet.ignoreContactSender`. The sender leaves the requests list and appears in `IgnoredContactsView` (un-ignore `dashpay.ignored.unignore` reverses it). Local-only, no on-chain artifact (R1 privacy); persists across relaunch. | -| DP-07 | Attach `encryptedAccountLabel`; see contact's "Their account" on receive | Platform | Common | ✅ | DIP-15 §8.5. Send: `AddContactView` → **Account label** (`dashpay.addContact.accountLabel`) carried into `sendContactRequest(…accountLabel:)`. Receive: the counterparty's `ContactDetailView` shows a read-only **"Their account"** block (assert on visible text — no a11y id yet). Verify on a two-wallet loop (cf. `MW-03`): the ingested request carries the encrypted bytes, but the plaintext is decrypted **on accept** (the signer-bearing register step) and shown on the **incoming row only** (direction-specific). | -| DP-08 | QR auto-accept (build "Add me" QR + add via pasted URI) | Platform | Thorough | ✅ | DIP-15 §8.13. Build: `DashPayProfileView` → **Add me (DIP-15 QR)** (`dashpay.profile.qrURI`, `du=…&dapk=…`, 1h validity — `AUTO_ACCEPT_TTL_SECS=3600`) via `buildAutoAcceptQR`. Add: DashPay tab → **Add via QR** (`dashpay.addViaQR`) → `AddViaQRSheet` (`dashpay.qr.uriField` / `dashpay.qr.send`) → `sendContactRequestFromQR`. Two-wallet: A builds the QR, B pastes the URI → the request is auto-accepted by A without A manually accepting (a distinct acceptance path from `DP-02`). **A's reciprocal is signer-backed**, so it only fires once A's wallet is **unlocked** (the "N contacts waiting to finish setup → Unlock" drain); the request + auto-accept proof reach A immediately, but the established reciprocal lands after unlock. The paste path is simulator-drivable; a camera scan yields the same string (Manual variant). | -| DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | -| DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | - -### 4.11 Group — `Domain=Group` - -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| GRP-01 | View group info / members | Platform | Thorough | ✅ | `GroupDetailView` (drill into member identities). | -| GRP-02 | Group queries (info / infos / actions / signers) | Platform | Thorough | ✅ | `PlatformQueriesView` group category → `dash_sdk_group_get_*`. | -| GRP-03 | Token group action — propose / co-sign | Platform | Uncommon | ✅ | Same as `TOK-15` / `TOK-16`. | -| GRP-04 | Standalone group lifecycle management | Platform | — | ➖ | Retired from the catalog — not implemented anywhere and never will be a standalone test: there is no group-create/membership transition; groups exist only as a token access-control construct (read queries `GRP-01`/`GRP-02`; actions `GRP-03` ≡ `TOK-15`/`TOK-16`). Kept here to document the absence; not seeded to the QA catalog. | - -### 4.12 System / Protocol / Diagnostics — `Domain=System` - -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| SYS-01 | Status / total credits / quorums / prefunded balance | Platform | Thorough | ✅ | `PlatformQueriesView` system category. | -| SYS-02 | Epochs info / current / finalized / proposed blocks | Platform | Thorough | ✅ | `PlatformQueriesView` epoch category. | -| SYS-03 | Protocol-version upgrade state / vote status | Platform | Uncommon | ✅ | `PlatformQueriesView` protocol category. | -| SYS-04 | Run-all-queries / DPNS test harness | Platform | Thorough | ✅ | `PlatformQueriesView` diagnostics (`runAllQueries`, `testDPNSQueries`), `DiagnosticsView`. | -| SYS-05 | Storage / Keychain / Wallet-memory explorers | — | Thorough | ✅ | `StorageExplorerView`, `KeychainExplorerView`, `WalletMemoryExplorerView` (Settings; debug tooling). | -| SYS-06 | Path elements (raw GroveDB) | Platform | Uncommon | 🧪 | **Get GroveDB Path Elements** read view (Platform Queries → System & Utility) → Swift wrapper over FFI `dash_sdk_system_get_path_elements` (proof-verified `Element::fetch_many` over `KeysInPath`). Enter a `path` + `keys` JSON array (hex bytes); returns `[{key, element, type}]`. Use a **bounded** path — root-level queries (`path=[]`) fail GroveDB proof verification ("Cannot verify lower bound"). The "DPNS contract example" preset fills `path=["40"]` (DataContractDocuments root) + the DPNS contract id → its subtree `tree` element. | - -### 4.13 Multi-wallet on-device Platform scenarios (same network) — `Domain=MultiWallet` - -These compose base actions using **two (or more) wallets on the same device and the same network**, so both sides of a Platform/shielded interaction are local and end-to-end verifiable without an external counterparty. They reuse the underlying action (cited by ID) — the value is exercising the cross-wallet path and verifying both endpoints on one device. - -Together with the wallet-lifecycle rows in §4.1 (`CORE-14..23`), these form the full multi-wallet test surface. None are Essential/Common — multi-wallet is a power-user / QA topology, not an everyday flow. "Act as wallet B" means navigating into wallet B (and its identity); there is **no** global wallet/identity selector (see `CORE-16`). - -| ID | Action | Layer | Tier | Status | Entry point & test notes | -|---|---|---|---|---|---| -| MW-01 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | ✅ | `IdentityDetailView` → **Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPickerView` — local / paste id / DPNS). Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. | -| MW-02 | Token transfer between two on-device identities | Platform | Thorough | ✅ | `TOK-02`, recipient = wallet B's identity. Switch to B; verify the token balance arrived. | -| MW-03 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | -| MW-04 | Document transfer / purchase across wallets | Platform | Uncommon | ✅ | A creates + lists a document (`DOC-02`/`DOC-06`); B transfers/purchases it (`DOC-05`/`DOC-07`). Ownership and credits move between A and B. Transfer half driven end-to-end this session via the production UI: the `card` doc `FgVSYG6sTZZ9…` moved from identity `BjJz3hdmg5Ec…` to a different seed-controlled identity `8267geu4…` (QA2), with ownership re-persisted. Purchase half shares `DOC-07`'s status (a fresh buy needs a counterparty-owned for-sale listing). | -| MW-05 | Contested DPNS race between two on-device identities | Platform | Uncommon | ✅ | A and B (different wallets) both register the same premium/contested name (`DPNS-05`) → produces a contest observable end-to-end on-device via `VOTE-02`/`VOTE-03`. | -| MW-06 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from its Receive → Shielded tab (`SH-13`). Both wallets must be bound + synced (`CORE-21`, `SH-01`); after syncing B, its shielded balance rises. NB only one wallet's shielded state is displayed at a time. | -| MW-07 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | A unshields (`SH-06`) to a Platform address belonging to wallet B; verify B receives the credits (subject to the MW-08 sync caveat). | -| MW-08 | Platform balance sync is per-active-wallet, **not** concurrent | Platform | Thorough | ✅ | `PlatformBalanceSyncService` is configured for ONE wallet (`configure(...walletId:)`, re-run on switch). Unlike Core SPV (`CORE-20`, all wallets at once), wallet B's Platform address/credit balances can be **stale until you switch to B and Sync Now**. Verify this is the intended behavior, not a bug. | -| MW-09 | Per-wallet Platform isolation (identities / usernames / tokens / contacts) | Platform | Thorough | ✅ | Extends `CORE-18` to Platform reads: wallet A's identities, DPNS names, token balances, and DashPay contacts must never surface under wallet B. | -| MW-10 | Same identity restored into two wallets (duplicate seed) | Platform | Uncommon | ✅ | Importing the same mnemonic as a second wallet derives the **same** identity; verify state stays consistent and balances are not double-counted or conflicting across the two wallets. | -| MW-11 | Shielded withdraw from A to B's Core L1 address | Shielded | Uncommon | ✅ | Wallet A's pool → a Core L1 address owned by wallet B (`SH-08`). Completes the cross-wallet shielded exit set (→ shielded `MW-06`, → Platform `MW-07`, → Core `MW-11`). Verify B's Core balance rises after SPV sync. | +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| DP-01 | Send contact request | Platform | Common | ✅ | | DashPay tab → **Add Contact** (toolbar button `dashpay.addContact`) → `AddContactView` (mode toggle `dashpay.addContact.mode`): by Identity ID (base58, 32-byte gated) or DPNS username (≥2-char live prefix search; not-found offers clear-and-retry `dashpay.addContact.retry`) → `platform_wallet_send_contact_request_with_signer`. Optimistic-send overlay until the request appears. | +| DP-02 | Accept contact request | Platform | Common | ✅ | | `ContactRequestsView` (Incoming) → **Accept** (`dashpay.request.accept`) → `platform_wallet_accept_contact_request_with_signer`. Contact moves to `ContactsView`; the bidirectional contact persists (both request-direction rows present for the pair). | +| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | | `ContactDetailView` → **Send Dash** (`dashpay.detail.sendDash`) → `SendDashPayPaymentSheet` → `platform_wallet_send_dashpay_payment`. A DashPay payment is an **L1 transaction**, so it requires the **Core SPV client running** (`CORE-07`) — with SPV stopped the broadcast fails with "SPV error: SPV Client not started". Payment appears in the contact's Payments (txid). **Verify both directions** — once a contact is established the channel is symmetric, so each party can pay the other (A→B *and* B→A); the recipient derives the sender's payment address from the xpubs exchanged at establishment. Each sender needs its own funded Core wallet + running SPV, and both endpoints must be on the **same network**. Send is disabled while the payment channel is broken — flagged on a permanent channel failure (e.g. the contact rotated their payment keys/addresses, or a request decrypt/validation failure) — showing "ask the contact to send a new request"; it re-enables when a fresh request arrives. | +| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | | `DashPayProfileView` → **Edit** (`dashpay.profile.edit`) → `DashPayProfileEditorView` (`dashpay.profile.displayName` / `.publicMessage` / `.avatarUrl`) → `platform_wallet_create_or_update_dashpay_profile_with_signer`. Non-destructive update; avatar renders via `DashPayAvatarView` and the re-fetched profile carries the computed `avatarHash` + 8-byte dHash `avatarFingerprint`. | +| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | read-only | DashPay tab: `ContactsView` (established + search `dashpay.search`), `ContactRequestsView` (incoming/outgoing), `ContactDetailView`, `DashPayProfileView` — backed by `PersistentDashpayContactRequest` (SwiftData); established contacts are derived in-memory by joining each pair's incoming + outgoing request rows. | +| DP-06 | Ignore a contact request (reversible local mute) | Platform | Thorough | ✅ | | `ContactRequestsView` → **Ignore** (`dashpay.request.ignore`) → `wallet.ignoreContactSender`. The sender leaves the requests list and appears in `IgnoredContactsView` (un-ignore `dashpay.ignored.unignore` reverses it). Local-only, no on-chain artifact (R1 privacy); persists across relaunch. | +| DP-07 | Attach `encryptedAccountLabel`; see contact's "Their account" on receive | Platform | Common | ✅ | | DIP-15 §8.5. Send: `AddContactView` → **Account label** (`dashpay.addContact.accountLabel`) carried into `sendContactRequest(…accountLabel:)`. Receive: the counterparty's `ContactDetailView` shows a read-only **"Their account"** block (assert on visible text — no a11y id yet). Verify on a two-wallet loop (cf. `DP-11`): the ingested request carries the encrypted bytes, but the plaintext is decrypted **on accept** (the signer-bearing register step) and shown on the **incoming row only** (direction-specific). | +| DP-08 | QR auto-accept (build "Add me" QR + add via pasted URI) | Platform | Thorough | ✅ | | DIP-15 §8.13. Build: `DashPayProfileView` → **Add me (DIP-15 QR)** (`dashpay.profile.qrURI`, `du=…&dapk=…`, 1h validity — `AUTO_ACCEPT_TTL_SECS=3600`) via `buildAutoAcceptQR`. Add: DashPay tab → **Add via QR** (`dashpay.addViaQR`) → `AddViaQRSheet` (`dashpay.qr.uriField` / `dashpay.qr.send`) → `sendContactRequestFromQR`. Two-wallet: A builds the QR, B pastes the URI → the request is auto-accepted by A without A manually accepting (a distinct acceptance path from `DP-02`). **A's reciprocal is signer-backed**, so it only fires once A's wallet is **unlocked** (the "N contacts waiting to finish setup → Unlock" drain); the request + auto-accept proof reach A immediately, but the established reciprocal lands after unlock. The paste path is simulator-drivable; a camera scan yields the same string (Manual variant). | +| DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | +| DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | +| DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | + +### 4.11 System / Protocol / Diagnostics — `Domain=System` + +| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | +|---|---|---|---|---|---|---| +| SYS-01 | Status / total credits / quorums / prefunded balance | Platform | Thorough | ✅ | read-only | `PlatformQueriesView` system category. | +| SYS-02 | Epochs info / current / finalized / proposed blocks | Platform | Thorough | ✅ | read-only | `PlatformQueriesView` epoch category. | +| SYS-03 | Protocol-version upgrade state / vote status | Platform | Uncommon | ✅ | read-only | `PlatformQueriesView` protocol category. | +| SYS-04 | Run-all-queries / DPNS test harness | Platform | Thorough | ✅ | read-only | `PlatformQueriesView` diagnostics (`runAllQueries`, `testDPNSQueries`), `DiagnosticsView`. | +| SYS-05 | Storage / Keychain / Wallet-memory explorers | — | Thorough | ✅ | read-only | `StorageExplorerView`, `KeychainExplorerView`, `WalletMemoryExplorerView` (Settings; debug tooling). | +| SYS-06 | Path elements (raw GroveDB) | Platform | Uncommon | 🧪 | read-only | **Get GroveDB Path Elements** read view (Platform Queries → System & Utility) → Swift wrapper over FFI `dash_sdk_system_get_path_elements` (proof-verified `Element::fetch_many` over `KeysInPath`). Enter a `path` + `keys` JSON array (hex bytes); returns `[{key, element, type}]`. Use a **bounded** path — root-level queries (`path=[]`) fail GroveDB proof verification ("Cannot verify lower bound"). The "DPNS contract example" preset fills `path=["40"]` (DataContractDocuments root) + the DPNS contract id → its subtree `tree` element. | +| SYS-07 | Platform balance sync is per-active-wallet, **not** concurrent | Platform | Thorough | ✅ | multiwallet | `PlatformBalanceSyncService` is configured for ONE wallet (`configure(...walletId:)`, re-run on switch). Unlike Core SPV (`CORE-20`, all wallets at once), wallet B's Platform address/credit balances can be **stale until you switch to B and Sync Now**. Verify this is the intended behavior, not a bug. | +| SYS-08 | Per-wallet Platform isolation (identities / usernames / tokens / contacts) | Platform | Thorough | ✅ | multiwallet | Extends `CORE-18` to Platform reads: wallet A's identities, DPNS names, token balances, and DashPay contacts must never surface under wallet B. | --- @@ -326,44 +315,57 @@ Counts are of rows reachable in the app (Status `✅`/`🧪`/`⚠️`); `🔌`/` | Tier | Count (approx.) | Automatable? | |---|---|---| -| Essential | 21 | yes | -| Common | 32 | yes | -| Thorough | 37 | yes | -| Uncommon | 25 | yes | +| Essential | 22 | yes | +| Common | 35 | yes | +| Thorough | 38 | yes | +| Uncommon | 30 | yes | | Manual | 2 (`CORE-08`, `DP-10`) | no — physical device | **By layer (automatable only):** | Layer | Count (approx.) | |---|---| -| Core | 17 | -| Platform | ~75 | -| Cross | 7 | -| Shielded | 16 | +| Core | 18 | +| Platform | ~81 | +| Cross | 11 | +| Shielded | 14 | -**Headline intersection — `Essential ∩ Platform` (the most common QA request):** `ID-02`, `ID-03`, `ID-04`, `DPNS-01`, `DPNS-02`, `DPNS-03`, `DPNS-04`. Essential Core lives in §4.1 (`CORE-01..08`); Essential cross-layer identity creation is `ID-01`; Essential shielded is `SH-01..06`. +**Headline intersection — `Essential ∩ Platform` (the most common QA request):** `ID-02`, `ID-03`, `ID-04`, `ADDR-07`, `DPNS-01`, `DPNS-02`, `DPNS-03`, `DPNS-04`. Essential Core lives in §4.1 (`CORE-01..08`); Essential cross-layer identity creation is `ID-01`; Essential shielded is `SH-01..06`. --- -## 6. Category index +## 6. Category & tag index -Membership of each feature category across **all** sections (primary section members + cross-cutting tests that live elsewhere). To run a `Category=X` selection, take the list below and intersect with `Tier` / `Layer` / `Status` as needed. `A-01..09` means every id in that span. +Every row now lives in its true feature **category** (the §4 section it's in) — there are no cross-cutting "category" rows living elsewhere anymore. To run a `Category=X` selection, take the category list below; for cross-cutting selections (multi-wallet, group, read-only, …) use the **tag index** that follows. Intersect either with `Tier` / `Layer` / `Status` as needed. `A-01..09` means every id in that span. + +**By category (§4 section):** - **Core / Wallet** — `CORE-01..23` -- **MultiWallet** — `CORE-14..23`, `MW-01..11` -- **Identity** — `ID-01..13`, `SH-11`, `MW-01`, `MW-08`, `MW-09`, `MW-10` -- **Address** (DIP-17 platform addresses) — `ADDR-01..04`, `ADDR-06`, `ID-06`, `ID-08`, `ID-11` -- **DPNS** — `DPNS-01..07`, `MW-05` -- **Voting** — `VOTE-01..07`, `DPNS-05`, `MW-05` +- **Identity** — `ID-01..15`, `SH-11` +- **Address** (DIP-17 platform addresses) — `ADDR-01..04`, `ADDR-06..09`, `ID-06`, `ID-08`, `ID-11` +- **DPNS** — `DPNS-01..08` +- **Voting** — `VOTE-01..07`, `DPNS-05` - **Contract** — `DC-01..04` -- **Document** — `DOC-01..14`, `MW-04` -- **Token** — `TOK-01..16`, `MW-02`, `GRP-03` -- **Shielded** — `SH-01..13`, `CORE-21`, `MW-06`, `MW-07`, `MW-11` -- **DashPay** — `DP-01..10`, `MW-03` -- **Group** — `GRP-01..04`, `TOK-15`, `TOK-16` -- **System / Diagnostics** — `SYS-01..06` - -Worked example — *"run all non-Uncommon Token tests"*: take **Token** = `TOK-01..16`, `MW-02`, `GRP-03`; drop the `Uncommon` ones (`TOK-08..16`, `GRP-03`) → run **`TOK-01..07` + `MW-02`**. +- **Document** — `DOC-01..15` +- **Token** — `TOK-01..20` +- **Shielded** — `SH-01..16`, `CORE-21` +- **DashPay** — `DP-01..11` +- **System / Diagnostics** — `SYS-01..08` + +**By tag (cross-cutting, the Tags column):** + +- **multiwallet** — `CORE-14..23`, `ID-14`, `ID-15`, `TOK-17`, `DPNS-08`, `DP-11`, `DOC-15`, `SH-14`, `SH-15`, `SH-16`, `SYS-07`, `SYS-08` +- **group** — `TOK-15`, `TOK-16`, `TOK-18`, `TOK-19`, `TOK-20` +- **contested** — `DPNS-05`, `DPNS-08`, `VOTE-01..07` +- **withdrawal** — `ID-10`, `ADDR-04`, `SH-08`, `SH-16` +- **aggregation** — `DOC-08`, `DOC-10..14` +- **read-only** — the pure query / view rows (see the Tags column; also Appendix A) +- **freeze** — `TOK-08`, `TOK-09` +- **distribution** — `TOK-07` +- **masternode** — `VOTE-01` +- **regression** — `TOK-17`, `ADDR-09`, `DP-10` + +Worked example — *"run all non-Uncommon Token tests"*: take **Token** = `TOK-01..20`; drop the `Uncommon`/stub ones (`TOK-08..16`, `TOK-20`) → run **`TOK-01..07`** plus the Thorough group / multi-wallet token rows `TOK-17`/`TOK-18`/`TOK-19`. Worked example — *"run all multi-wallet token tests"*: `Category=Token AND Tag=multiwallet` → `TOK-17`. --- @@ -482,7 +484,7 @@ For completeness (the "everything gRPC + Core can do" requirement), these exist - `SH-11` create identity from shielded pool (Type 20) **🚫 Not implemented anywhere:** -- `GRP-04` standalone group lifecycle management +- `TOK-20` standalone group lifecycle management - `getConsensusParams` (served via Tenderdash RPC, not the SDK) **Protocol-level write transitions present in DPP but not surfaced as distinct app actions** (the address/asset-lock family — `IdentityCreditTransferToAddresses`, `IdentityCreateFromAddresses`, `IdentityTopUpFromAddresses`, `AddressFundsTransfer`, `AddressFundingFromAssetLock`, `AddressCreditWithdrawal`) are largely covered by the `ID-*`/`ADDR-*` rows above; the shielded family (`Shield`, `ShieldedTransfer`, `Unshield`, `ShieldFromAssetLock`, `ShieldedWithdrawal`, `IdentityCreateFromShieldedPool`) maps to the `SH-*` rows. Anything not mapped is either internal or `🔌`/`🚫` above. From 038c298b7e40036838e9b5d9ec76e0b874063f88 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 7 Jul 2026 02:34:04 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(contract):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20paginate=20migrate-runs,=20harden=20parser=20&=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrate-runs.mjs: `queryAll` now pages through every match via `startAfter` on the last doc's $id (was capped at the first 100), so per-test run history and the idempotency lookup can't silently truncate. - migrate-runs.mjs: after a nonce reconnect, re-verify the run didn't already land before retrying the create — a lost-ack false negative could otherwise duplicate a testRun. - parse-test-plan.mjs: require the full 7-cell row before destructuring so a short row can't shift Notes into the Tags cell. - TEST_PLAN.md (§1/§6): keep `Category` a distinct selection axis — a few rows are cross-cutting and belong to a category other than their §4 section (ID-06/08/11 → Address, SH-11 → Identity, CORE-21 → Shielded), resolved via the §6 index; tags cover cross-cutting modalities. Co-Authored-By: Claude Opus 4.8 --- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 4 +- qa-contract/src/migrate-runs.mjs | 41 +++++++++++++++++-- qa-contract/src/parse-test-plan.mjs | 4 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index c810f50f350..498068c6fe3 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -24,7 +24,7 @@ Every catalog row carries four orthogonal, machine-filterable fields. Select tes A test is **automatable now** only if Status is `✅`, `🧪`, or `⚠️` (reachable and drivable in the simulator) **and** `Tier ≠ Manual`. `Tier=Manual` marks implemented features that need a human on a physical device (e.g. a camera) — the automated QA agent must **skip and flag them for manual testing**, never mark them failed. `🔌`/`🚫` rows are listed for completeness — skip them unless asked to confirm absence. -A row's **category** is the §4 section it lives in — every row now lives in its true feature section. Cross-cutting concerns that used to be a separate section (multi-wallet, group) are expressed as **tags** instead: e.g. `TOK-17` (token transfer between two wallets) lives in **Token** and carries `Tag=multiwallet`, and `CORE-21` is `Tag=multiwallet` on the Core/Shielded side. Select cross-cutting sets with the Tags column — `Tag=multiwallet`, `Tag=group`, `Tag=read-only`, etc. — intersected with `Tier`/`Layer`/`Status`/`Category` as needed. This is the axis behind requests like *"run all multi-wallet token tests"* (`Category=Token AND Tag=multiwallet`). +A row's **primary home** is the §4 section it lives in (its `Domain=…`). **Category** stays a distinct selection axis, though: a handful of rows are cross-cutting and belong to a category *other* than their section — e.g. `ID-06`/`ID-08`/`ID-11` are **Address** tests that live in §4.2 Identity, `SH-11` is an **Identity** test in the Shielded section, and `CORE-21` is **Shielded**. Resolve any `Category=…` selection through the **§6 category index**, which lists every member per category (primary + cross-cutting). Separately, the old MultiWallet/Group *sections* are gone — those cross-cutting concerns are now **tags**: e.g. `TOK-17` (token transfer between two wallets) lives in **Token** and carries `Tag=multiwallet`. Select cross-cutting sets with the Tags column — `Tag=multiwallet`, `Tag=group`, `Tag=read-only`, etc. — intersected with `Tier`/`Layer`/`Status`/`Category` as needed. This is the axis behind requests like *"run all multi-wallet token tests"* (`Category=Token AND Tag=multiwallet`). **Worked examples of a request → selection:** @@ -336,7 +336,7 @@ Counts are of rows reachable in the app (Status `✅`/`🧪`/`⚠️`); `🔌`/` ## 6. Category & tag index -Every row now lives in its true feature **category** (the §4 section it's in) — there are no cross-cutting "category" rows living elsewhere anymore. To run a `Category=X` selection, take the category list below; for cross-cutting selections (multi-wallet, group, read-only, …) use the **tag index** that follows. Intersect either with `Tier` / `Layer` / `Status` as needed. `A-01..09` means every id in that span. +Each row's **primary home** is its §4 section, but a few rows are cross-cutting and are listed under an additional **category** below (e.g. `ID-06`/`ID-08`/`ID-11` under Address, `SH-11` under Identity, `CORE-21` under Shielded). To run a `Category=X` selection, take the category list below — it already includes those cross-cutting members. For cross-cutting *modalities* (multi-wallet, group, read-only, …) use the **tag index** that follows. Intersect either with `Tier` / `Layer` / `Status` as needed. `A-01..09` means every id in that span. **By category (§4 section):** diff --git a/qa-contract/src/migrate-runs.mjs b/qa-contract/src/migrate-runs.mjs index 7e57ea45178..7739b4e1a2b 100644 --- a/qa-contract/src/migrate-runs.mjs +++ b/qa-contract/src/migrate-runs.mjs @@ -31,12 +31,32 @@ const OLD_CATEGORY_CODES = Array.from({ length: 16 }, (_, i) => i); function entropy() { return Uint8Array.from(randomBytes(32)); } +const PAGE = 100; + +// Page through every match, not just the first PAGE, using `startAfter` on the +// last document's $id. `documents.query` caps a page at PAGE rows, so a full +// page means there may be more. Callers that can exceed PAGE rows (the per-test +// run history + idempotency lookup) pass a stable `orderBy` so paging is +// deterministic; the equality-only testCase scan is bounded well under PAGE. async function queryAll(sdk, dataContractId, documentTypeName, where, orderBy) { - const res = await sdk.documents.query({ - dataContractId, documentTypeName, where, ...(orderBy ? { orderBy } : {}), limit: 100, - }); const out = []; - for (const doc of res.values()) if (doc) out.push(doc.toJSON()); + let startAfter; + for (;;) { + const res = await sdk.documents.query({ + dataContractId, + documentTypeName, + where, + ...(orderBy ? { orderBy } : {}), + ...(startAfter ? { startAfter } : {}), + limit: PAGE, + }); + const raw = [...res.values()]; + for (const doc of raw) if (doc) out.push(doc.toJSON()); + if (raw.length < PAGE) break; + const lastId = raw[raw.length - 1]?.toJSON().$id; + if (!lastId) break; // can't advance the cursor — stop rather than loop forever + startAfter = lastId; + } return out; } @@ -153,6 +173,19 @@ async function main() { console.error(` ~ ${label} nonce desync (attempt ${attempt}) — reconnecting + retrying`); await new Promise((r) => setTimeout(r, 2500)); await reconnect(); + // A nonce error can be a false negative — the write may have landed + // before the ack was lost. Re-check before retrying so we don't + // create a duplicate testRun for the same (testId, result, buildRef). + const after = await queryAll( + sdk, newContractId, 'testRun', + [['$ownerId', '==', newOwner], ['app', '==', app], ['testId', '==', newId]], + [['$createdAt', 'asc']], + ); + if (after.some((r) => `${r.result}|${r.buildRef}` === `${run.result}|${run.buildRef}`)) { + console.log(` = ${label} (${run.result}/${run.buildRef}) already landed — skipping retry`); + skipped += 1; + break; + } continue; } failed += 1; diff --git a/qa-contract/src/parse-test-plan.mjs b/qa-contract/src/parse-test-plan.mjs index e75f486a22e..07257d04844 100644 --- a/qa-contract/src/parse-test-plan.mjs +++ b/qa-contract/src/parse-test-plan.mjs @@ -90,7 +90,9 @@ export function parseTestPlan(planPath = DEFAULT_TEST_PLAN, planCommit) { if (!trimmed.startsWith('|')) continue; const cells = splitRow(trimmed); - if (cells.length < 6) continue; + // Catalog rows are 7 cells: ID | Action | Layer | Tier | Status | Tags | Notes. + // Require all 7 so a short row can't shift Notes into the Tags cell. + if (cells.length < 7) continue; if (isSeparator(cells)) continue; const [testId, title, layer, tier, status, tagsCell, ...rest] = cells;