diff --git a/docs/README.md b/docs/README.md index b29fefb3..87fb0391 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Conceptual documents that help you understand why Dotify works the way it does. | [Royalty Settlement](./explanation/royalty-settlement.md) | All | How native runtime payments settle, become claimable on recipient failure, and stay separate from Product CASH | | [Listening Rooms](./explanation/listening-rooms.md) | All | WebRTC peer-to-peer streaming, signaling protocol, known limitations | | [Product DevNet Architecture](./explanation/product-devnet-architecture.md) | Maintainers | Dual-host boundaries, Product account capabilities, rooms, storage, and the proposed contract port | +| [Product CASH Settlement Decision](./design/product-cash-settlement-decision.md) | Maintainers | Why CASH remains unavailable until Product supplies a verifiable CASH-to-runtime entitlement path | --- diff --git a/docs/backlog/implementation/evidence/W16.md b/docs/backlog/implementation/evidence/W16.md new file mode 100644 index 00000000..d79edfb2 --- /dev/null +++ b/docs/backlog/implementation/evidence/W16.md @@ -0,0 +1,154 @@ +# W16 - evidence and handoff + +## Identity + +- Sequence and scope: W16 - prove a real Product CASH settlement path. +- Date: 2026-09-14. +- Starting dev SHA: `383f75f80c21b29e8a63c5d96eb054fd44d1240f`. + The branch was rebased onto current `origin/dev` + `49efda042c544414816e451a5b061e6b83c97103` after W13 merged, then onto + `26da9c85405973ff4cd54ff72ebcfb429065fb36` after W14 and the dev + Playwright fix merged. +- Implementation SHA actually tested: + initial implementation `52369d590783d18ca9d8e80867f00b801588e326`; review + hardening `ab4abf541325d8132ef6909749151cc0dd85015f`. +- Branch / PR / issue: `feat/product-cash-settlement`; PR #162 + (`https://github.com/knzeng-e/dotify/pull/162`); existing Product feasibility + epic #85 (reference only, do not close). +- Related dependency evidence: W05 in + `docs/backlog/implementation/evidence/W05.md` / PR #146 and W11 in + `docs/backlog/implementation/evidence/W11.md` / PR #153 are both present in + current `dev` history. +- Code readiness: locally verified. +- Release readiness: blocked. Current Product APIs do not provide an + authoritative proof connecting a People-chain CASH payment to the matching + Asset Hub SmartRuntime entitlement. + +## Result and decisions + +W16 completes the allowed unsupported-result path from the implementation +contract. Product CASH remains an explicit, non-executable payment rail. Classic +access continues to use the native Asset Hub runtime payment and its on-chain +`hasPaid` / `canAccess` read-back. + +The dated decision record rechecks the September 2026 Product DevNet topology, +current registry, CASH asset identities, and the current published Product SDK +surface. `PaymentManager.requestPayment` returns an opaque payment id and the +status subscription reports `Processing`, `Completed`, or `Failed`; it does not +provide the chain transaction, finalized block, amount/recipient proof, or an +Asset Hub entitlement proof. A `Completed` callback therefore cannot authorize +content-key delivery. + +The new read-only readiness harness verifies the local fail-closed boundary and +models the minimum evidence a future Product mechanism must supply: quote, +Product-public-key-to-H160 payer derivation, recipient/asset/release binding, +People-chain finality for the exact CASH transfer, exactly-once runtime +entitlement correlated to the same receipt and finality proof, stable receipt +identity, retry/replay protection, and reconciliation. It always keeps even +perfectly shaped future evidence blocked until Dotify implements a mechanism +supported by Product. Trusted relays and operator attestations fail rather than +masquerading as Product-native settlement. + +Alternatives rejected in this sequence are treating a Host `Completed` status +as access, operating a private Dotify settlement relay, and silently converting +CASH to the runtime native asset. Each introduces custody, exchange-rate, replay, +or entitlement authority that the current platform does not provide. + +## Verification + +| Command or real scenario | Environment and build | Observed result | Artifact | +| --- | --- | --- | --- | +| Official Product money, address, Host API, and 2026-09-09 DevNet update review | Public Product documentation, reviewed 2026-09-14 | Confirmed People asset `1`, Asset Hub protected asset `50000413`, 6 decimals, EVM chain `420420417`, current CDM registry `0x05662b3dbd5dd9f2ff92d67630477e84b0b37c1f`, and no documented CASH-to-runtime entitlement proof. | `docs/design/product-cash-settlement-decision.md` | +| `npm view @parity/product-sdk version` and `npm view @parity/product-sdk-host version` | npm registry, 2026-09-14 | Passed: current versions match repository pins `0.27.0` and `0.19.1`. | Terminal output in this run | +| `npm --prefix web run test:product-cash-settlement` | macOS, Node `v22.13.1`, review hardening `ab4abf5` | Passed 14/14 Node tests. Covered unsupported static boundary, Host completion without entitlement, Product public-key/H160 mismatch, wrong payer/recipient/asset/context/finality chain, unrelated finalized People block, failed payment, replay, duplicate retry, crash after payment, delayed/reorg-unsafe finality, pre-existing access, wrong/repeated entitlement, trusted relay rejection, and disabled future-authority shape. | Terminal output in this run | +| `npm --prefix web run smoke:product-cash-settlement -- --json-out /tmp/dotify-w16-product-cash-settlement-review.json --md-out /tmp/dotify-w16-product-cash-settlement-review.md` | Same, read-only local snapshot | Passed with expected result `cash-settlement-unavailable`: 6 pass, 0 fail, 1 blocked, 1 not run. Missing authoritative Product mechanism is visible and does not become a false failure of the native pilot rail. | `/tmp/dotify-w16-product-cash-settlement-review.json`, `/tmp/dotify-w16-product-cash-settlement-review.md` | +| `node web/scripts/product-cash-settlement-readiness.mjs --evidence /tmp/dotify-w16-evidence-does-not-exist.json` | Same | Exited 1 with an explicit missing-evidence error; a mistyped evidence path is not treated as an omitted live check. | Terminal output in this run | +| `npm --prefix web run test:unit` | Same, review hardening `ab4abf5` | Passed 55 files / 435 tests. | Terminal output in this run | +| `npm --prefix web run lint` | Same | Passed with 0 errors and 3 inherited React hook dependency warnings in `App.tsx` and `ArtistShell.tsx`. | Terminal output in this run | +| `npm --prefix web run fmt:check` | Same | Passed. | Terminal output in this run | +| `npm --prefix web run build` | Same | Passed. Existing Rollup warnings remain for the `@scure/base` annotation, mixed static/dynamic `protectedAudio.ts` import, and large chunks. | `web/dist/` ignored build output | +| `CATALOG_API_URL=http://127.0.0.1:9 npm --prefix web run build:product-devnet` | Same, deterministic offline catalog refresh | Passed. Generator reported the expected failed fetch and preserved the existing checked-in bootstrap; no generated drift. Existing Rollup/chunk warnings remain. | `web/dist-product/` ignored build output | +| `node scripts/backlog-sync.mjs --check --offline` | Repository root | Passed with inherited warnings for active item 24 lacking an issue mapping and duplicate numbered backlog docs for 08. | Terminal output in this run | +| `git diff --check origin/dev...HEAD` | Repository root | Passed after removing two Markdown hard-break spaces caught by the first PR CI run. | Terminal output in this run | + +No wallet, funded Product account, payment request, runtime write, deployment, or +real participant interaction was used. The evidence fixtures are synthetic and +prove validator behavior only. + +## Compatibility and operations + +- Supported devices, browsers, and Product host versions: no real Product host + payment was attempted. The decision covers the currently published SDK + versions and keeps all hosts on the existing native runtime path. +- New config/permissions and documented defaults: no environment variable, + Product permission, origin, or hosted setting changed. The Product deployment + runbook now includes the read-only readiness command. +- Storage/key/contract migration and compatibility evidence: none required; + no storage, key, ABI, runtime, or contract state changed. +- Deployment identifiers: none. W16 did not deploy or submit transactions. +- Rollback procedure and rehearsal evidence: revert the W16 PR to remove the + harness and decision text. Runtime behavior remains the pre-W16 native payment + path throughout, so no data rollback is required. +- Data collected, retention, and user controls: no user or payment data was + collected. Optional evidence is read from an operator-selected local file and + is not uploaded by the harness. + +## Acceptance mapping + +| Sequence criterion | Passed / failed / not run | Supporting evidence or exact blocker | +| --- | --- | --- | +| Recheck Product APIs, asset identifiers, topology, receipt authenticity, and finality | Passed for current documented surface | Dated decision record and package/version checks. The review found no authentic settlement proof surface. | +| Model quote through reconciliation, including replay and crash handling | Passed locally | Readiness schema and 14 deterministic tests cover each boundary. | +| Wrong payer, asset, recipient, or chain fails closed | Passed locally | Combined invalid-binding test checks Product context, Product public-key/H160 derivation, quote, Host amount/recipient, and People finality chain. | +| Reused receipt and duplicate retry do not grant access twice | Passed locally | Replay/retry test rejects prior use and retry count above one; entitlement test requires `issuanceCount=1`. | +| Reorg and delayed finality do not grant access | Passed locally | Included or reorg-unsafe finality remains blocked. | +| Crash between payment and entitlement and unreconciled payment stay visible | Passed locally | Host payment may pass while missing entitlement and `unreconciledPayment=true` remain blocked. | +| Unrelated finality or pre-existing access cannot satisfy CASH settlement | Passed locally | New review-hardening tests require finality to identify the exact CASH transfer and entitlement to bind the same quote, Host payment id, finality block, and finality transaction hash. | +| One real CASH receipt creates exactly one correct entitlement and reconciled value | Not run / externally blocked | Product exposes no authoritative proof or bridge connecting the two chains. No authorized funds were spent. | +| UI never claims access from an unverified payment signal | Passed locally | `product-cash` remains `status: unsupported`; runtime writers accept only `ExecutableTrackAccessPaymentIntent`; static harness gates pass. | + +## Remaining gates + +- Product must publish a settlement or attestation mechanism binding the payer, + recipient, CASH asset, People-chain finality, stable receipt id, Asset Hub + runtime/content hash, and exactly-once entitlement. +- Once that exists, a new bounded implementation must persist idempotency state + across restarts, define support/refund responsibility, add the disabled-by- + default adapter, and run one authorized funded testnet rehearsal. +- Product CASH must remain unavailable until that follow-up passes. This does not + block the native-runtime-payment pilot. + +## Next agent + +- Next eligible sequence(s), based on merged prerequisites: W15, W17, W19, and + W20. W15 is now unblocked because W14 / PR #161 has merged. +- Files/interfaces changed that the next agent must inspect: + `docs/design/product-cash-settlement-decision.md`, + `web/scripts/product-cash-settlement-readiness.mjs`, + `web/src/features/payments/paymentModel.ts`, and + `docs/operations/product-devnet-deployment.md`. +- Decisions that must not be silently reversed: Host `Completed` is not runtime + entitlement; no private relay or implicit exchange is Product-native; content + keys remain gated by runtime read-back. +- Exact command/scenario to reproduce a remaining issue: + +```bash +npm --prefix web run smoke:product-cash-settlement -- \ + --md-out /tmp/dotify-product-cash-settlement.md \ + --json-out /tmp/dotify-product-cash-settlement.json +``` + +- Project/metadata updates completed and any inaccessible fields: PR #162 + is assigned to `knzeng-e`, labeled `P1`, `product-sdk`, `research`, + `documentation`, `testing`, and `dotify-backlog`, and added to Project 5 with + Status `In Progress`, Priority `P1`, Track `Product SDK`, Phase + `Product feasibility`, Type `Research`, and Backlog doc + `docs/backlog/implementation/W16-product-cash-settlement.md`. No milestone is + set because the repository has no open milestone; no reviewer is requested + because no separate owner is known. Issue #85 remains an open Product + feasibility epic and must not be closed by this research slice. + +Implementation summary: W16 establishes an evidence-backed, fail-closed Product +CASH boundary and a reusable readiness harness without inventing settlement. +Exact next step: review the draft PR, then keep CASH unavailable until Product +provides the required authoritative mechanism. diff --git a/docs/design/product-cash-settlement-decision.md b/docs/design/product-cash-settlement-decision.md new file mode 100644 index 00000000..cb7d10a9 --- /dev/null +++ b/docs/design/product-cash-settlement-decision.md @@ -0,0 +1,142 @@ +# Product CASH Settlement Decision + +Status: W16 decision record. + +Date: 2026-09-14. + +Decision: Product CASH access payments remain unavailable in Dotify until Product +exposes an authoritative settlement or attestation path that can grant and +verify Asset Hub runtime entitlement. + +This review was performed against the current published SDK versions: +`@parity/product-sdk@0.27.0` and `@parity/product-sdk-host@0.19.1`. + +## Official Sources Rechecked + +- Product DevNet update, 2026-09-09: + https://forum.polkadot.network/t/polkadot-product-devnet-update/18602 +- Product money model: + https://docs.polkadotcommunity.foundation/architecture/money/ +- Product addresses and registries: + https://docs.polkadotcommunity.foundation/reference/addresses/ +- Product SDK Host API: + https://paritytech.github.io/product-sdk/api/host/ +- Product SDK transaction API: + https://paritytech.github.io/product-sdk/api/tx/ + +## Current Facts + +Product DevNet is a Paseo system-parachain suite: + +| Component | Current W16 fact | +| --- | --- | +| Product app | `dotify-test01.dot` | +| Contracts and DotNS | Asset Hub para `1000` | +| Identity and CASH | People para `1004` | +| App bundle storage | Bulletin para `1010` | +| Asset Hub EVM chain id | `420420417` | +| CASH on People | local pUSD asset id `1` | +| CASH on Asset Hub | protected asset id `50000413`, 6 decimals | +| Current CDM registry | `0x05662b3dbd5dd9f2ff92d67630477e84b0b37c1f` | + +The September 2026 Product DevNet reset moved DotNS/CDM registries and required +fresh descriptors. That matters for any payment proof because stale registry or +descriptor evidence can decode the wrong chain state without throwing a useful +Dotify-level error. + +The Product Host payment surface is intentionally high-level. `PaymentManager` +exposes balance subscription, top-up, `requestPayment(amount, destination, +from?)`, and payment-status subscription. The documented terminal status is +`Completed` or `Failed`. `requestPayment` returns an opaque payment id, while +the status subscription does not expose a chain transaction hash, finalized +block, amount/recipient binding, or an Asset Hub entitlement proof. That is +enough to drive a Host-mediated payment experience, but it is not the same fact +as a Dotify runtime access grant. + +Dotify's Classic entitlement is on the artist SmartRuntime. Today it is granted +by `musicRoyPayAccess(contentHash)` on Asset Hub and then read back through +`musicAccHasPaid(contentHash, listener)` plus +`musicAccCanAccess(contentHash, listener)`. The key service opens full playback +only after the runtime read-back confirms access. + +## Options Considered + +| Option | Trust assumption | Decision | +| --- | --- | --- | +| Treat Host `PaymentManager` `Completed` as access | Trusts an off-chain/high-level Host status without runtime entitlement | Rejected. A CASH receipt alone does not update `musicAccHasPaid` and cannot authorize key release. | +| Dotify-operated relay receives CASH and calls `musicRoyPayAccess` | Trusts Dotify custody, exchange/accounting, relay uptime, and replay controls | Rejected for W16. This is a trusted payment processor, not Product-native settlement, and requires an explicit product decision. | +| Implicit CASH-to-PAS/native conversion | Trusts an exchange rate and asset bridge not present in Dotify | Rejected. W16 forbids implicit exchange rates and fake CASH settlement. | +| Product-confirmed CASH-to-runtime entitlement bridge or attestation | Product supplies a verifiable proof binding payment and runtime grant | Required future path. Not available in current official docs/API snapshot. | +| Keep native runtime payment for Classic and leave CASH disabled | No new payment trust; runtime remains entitlement authority | Accepted for W16. | + +## Required Future Proof Shape + +A future CASH path must prove all of these before Dotify can label access as +acquired: + +- quote id and idempotency key; +- payer Product public key and H160 runtime address, with the H160 derived from + that exact public key through Product's address mapping; +- recipient address; +- CASH amount in atomic 6-decimal units; +- People asset id `1`, Asset Hub protected asset id `50000413`, and Asset Hub + EVM chain id `420420417`; +- exact runtime address and content hash; +- Host payment id and terminal status; +- People-chain finality for the exact CASH transfer, including quote id, Host + payment id, payer, recipient, amount, People asset id, transaction hash, and a + reorg-safe block identity; +- one Asset Hub runtime entitlement for the same payer/runtime/content hash, + correlated to the same Host payment id and finalized People-chain transfer; +- entitlement timing proving the runtime grant was issued after Host completion + and People-chain finality; +- replay protection proving the same receipt was not reused; +- reconciliation result for crashes, timeouts, duplicate retries, and payments + that settled without entitlement. + +An implementation can pass W16's future "one real receipt creates exactly one +entitlement" requirement only when this proof is provided by a Product-supported +mechanism or an explicitly approved trust design. A private Dotify relay is not +allowed to pretend to be that mechanism. + +## Implemented Boundary + +The current repository keeps the rail closed in three places: + +- `web/src/features/payments/paymentModel.ts` models `product-cash` as + `status: 'unsupported'` with settlement + `pending-product-confirmation`. +- `RuntimeWritePort.payForAccess` accepts only + `ExecutableTrackAccessPaymentIntent`, which is currently the native runtime + payment intent. +- `web/scripts/product-cash-settlement-readiness.mjs` validates this static + boundary and can inspect future evidence. The CLI reports missing official + support as `blocked`, rejects unsafe evidence as `fail`, and exits non-zero + only for invalid/unsafe evidence. + +Run: + +```bash +cd web +npm run smoke:product-cash-settlement +``` + +Optional future evidence can be checked with: + +```bash +cd web +npm run smoke:product-cash-settlement -- \ + --evidence /path/to/product-cash-evidence.json \ + --json-out /tmp/dotify-product-cash-settlement.json \ + --md-out /tmp/dotify-product-cash-settlement.md +``` + +The default result should remain `cash-settlement-unavailable` until Product +documents or ships the authoritative bridge/attestation path. + +## Operational Consequence + +Classic support stays on the native runtime payment rail for the current pilot. +Product CASH can be shown as planned/future work, but the UI and backend must +not claim a listener acquired access from CASH unless the SmartRuntime read-back +confirms the matching entitlement. diff --git a/docs/explanation/product-devnet-architecture.md b/docs/explanation/product-devnet-architecture.md index 458f09ce..7f002a3e 100644 --- a/docs/explanation/product-devnet-architecture.md +++ b/docs/explanation/product-devnet-architecture.md @@ -225,7 +225,14 @@ CASH is represented separately as an unsupported `product-cash` rail. That is intentional. CASH lives on People chain while Dotify entitlements live in Asset Hub runtimes, so the Product-native path needs an explicit receipt or bridge model before any listener payment can execute. Dotify must not silently convert -CASH to native runtime value or mark access paid without runtime evidence. +CASH to native runtime value or mark access paid without runtime evidence. The +W16 decision record now names the required future proof shape and the local +harness that keeps this rail unavailable until Product supplies a verifiable +CASH-to-runtime entitlement path. The proof must derive the payer H160 from the +Product public key, identify the exact finalized CASH transfer, and correlate +the Asset Hub entitlement to that same receipt rather than accepting pre-existing +runtime access: +[`Product CASH Settlement Decision`](../design/product-cash-settlement-decision.md). The CDM adapter has one deliberate gap: historical royalty payment events are not read through Product contract handles because the current SDK surface diff --git a/docs/explanation/royalty-settlement.md b/docs/explanation/royalty-settlement.md index 21782046..89b1e278 100644 --- a/docs/explanation/royalty-settlement.md +++ b/docs/explanation/royalty-settlement.md @@ -147,6 +147,11 @@ native amount as a typed runtime payment intent before submitting Product CASH settlement is deliberately not executable through this path. CASH lives on People chain while Dotify runtime entitlements live on Asset Hub, so that future flow needs an explicit Product-confirmed receipt or bridge design. +The current W16 decision record keeps CASH unavailable until that mechanism can +bind the Product-derived payer identity, recipient, asset id, exact +People-chain transfer finality, correlated Asset-Hub runtime entitlement, and +replay prevention: +[`Product CASH Settlement Decision`](../design/product-cash-settlement-decision.md). ### Royalty event structure diff --git a/docs/index.html b/docs/index.html index 2ff980bd..06444074 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1533,8 +1533,8 @@
` link.
8. A wallet-free browser joins that link from outside the Product host.
diff --git a/web/package.json b/web/package.json
index 1915b078..9d3b1967 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,9 +26,11 @@
"smoke:devnet": "node scripts/devnet-endpoint-smoke.mjs",
"smoke:product-journey": "node scripts/product-devnet-journey-harness.mjs",
"smoke:pilot-release": "node scripts/pilot-release-readiness.mjs",
+ "smoke:product-cash-settlement": "node scripts/product-cash-settlement-readiness.mjs",
"smoke:nearby-privacy": "node scripts/nearby-privacy-simulation.mjs",
"smoke:signal": "node scripts/signaling-smoke.mjs",
"test:pilot-release-readiness": "node --test scripts/pilot-release-readiness.test.mjs",
+ "test:product-cash-settlement": "node --test scripts/product-cash-settlement-readiness.test.mjs",
"lint": "eslint .",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'server/**/*.mjs' 'scripts/**/*.mjs' README.md",
"fmt:check": "prettier --check 'src/**/*.{ts,tsx}' 'server/**/*.mjs' 'scripts/**/*.mjs' README.md",
diff --git a/web/scripts/product-cash-settlement-readiness.mjs b/web/scripts/product-cash-settlement-readiness.mjs
new file mode 100644
index 00000000..f8f625ec
--- /dev/null
+++ b/web/scripts/product-cash-settlement-readiness.mjs
@@ -0,0 +1,719 @@
+#!/usr/bin/env node
+
+// Product CASH settlement readiness harness.
+//
+// This is deliberately read-only. It validates Dotify's local fail-closed CASH
+// boundary and can inspect operator-supplied evidence for a future Product CASH
+// bridge, but it never submits a payment and never treats a Host payment status
+// alone as a runtime access entitlement.
+
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { deriveH160 } from '@parity/product-sdk/address';
+
+export const PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION = 1;
+export const PRODUCT_CASH_DECISION_DATE = '2026-09-14';
+export const PRODUCT_CASH_DEVNET_RESET_AT = '2026-09-09T00:00:00.000Z';
+
+export const PRODUCT_CASH_TOPOLOGY = {
+ productId: 'dotify-test01.dot',
+ assetHubParaId: 1000,
+ peopleParaId: 1004,
+ bulletinParaId: 1010,
+ assetHubChainId: 420420417,
+ peopleCashAssetId: 1,
+ assetHubCashAssetId: 50000413,
+ cashDecimals: 6,
+ cdmRegistry: '0x05662b3dbd5dd9f2ff92d67630477e84b0b37c1f'
+};
+
+export const PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY =
+ 'Product must expose an authoritative CASH settlement or attestation path that binds the payer, recipient, CASH asset, People-chain finality, Asset-Hub runtime entitlement, and stable receipt id.';
+
+const EXPECTED_PRODUCT_PACKAGES = {
+ '@parity/product-sdk': '0.27.0',
+ '@parity/product-sdk-host': '0.19.1'
+};
+
+const RECOGNIZED_AUTHORITY = 'product-confirmed-cash-asset-hub-entitlement';
+
+function isRecord(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isHexAddress(value) {
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value);
+}
+
+function isHexHash(value) {
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
+}
+
+function hexBytes(value, byteLength) {
+ if (typeof value !== 'string' || !value.startsWith('0x')) return null;
+ const hex = value.slice(2);
+ if (hex.length !== byteLength * 2 || !/^[0-9a-fA-F]+$/.test(hex)) return null;
+ return Uint8Array.from(Buffer.from(hex, 'hex'));
+}
+
+function deriveProductH160(productPublicKey) {
+ const publicKey = hexBytes(productPublicKey, 32);
+ if (!publicKey) return null;
+
+ try {
+ const derived = deriveH160(publicKey);
+ return isHexAddress(derived) ? derived : null;
+ } catch {
+ return null;
+ }
+}
+
+function isPositiveIntegerString(value) {
+ return typeof value === 'string' && /^[1-9][0-9]*$/.test(value);
+}
+
+function isNonNegativeInteger(value) {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
+}
+
+function sameText(left, right) {
+ return typeof left === 'string' && typeof right === 'string' && left.toLowerCase() === right.toLowerCase();
+}
+
+function parseDateMs(value) {
+ if (typeof value !== 'string') return null;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function gate(gates, status, id, label, detail, source = 'local') {
+ gates.push({ id, label, status, detail, source });
+}
+
+function pass(gates, id, label, detail, source) {
+ gate(gates, 'pass', id, label, detail, source);
+}
+
+function fail(gates, id, label, detail, source) {
+ gate(gates, 'fail', id, label, detail, source);
+}
+
+function blocked(gates, id, label, detail, source) {
+ gate(gates, 'blocked', id, label, detail, source);
+}
+
+function notRun(gates, id, label, detail, source) {
+ gate(gates, 'not-run', id, label, detail, source);
+}
+
+export function packageLockVersion(lockfile, packageName) {
+ return lockfile?.packages?.[`node_modules/${packageName}`]?.version ?? null;
+}
+
+function checkPinnedPackage(gates, lockfile, packageName, expectedVersion) {
+ const installed = packageLockVersion(lockfile, packageName);
+ if (installed === expectedVersion) {
+ pass(gates, `package:${packageName}`, packageName, `Installed ${installed}.`, 'web/package-lock.json');
+ } else {
+ fail(gates, `package:${packageName}`, packageName, `Expected ${expectedVersion}, found ${installed ?? 'missing'}.`, 'web/package-lock.json');
+ }
+}
+
+export function evaluateStaticCashBoundary(snapshot) {
+ const gates = [];
+ const paymentModel = snapshot.paymentModelText ?? '';
+ const runtimePorts = snapshot.runtimePortsText ?? '';
+ const productDocs = snapshot.productArchitectureText ?? '';
+
+ for (const [packageName, version] of Object.entries(EXPECTED_PRODUCT_PACKAGES)) {
+ checkPinnedPackage(gates, snapshot.webPackageLock, packageName, version);
+ }
+
+ if (paymentModel.includes("rail: 'product-cash'") && paymentModel.includes("status: 'unsupported'")) {
+ pass(gates, 'cash-intent-unsupported', 'CASH intent boundary', 'Product CASH is modeled as an explicit unsupported rail.', 'paymentModel.ts');
+ } else {
+ fail(
+ gates,
+ 'cash-intent-unsupported',
+ 'CASH intent boundary',
+ 'Product CASH must remain explicit and unsupported until W16/W16-follow-up proves settlement.',
+ 'paymentModel.ts'
+ );
+ }
+
+ if (runtimePorts.includes('ExecutableTrackAccessPaymentIntent') && runtimePorts.includes('payForAccess(intent: ExecutableTrackAccessPaymentIntent)')) {
+ pass(gates, 'cash-not-executable', 'Executable payment rail', 'Runtime writers accept only executable native runtime payment intents.', 'runtimePorts.ts');
+ } else {
+ fail(gates, 'cash-not-executable', 'Executable payment rail', 'Runtime writers must not accept product-cash intents.', 'runtimePorts.ts');
+ }
+
+ const normalizedProductDocs = productDocs.replace(/\s+/g, ' ');
+ if (normalizedProductDocs.includes('Dotify must not silently convert CASH to native runtime value or mark access paid without runtime evidence.')) {
+ pass(
+ gates,
+ 'cash-doc-boundary',
+ 'Documented CASH boundary',
+ 'Product architecture docs state the no-conversion/no-unverified-access rule.',
+ 'product-devnet-architecture.md'
+ );
+ } else {
+ fail(
+ gates,
+ 'cash-doc-boundary',
+ 'Documented CASH boundary',
+ 'Product architecture docs must state the no-conversion/no-unverified-access rule.',
+ 'product-devnet-architecture.md'
+ );
+ }
+
+ pass(
+ gates,
+ 'product-cash-topology',
+ 'Product CASH topology',
+ `CASH People asset ${PRODUCT_CASH_TOPOLOGY.peopleCashAssetId}, Asset Hub protected asset ${PRODUCT_CASH_TOPOLOGY.assetHubCashAssetId}, runtime chain ${PRODUCT_CASH_TOPOLOGY.assetHubChainId}.`,
+ 'official Product docs'
+ );
+
+ return gates;
+}
+
+export function evaluateProductCashSettlementEvidence(evidence, options = {}) {
+ const gates = [];
+ const source = options.source ?? 'operator evidence';
+
+ if (!evidence) {
+ notRun(gates, 'cash-evidence', 'Live CASH settlement evidence', 'No Product CASH settlement evidence was supplied.', source);
+ blocked(gates, 'cash-authority', 'Authoritative CASH entitlement mechanism', PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY, 'official Product APIs');
+ return gates;
+ }
+
+ if (!isRecord(evidence)) {
+ fail(gates, 'cash-evidence-shape', 'Evidence schema', 'Evidence must be a JSON object.', source);
+ return gates;
+ }
+
+ if (evidence.schemaVersion === PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION) {
+ pass(gates, 'schema-version', 'Evidence schema', `schemaVersion ${evidence.schemaVersion}.`, source);
+ } else {
+ fail(gates, 'schema-version', 'Evidence schema', `Expected schemaVersion ${PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION}.`, source);
+ }
+
+ const capturedAtMs = parseDateMs(evidence.capturedAt);
+ const resetAtMs = Date.parse(PRODUCT_CASH_DEVNET_RESET_AT);
+ if (capturedAtMs !== null && capturedAtMs >= resetAtMs) {
+ pass(gates, 'captured-at', 'Capture date', `${evidence.capturedAt} is after the September 2026 Product DevNet reset.`, source);
+ } else {
+ fail(gates, 'captured-at', 'Capture date', `Evidence must be captured after ${PRODUCT_CASH_DEVNET_RESET_AT}.`, source);
+ }
+
+ evaluateContext(gates, evidence.context, source);
+ evaluateQuote(gates, evidence.quote, source);
+ evaluateHostPayment(gates, evidence.quote, evidence.hostPayment, source);
+ evaluateFinality(gates, evidence.quote, evidence.hostPayment, evidence.finality, source);
+ evaluateRuntimeEntitlement(gates, evidence.quote, evidence.hostPayment, evidence.finality, evidence.entitlement, source);
+ evaluateReconciliation(gates, evidence.hostPayment, evidence.entitlement, evidence.reconciliation, source);
+ evaluateAuthority(gates, evidence.settlementAuthority, { source });
+
+ return gates;
+}
+
+function evaluateContext(gates, context, source) {
+ if (!isRecord(context)) {
+ fail(gates, 'context', 'Product context', 'Missing context object.', source);
+ return;
+ }
+
+ if (context.productId === PRODUCT_CASH_TOPOLOGY.productId) {
+ pass(gates, 'context:product-id', 'Product ID', context.productId, source);
+ } else {
+ fail(gates, 'context:product-id', 'Product ID', `Expected ${PRODUCT_CASH_TOPOLOGY.productId}, found ${context.productId ?? 'missing'}.`, source);
+ }
+
+ if (context.assetHubChainId === PRODUCT_CASH_TOPOLOGY.assetHubChainId && context.peopleParaId === PRODUCT_CASH_TOPOLOGY.peopleParaId) {
+ pass(gates, 'context:chains', 'Chain topology', 'People 1004 CASH settlement and Asset Hub 420420417 runtime entitlement are both named.', source);
+ } else {
+ fail(gates, 'context:chains', 'Chain topology', 'Expected People para 1004 and Asset Hub EVM chain 420420417.', source);
+ }
+
+ if (sameText(context.cdmRegistry, PRODUCT_CASH_TOPOLOGY.cdmRegistry)) {
+ pass(gates, 'context:cdm-registry', 'CDM registry', context.cdmRegistry, source);
+ } else {
+ fail(gates, 'context:cdm-registry', 'CDM registry', `Expected ${PRODUCT_CASH_TOPOLOGY.cdmRegistry}.`, source);
+ }
+}
+
+function evaluateQuote(gates, quote, source) {
+ if (!isRecord(quote)) {
+ fail(gates, 'quote', 'Bound quote', 'Missing quote object.', source);
+ return;
+ }
+
+ if (typeof quote.quoteId === 'string' && quote.quoteId.trim()) {
+ pass(gates, 'quote:id', 'Quote id', 'Stable quote id is present.', source);
+ } else {
+ fail(gates, 'quote:id', 'Quote id', 'Quote must have a stable quoteId.', source);
+ }
+
+ if (typeof quote.idempotencyKey === 'string' && quote.idempotencyKey.trim()) {
+ pass(gates, 'quote:idempotency', 'Idempotency key', 'Quote carries a retry-safe idempotency key.', source);
+ } else {
+ fail(gates, 'quote:idempotency', 'Idempotency key', 'Quote must bind retries to a stable idempotency key.', source);
+ }
+
+ const derivedPayerH160 = deriveProductH160(quote.payerProductPublicKey);
+ if (isHexAddress(quote.payerH160) && derivedPayerH160 && sameText(quote.payerH160, derivedPayerH160)) {
+ pass(gates, 'quote:payer', 'Payer binding', 'Quote payerH160 matches deriveH160(payerProductPublicKey).', source);
+ } else {
+ fail(
+ gates,
+ 'quote:payer',
+ 'Payer binding',
+ `Quote must include payerH160 and a 32-byte payerProductPublicKey whose Product-derived H160 matches it; derived ${derivedPayerH160 ?? 'unavailable'}.`,
+ source
+ );
+ }
+
+ if (isHexAddress(quote.recipient)) {
+ pass(gates, 'quote:recipient', 'Recipient binding', quote.recipient, source);
+ } else {
+ fail(gates, 'quote:recipient', 'Recipient binding', 'Quote must include the CASH recipient H160 address.', source);
+ }
+
+ if (isHexAddress(quote.runtimeAddress) && isHexHash(quote.contentHash)) {
+ pass(gates, 'quote:release', 'Runtime release binding', 'Quote binds runtimeAddress and contentHash.', source);
+ } else {
+ fail(gates, 'quote:release', 'Runtime release binding', 'Quote must bind the exact runtimeAddress and contentHash.', source);
+ }
+
+ if (
+ isPositiveIntegerString(quote.amountAtomic) &&
+ quote.cashDecimals === PRODUCT_CASH_TOPOLOGY.cashDecimals &&
+ quote.peopleAssetId === PRODUCT_CASH_TOPOLOGY.peopleCashAssetId &&
+ quote.assetHubAssetId === PRODUCT_CASH_TOPOLOGY.assetHubCashAssetId
+ ) {
+ pass(gates, 'quote:asset', 'CASH asset binding', 'Quote uses CASH decimals 6, People asset 1, and Asset Hub protected asset 50000413.', source);
+ } else {
+ fail(gates, 'quote:asset', 'CASH asset binding', 'Quote must use CASH decimals 6, People asset 1, and Asset Hub protected asset 50000413.', source);
+ }
+}
+
+function evaluateHostPayment(gates, quote, hostPayment, source) {
+ if (!isRecord(hostPayment)) {
+ blocked(gates, 'host-payment', 'Host payment status', 'No Host payment status was supplied.', source);
+ return;
+ }
+
+ if (isRecord(quote) && hostPayment.quoteId === quote.quoteId) {
+ pass(gates, 'host-payment:quote', 'Payment quote binding', 'Host payment references the quote id.', source);
+ } else {
+ fail(gates, 'host-payment:quote', 'Payment quote binding', 'Host payment must reference the same quoteId.', source);
+ }
+
+ if (hostPayment.status === 'Completed') {
+ pass(gates, 'host-payment:status', 'Host payment status', 'Host reports Completed.', source);
+ } else if (hostPayment.status === 'Processing') {
+ blocked(gates, 'host-payment:status', 'Host payment status', 'Host payment is still processing.', source);
+ } else {
+ fail(gates, 'host-payment:status', 'Host payment status', `Expected Completed, found ${hostPayment.status ?? 'missing'}.`, source);
+ }
+
+ if (isRecord(quote) && hostPayment.amountAtomic === quote.amountAtomic && sameText(hostPayment.destination, quote.recipient)) {
+ pass(gates, 'host-payment:amount-recipient', 'Amount and recipient', 'Host payment matches the quoted amount and recipient.', source);
+ } else {
+ fail(gates, 'host-payment:amount-recipient', 'Amount and recipient', 'Host payment must match quote amountAtomic and recipient.', source);
+ }
+}
+
+function evaluateFinality(gates, quote, hostPayment, finality, source) {
+ if (!isRecord(finality)) {
+ blocked(gates, 'finality', 'People-chain finality', 'Missing finality evidence for the CASH movement.', source);
+ return;
+ }
+
+ if (finality.peopleParaId !== PRODUCT_CASH_TOPOLOGY.peopleParaId) {
+ fail(gates, 'finality:chain', 'Finality chain', 'Finality must be observed on People para 1004.', source);
+ return;
+ }
+
+ if (finality.status === 'finalized' && isHexHash(finality.blockHash) && finality.reorgSafe === true) {
+ pass(gates, 'finality:chain', 'Finality chain', 'People-chain CASH settlement is finalized and reorg-safe.', source);
+ } else if (finality.status === 'included' || finality.status === 'pending') {
+ blocked(gates, 'finality:chain', 'Finality chain', 'Payment is not finalized yet; do not grant runtime access.', source);
+ return;
+ } else {
+ fail(gates, 'finality:chain', 'Finality chain', 'Finality evidence must include finalized status, blockHash, and reorgSafe=true.', source);
+ return;
+ }
+
+ if (
+ isRecord(quote) &&
+ isRecord(hostPayment) &&
+ finality.quoteId === quote.quoteId &&
+ finality.paymentId === hostPayment.paymentId &&
+ sameText(finality.payerH160, quote.payerH160) &&
+ sameText(finality.recipient, quote.recipient) &&
+ finality.amountAtomic === quote.amountAtomic &&
+ finality.peopleAssetId === quote.peopleAssetId &&
+ isHexHash(finality.transactionHash)
+ ) {
+ pass(
+ gates,
+ 'finality:payment-binding',
+ 'Finalized CASH transfer binding',
+ 'Finality identifies the exact CASH transfer and matches quoteId, paymentId, payer, recipient, amount, and People asset.',
+ source
+ );
+ } else {
+ fail(
+ gates,
+ 'finality:payment-binding',
+ 'Finalized CASH transfer binding',
+ 'Finalized People-chain evidence must identify the CASH transfer and bind quoteId, paymentId, payerH160, recipient, amountAtomic, peopleAssetId, and transactionHash.',
+ source
+ );
+ }
+}
+
+function evaluateRuntimeEntitlement(gates, quote, hostPayment, finality, entitlement, source) {
+ if (!isRecord(entitlement)) {
+ blocked(gates, 'entitlement', 'Asset Hub runtime entitlement', 'Missing runtime entitlement evidence. A Host payment receipt alone is not access.', source);
+ return;
+ }
+
+ if (entitlement.status === 'verified' && entitlement.hasPaid === true && entitlement.canAccess === true && entitlement.finalized === true) {
+ pass(gates, 'entitlement:state', 'Runtime access state', 'Runtime read-back reports paid and playable access after a finalized entitlement write.', source);
+ } else {
+ fail(gates, 'entitlement:state', 'Runtime access state', 'Entitlement must be finalized and read back hasPaid=true plus canAccess=true.', source);
+ }
+
+ if (
+ isRecord(quote) &&
+ sameText(entitlement.runtimeAddress, quote.runtimeAddress) &&
+ sameText(entitlement.contentHash, quote.contentHash) &&
+ sameText(entitlement.listenerAddress, quote.payerH160)
+ ) {
+ pass(gates, 'entitlement:binding', 'Runtime entitlement binding', 'Entitlement matches the quoted runtime, content hash, and payer identity.', source);
+ } else {
+ fail(gates, 'entitlement:binding', 'Runtime entitlement binding', 'Entitlement must match quote runtimeAddress, contentHash, and payerH160.', source);
+ }
+
+ if (entitlement.issuanceCount === 1) {
+ pass(gates, 'entitlement:once', 'Exactly-once entitlement', 'Exactly one runtime entitlement was issued for the receipt.', source);
+ } else {
+ fail(gates, 'entitlement:once', 'Exactly-once entitlement', 'Expected issuanceCount=1.', source);
+ }
+
+ if (isHexHash(entitlement.transactionHash) && isHexHash(entitlement.blockHash) && parseDateMs(entitlement.issuedAt) !== null) {
+ pass(
+ gates,
+ 'entitlement:write',
+ 'Entitlement write evidence',
+ 'Entitlement includes finalized Asset Hub transaction, block, and issuedAt timestamp.',
+ source
+ );
+ } else {
+ fail(
+ gates,
+ 'entitlement:write',
+ 'Entitlement write evidence',
+ 'Entitlement must include transactionHash, blockHash, and issuedAt for the Asset Hub runtime write.',
+ source
+ );
+ }
+
+ if (
+ isRecord(quote) &&
+ isRecord(hostPayment) &&
+ isRecord(finality) &&
+ entitlement.quoteId === quote.quoteId &&
+ entitlement.paymentId === hostPayment.paymentId &&
+ sameText(entitlement.cashFinalityBlockHash, finality.blockHash) &&
+ sameText(entitlement.cashFinalityTransactionHash, finality.transactionHash)
+ ) {
+ pass(
+ gates,
+ 'entitlement:cash-receipt',
+ 'CASH receipt correlation',
+ 'Runtime entitlement is correlated to the same quote, Host payment id, and finalized People-chain CASH transfer.',
+ source
+ );
+ } else {
+ fail(
+ gates,
+ 'entitlement:cash-receipt',
+ 'CASH receipt correlation',
+ 'Runtime entitlement must bind quoteId, paymentId, cashFinalityBlockHash, and cashFinalityTransactionHash so pre-existing access cannot satisfy a later CASH receipt.',
+ source
+ );
+ }
+
+ const issuedAtMs = parseDateMs(entitlement.issuedAt);
+ const hostCompletedAtMs = isRecord(hostPayment) ? parseDateMs(hostPayment.completedAt) : null;
+ const finalizedAtMs = isRecord(finality) ? parseDateMs(finality.finalizedAt) : null;
+ if (issuedAtMs !== null && hostCompletedAtMs !== null && finalizedAtMs !== null && issuedAtMs >= hostCompletedAtMs && issuedAtMs >= finalizedAtMs) {
+ pass(gates, 'entitlement:timing', 'Entitlement timing', 'Runtime entitlement was issued after Host completion and People-chain finality.', source);
+ } else {
+ fail(
+ gates,
+ 'entitlement:timing',
+ 'Entitlement timing',
+ 'Entitlement issuedAt must be present and no earlier than hostPayment.completedAt and finality.finalizedAt.',
+ source
+ );
+ }
+}
+
+function evaluateReconciliation(gates, hostPayment, entitlement, reconciliation, source) {
+ if (!isRecord(reconciliation)) {
+ blocked(gates, 'reconciliation', 'Reconciliation and replay prevention', 'Missing reconciliation evidence.', source);
+ return;
+ }
+
+ const receiptId = reconciliation.stableReceiptId;
+ if (
+ typeof receiptId === 'string' &&
+ receiptId.trim() &&
+ (!isRecord(hostPayment) || receiptId === hostPayment.paymentId) &&
+ (!isRecord(entitlement) || receiptId === entitlement.paymentId)
+ ) {
+ pass(
+ gates,
+ 'reconciliation:receipt-id',
+ 'Stable receipt id',
+ 'Stable receipt id is recorded and matches the Host payment id and entitlement when present.',
+ source
+ );
+ } else {
+ fail(
+ gates,
+ 'reconciliation:receipt-id',
+ 'Stable receipt id',
+ 'stableReceiptId must be present and match hostPayment.paymentId plus entitlement.paymentId when present.',
+ source
+ );
+ }
+
+ if (reconciliation.receiptAlreadyUsed === false) {
+ pass(gates, 'reconciliation:replay', 'Receipt replay', 'Receipt is not marked as previously used.', source);
+ } else {
+ fail(gates, 'reconciliation:replay', 'Receipt replay', 'Reused CASH receipts must be rejected.', source);
+ }
+
+ if (isNonNegativeInteger(reconciliation.duplicateRetryCount) && reconciliation.duplicateRetryCount <= 1) {
+ pass(gates, 'reconciliation:retry', 'Duplicate retry safety', 'Retries are idempotent and did not create duplicate payments.', source);
+ } else {
+ fail(gates, 'reconciliation:retry', 'Duplicate retry safety', 'duplicateRetryCount must be 0 or 1; repeated submissions must not charge twice.', source);
+ }
+
+ if (reconciliation.unreconciledPayment === false && (!isRecord(entitlement) || entitlement.issuanceCount === 1)) {
+ pass(gates, 'reconciliation:unreconciled', 'Unreconciled payment', 'No unreconciled payment remains in the evidence.', source);
+ } else {
+ blocked(
+ gates,
+ 'reconciliation:unreconciled',
+ 'Unreconciled payment',
+ 'Payment remains unreconciled; support/refund responsibility must be handled before access is claimed.',
+ source
+ );
+ }
+}
+
+function evaluateAuthority(gates, authority, options) {
+ if (!isRecord(authority)) {
+ blocked(gates, 'cash-authority', 'Authoritative CASH entitlement mechanism', PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY, 'official Product APIs');
+ return;
+ }
+
+ if (authority.kind === 'trusted-relay' || authority.kind === 'operator-attestation') {
+ fail(
+ gates,
+ 'cash-authority',
+ 'Authoritative CASH entitlement mechanism',
+ `Refusing ${authority.kind}: W16 does not allow a trusted relay to masquerade as Product-supported settlement.`,
+ options.source
+ );
+ return;
+ }
+
+ if (authority.kind !== RECOGNIZED_AUTHORITY) {
+ blocked(gates, 'cash-authority', 'Authoritative CASH entitlement mechanism', PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY, options.source);
+ return;
+ }
+
+ blocked(
+ gates,
+ 'cash-authority',
+ 'Authoritative CASH entitlement mechanism',
+ 'Evidence names the required future authority shape, but this Dotify build does not enable a supported Product CASH bridge.',
+ options.source
+ );
+}
+
+export function summarizeGates(gates) {
+ const summary = {
+ passCount: gates.filter(item => item.status === 'pass').length,
+ failCount: gates.filter(item => item.status === 'fail').length,
+ blockedCount: gates.filter(item => item.status === 'blocked').length,
+ notRunCount: gates.filter(item => item.status === 'not-run').length,
+ status: 'pass'
+ };
+ if (summary.failCount > 0) summary.status = 'fail';
+ else if (summary.blockedCount > 0) summary.status = 'blocked';
+ else if (summary.notRunCount > 0) summary.status = 'not-run';
+ return summary;
+}
+
+export function buildProductCashSettlementReport(input = {}) {
+ const generatedAt = input.generatedAt ?? new Date().toISOString();
+ const staticGates = evaluateStaticCashBoundary(input.snapshot ?? readLocalSnapshot(input.rootDir));
+ const evidenceGates = evaluateProductCashSettlementEvidence(input.evidence ?? null, {
+ source: input.evidenceSource
+ });
+ const gates = [...staticGates, ...evidenceGates];
+ const summary = summarizeGates(gates);
+
+ return {
+ schemaVersion: PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION,
+ generatedAt,
+ decisionDate: PRODUCT_CASH_DECISION_DATE,
+ summary,
+ topology: PRODUCT_CASH_TOPOLOGY,
+ externalDependency: PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY,
+ staticGates,
+ evidenceGates,
+ gates,
+ result:
+ summary.status === 'pass' ? 'cash-settlement-supported' : summary.status === 'fail' ? 'cash-settlement-evidence-invalid' : 'cash-settlement-unavailable'
+ };
+}
+
+export function renderMarkdownReport(report) {
+ const rows = report.gates.map(item => `| ${item.status} | ${item.id} | ${item.label} | ${escapePipes(item.detail)} | ${item.source} |`).join('\n');
+
+ return `# Product CASH settlement readiness
+
+- Generated: ${report.generatedAt}
+- Decision date: ${report.decisionDate}
+- Result: ${report.result}
+- Summary: ${report.summary.passCount} pass / ${report.summary.failCount} fail / ${report.summary.blockedCount} blocked / ${report.summary.notRunCount} not-run
+- External dependency: ${report.externalDependency}
+
+## Topology
+
+- Product ID: \`${report.topology.productId}\`
+- Asset Hub para: \`${report.topology.assetHubParaId}\`
+- People para: \`${report.topology.peopleParaId}\`
+- Asset Hub EVM chain ID: \`${report.topology.assetHubChainId}\`
+- CASH on People: asset \`${report.topology.peopleCashAssetId}\`
+- CASH on Asset Hub: protected asset \`${report.topology.assetHubCashAssetId}\`
+- CDM registry: \`${report.topology.cdmRegistry}\`
+
+## Gates
+
+| Status | ID | Gate | Detail | Source |
+| --- | --- | --- | --- | --- |
+${rows}
+`;
+}
+
+function escapePipes(value) {
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', '
');
+}
+
+export function readLocalSnapshot(rootDir = repoRoot()) {
+ return {
+ webPackageLock: readJsonIfExists(resolve(rootDir, 'web/package-lock.json')),
+ paymentModelText: readTextIfExists(resolve(rootDir, 'web/src/features/payments/paymentModel.ts')),
+ runtimePortsText: readTextIfExists(resolve(rootDir, 'web/src/features/runtime/runtimePorts.ts')),
+ productArchitectureText: readTextIfExists(resolve(rootDir, 'docs/explanation/product-devnet-architecture.md'))
+ };
+}
+
+export function gitCommit(rootDir = repoRoot()) {
+ try {
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
+ } catch {
+ return 'unknown';
+ }
+}
+
+function repoRoot() {
+ return resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+}
+
+function readTextIfExists(path) {
+ return existsSync(path) ? readFileSync(path, 'utf8') : '';
+}
+
+function readJsonIfExists(path) {
+ if (!existsSync(path)) return null;
+ return JSON.parse(readFileSync(path, 'utf8'));
+}
+
+function readRequiredJson(path, label) {
+ if (!existsSync(path)) {
+ throw new Error(`${label} file not found: ${path}`);
+ }
+ return JSON.parse(readFileSync(path, 'utf8'));
+}
+
+function parseArgs(argv) {
+ const args = { rootDir: repoRoot(), evidence: null, jsonOut: null, mdOut: null };
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === '--evidence') args.evidence = argv[++index] ?? null;
+ else if (arg === '--json-out') args.jsonOut = argv[++index] ?? null;
+ else if (arg === '--md-out') args.mdOut = argv[++index] ?? null;
+ else if (arg === '--root') args.rootDir = argv[++index] ?? args.rootDir;
+ else if (arg === '--help' || arg === '-h') args.help = true;
+ else throw new Error(`Unknown argument: ${arg}`);
+ }
+ return args;
+}
+
+function usage() {
+ return `Usage: node scripts/product-cash-settlement-readiness.mjs [--evidence evidence.json] [--json-out report.json] [--md-out report.md]
+
+Without --evidence, the harness validates the local fail-closed CASH boundary
+and reports the Product CASH settlement rail as blocked on external Product
+support. It never signs or submits transactions.`;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help) {
+ console.log(usage());
+ return;
+ }
+
+ const evidencePath = args.evidence ? resolve(args.evidence) : null;
+ const evidence = evidencePath ? readRequiredJson(evidencePath, 'Evidence') : null;
+ const report = buildProductCashSettlementReport({
+ rootDir: args.rootDir,
+ evidence,
+ evidenceSource: evidencePath ?? undefined
+ });
+
+ if (args.jsonOut) {
+ writeFileSync(resolve(args.jsonOut), `${JSON.stringify(report, null, 2)}\n`);
+ }
+ if (args.mdOut) {
+ writeFileSync(resolve(args.mdOut), renderMarkdownReport(report));
+ }
+
+ console.log(renderMarkdownReport(report));
+
+ if (report.summary.failCount > 0) {
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main().catch(error => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
diff --git a/web/scripts/product-cash-settlement-readiness.test.mjs b/web/scripts/product-cash-settlement-readiness.test.mjs
new file mode 100644
index 00000000..65cfc4b3
--- /dev/null
+++ b/web/scripts/product-cash-settlement-readiness.test.mjs
@@ -0,0 +1,378 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY,
+ PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION,
+ PRODUCT_CASH_TOPOLOGY,
+ buildProductCashSettlementReport,
+ packageLockVersion
+} from './product-cash-settlement-readiness.mjs';
+
+const PRODUCT_PUBLIC_KEY = `0x${'44'.repeat(32)}`;
+const ADDRESS = '0x0e8ce681fb6d8aa53c5302e72b80f654141a0e65';
+const WRONG_DERIVED_ADDRESS = '0x1111111111111111111111111111111111111111';
+const RECIPIENT = '0x2222222222222222222222222222222222222222';
+const RUNTIME = '0x3333333333333333333333333333333333333333';
+const CONTENT_HASH = `0x${'ab'.repeat(32)}`;
+const CASH_TX_HASH = `0x${'cd'.repeat(32)}`;
+const ENTITLEMENT_TX_HASH = `0x${'12'.repeat(32)}`;
+const ENTITLEMENT_BLOCK_HASH = `0x${'34'.repeat(32)}`;
+const BLOCK_HASH = `0x${'ef'.repeat(32)}`;
+
+function staticSnapshot(patch = {}) {
+ return {
+ webPackageLock: {
+ packages: {
+ 'node_modules/@parity/product-sdk': { version: '0.27.0' },
+ 'node_modules/@parity/product-sdk-host': { version: '0.19.1' }
+ }
+ },
+ paymentModelText: "type Cash = { rail: 'product-cash'; status: 'unsupported' };",
+ runtimePortsText:
+ 'import type { ExecutableTrackAccessPaymentIntent } from "../payments/paymentModel"; payForAccess(intent: ExecutableTrackAccessPaymentIntent): Promise;',
+ productArchitectureText: 'Dotify must not silently convert CASH to native runtime value or mark access paid without runtime evidence.',
+ ...patch
+ };
+}
+
+function completeEvidence(patch = {}) {
+ const quote = {
+ quoteId: 'quote-001',
+ idempotencyKey: 'dotify-cash-quote-001',
+ payerH160: ADDRESS,
+ payerProductPublicKey: PRODUCT_PUBLIC_KEY,
+ recipient: RECIPIENT,
+ runtimeAddress: RUNTIME,
+ contentHash: CONTENT_HASH,
+ amountAtomic: '2500000',
+ cashDecimals: PRODUCT_CASH_TOPOLOGY.cashDecimals,
+ peopleAssetId: PRODUCT_CASH_TOPOLOGY.peopleCashAssetId,
+ assetHubAssetId: PRODUCT_CASH_TOPOLOGY.assetHubCashAssetId
+ };
+
+ const hostPayment = {
+ paymentId: 'payment-001',
+ quoteId: quote.quoteId,
+ status: 'Completed',
+ amountAtomic: quote.amountAtomic,
+ destination: quote.recipient,
+ completedAt: '2026-09-14T10:01:00.000Z'
+ };
+
+ return {
+ schemaVersion: PRODUCT_CASH_SETTLEMENT_SCHEMA_VERSION,
+ capturedAt: '2026-09-14T10:00:00.000Z',
+ context: {
+ productId: PRODUCT_CASH_TOPOLOGY.productId,
+ assetHubChainId: PRODUCT_CASH_TOPOLOGY.assetHubChainId,
+ peopleParaId: PRODUCT_CASH_TOPOLOGY.peopleParaId,
+ cdmRegistry: PRODUCT_CASH_TOPOLOGY.cdmRegistry
+ },
+ quote,
+ hostPayment,
+ finality: {
+ peopleParaId: PRODUCT_CASH_TOPOLOGY.peopleParaId,
+ status: 'finalized',
+ blockHash: BLOCK_HASH,
+ reorgSafe: true,
+ quoteId: quote.quoteId,
+ paymentId: hostPayment.paymentId,
+ payerH160: quote.payerH160,
+ recipient: quote.recipient,
+ amountAtomic: quote.amountAtomic,
+ peopleAssetId: quote.peopleAssetId,
+ transactionHash: CASH_TX_HASH,
+ finalizedAt: '2026-09-14T10:02:00.000Z'
+ },
+ entitlement: {
+ status: 'verified',
+ runtimeAddress: quote.runtimeAddress,
+ contentHash: quote.contentHash,
+ listenerAddress: quote.payerH160,
+ hasPaid: true,
+ canAccess: true,
+ finalized: true,
+ transactionHash: ENTITLEMENT_TX_HASH,
+ blockHash: ENTITLEMENT_BLOCK_HASH,
+ issuanceCount: 1,
+ quoteId: quote.quoteId,
+ paymentId: hostPayment.paymentId,
+ cashFinalityBlockHash: BLOCK_HASH,
+ cashFinalityTransactionHash: CASH_TX_HASH,
+ issuedAt: '2026-09-14T10:03:00.000Z'
+ },
+ reconciliation: {
+ stableReceiptId: hostPayment.paymentId,
+ receiptAlreadyUsed: false,
+ duplicateRetryCount: 1,
+ unreconciledPayment: false
+ },
+ settlementAuthority: {
+ kind: 'product-confirmed-cash-asset-hub-entitlement',
+ sourceUrl: 'https://docs.polkadotcommunity.foundation/future-cash-entitlement-proof'
+ },
+ ...patch
+ };
+}
+
+function reportFor(evidence, options = {}) {
+ return buildProductCashSettlementReport({
+ snapshot: staticSnapshot(),
+ evidence,
+ generatedAt: '2026-09-14T11:00:00.000Z',
+ ...options
+ });
+}
+
+function gate(report, id) {
+ return report.gates.find(item => item.id === id);
+}
+
+test('packageLockVersion reads exact pinned Product package versions', () => {
+ assert.equal(packageLockVersion(staticSnapshot().webPackageLock, '@parity/product-sdk-host'), '0.19.1');
+ assert.equal(packageLockVersion(staticSnapshot().webPackageLock, '@missing/pkg'), null);
+});
+
+test('local static gates pass while Product CASH remains externally blocked', () => {
+ const report = reportFor(null);
+
+ assert.equal(report.summary.status, 'blocked');
+ assert.equal(report.summary.failCount, 0);
+ assert.equal(gate(report, 'cash-authority').status, 'blocked');
+ assert.equal(gate(report, 'cash-authority').detail, PRODUCT_CASH_SETTLEMENT_EXTERNAL_DEPENDENCY);
+ assert.equal(
+ report.staticGates.every(item => item.status === 'pass'),
+ true
+ );
+});
+
+test('off-chain Host payment completion alone never grants Dotify access', () => {
+ const evidence = completeEvidence({ entitlement: undefined, finality: undefined, reconciliation: undefined, settlementAuthority: undefined });
+ const report = reportFor(evidence);
+
+ assert.equal(report.summary.status, 'blocked');
+ assert.equal(gate(report, 'host-payment:status').status, 'pass');
+ assert.equal(gate(report, 'entitlement').status, 'blocked');
+ assert.match(gate(report, 'entitlement').detail, /Host payment receipt alone is not access/);
+});
+
+test('valid but wrong Product payer mapping fails closed', () => {
+ const base = completeEvidence();
+ const report = reportFor(
+ completeEvidence({
+ quote: {
+ ...base.quote,
+ payerH160: WRONG_DERIVED_ADDRESS
+ },
+ finality: {
+ ...base.finality,
+ payerH160: WRONG_DERIVED_ADDRESS
+ },
+ entitlement: {
+ ...base.entitlement,
+ listenerAddress: WRONG_DERIVED_ADDRESS
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'quote:payer').status, 'fail');
+ assert.match(gate(report, 'quote:payer').detail, /derived 0x0e8ce681fb6d8aa53c5302e72b80f654141a0e65/);
+});
+
+test('wrong payer, recipient, asset, and chain evidence fails closed', () => {
+ const evidence = completeEvidence({
+ context: {
+ productId: 'other.dot',
+ assetHubChainId: 1,
+ peopleParaId: 1502,
+ cdmRegistry: '0x59b0245778917af55224e5f8fb55f7f8d452619f'
+ },
+ quote: {
+ ...completeEvidence().quote,
+ payerH160: 'not-an-address',
+ recipient: 'not-a-recipient',
+ cashDecimals: 18,
+ peopleAssetId: 2,
+ assetHubAssetId: 42
+ },
+ hostPayment: {
+ ...completeEvidence().hostPayment,
+ quoteId: 'different-quote',
+ destination: ADDRESS,
+ amountAtomic: '100'
+ },
+ finality: {
+ ...completeEvidence().finality,
+ peopleParaId: 999
+ }
+ });
+ const report = reportFor(evidence);
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'context:product-id').status, 'fail');
+ assert.equal(gate(report, 'context:chains').status, 'fail');
+ assert.equal(gate(report, 'quote:payer').status, 'fail');
+ assert.equal(gate(report, 'quote:recipient').status, 'fail');
+ assert.equal(gate(report, 'quote:asset').status, 'fail');
+ assert.equal(gate(report, 'host-payment:quote').status, 'fail');
+ assert.equal(gate(report, 'host-payment:amount-recipient').status, 'fail');
+ assert.equal(gate(report, 'finality:chain').status, 'fail');
+});
+
+test('a failed Host payment never becomes access even with claimed entitlement evidence', () => {
+ const evidence = completeEvidence({
+ hostPayment: {
+ ...completeEvidence().hostPayment,
+ status: 'Failed'
+ }
+ });
+ const report = reportFor(evidence);
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'host-payment:status').status, 'fail');
+});
+
+test('reused receipts and duplicate payment retries are rejected', () => {
+ const report = reportFor(
+ completeEvidence({
+ reconciliation: {
+ stableReceiptId: 'payment-001',
+ receiptAlreadyUsed: true,
+ duplicateRetryCount: 2,
+ unreconciledPayment: false
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'reconciliation:replay').status, 'fail');
+ assert.equal(gate(report, 'reconciliation:retry').status, 'fail');
+});
+
+test('a crash after payment but before entitlement stays unreconciled and blocked', () => {
+ const report = reportFor(
+ completeEvidence({
+ entitlement: undefined,
+ reconciliation: {
+ stableReceiptId: 'payment-001',
+ receiptAlreadyUsed: false,
+ duplicateRetryCount: 0,
+ unreconciledPayment: true
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'blocked');
+ assert.equal(gate(report, 'host-payment:status').status, 'pass');
+ assert.equal(gate(report, 'entitlement').status, 'blocked');
+ assert.equal(gate(report, 'reconciliation:unreconciled').status, 'blocked');
+});
+
+test('included or reorg-unsafe payments stay blocked before entitlement', () => {
+ const report = reportFor(
+ completeEvidence({
+ finality: {
+ peopleParaId: PRODUCT_CASH_TOPOLOGY.peopleParaId,
+ status: 'included',
+ blockHash: BLOCK_HASH,
+ reorgSafe: false
+ },
+ entitlement: undefined,
+ reconciliation: undefined,
+ settlementAuthority: undefined
+ })
+ );
+
+ assert.equal(report.summary.status, 'blocked');
+ assert.equal(gate(report, 'finality:chain').status, 'blocked');
+});
+
+test('an unrelated finalized People block cannot prove CASH finality', () => {
+ const report = reportFor(
+ completeEvidence({
+ finality: {
+ peopleParaId: PRODUCT_CASH_TOPOLOGY.peopleParaId,
+ status: 'finalized',
+ blockHash: BLOCK_HASH,
+ reorgSafe: true
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'finality:chain').status, 'pass');
+ assert.equal(gate(report, 'finality:payment-binding').status, 'fail');
+});
+
+test('wrong or repeated runtime entitlement fails instead of issuing access', () => {
+ const report = reportFor(
+ completeEvidence({
+ entitlement: {
+ status: 'verified',
+ runtimeAddress: RUNTIME,
+ contentHash: CONTENT_HASH,
+ listenerAddress: RECIPIENT,
+ hasPaid: true,
+ canAccess: true,
+ finalized: true,
+ transactionHash: ENTITLEMENT_TX_HASH,
+ issuanceCount: 2
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'entitlement:binding').status, 'fail');
+ assert.equal(gate(report, 'entitlement:once').status, 'fail');
+});
+
+test('pre-existing access cannot satisfy a later CASH receipt', () => {
+ const base = completeEvidence();
+ const report = reportFor(
+ completeEvidence({
+ entitlement: {
+ status: 'verified',
+ runtimeAddress: base.quote.runtimeAddress,
+ contentHash: base.quote.contentHash,
+ listenerAddress: base.quote.payerH160,
+ hasPaid: true,
+ canAccess: true,
+ finalized: true,
+ transactionHash: ENTITLEMENT_TX_HASH,
+ blockHash: ENTITLEMENT_BLOCK_HASH,
+ issuanceCount: 1,
+ issuedAt: '2026-09-14T10:03:00.000Z'
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'entitlement:state').status, 'pass');
+ assert.equal(gate(report, 'entitlement:binding').status, 'pass');
+ assert.equal(gate(report, 'entitlement:cash-receipt').status, 'fail');
+});
+
+test('trusted relay evidence is explicitly refused', () => {
+ const report = reportFor(
+ completeEvidence({
+ settlementAuthority: {
+ kind: 'trusted-relay',
+ sourceUrl: 'https://example.invalid/relay'
+ }
+ })
+ );
+
+ assert.equal(report.summary.status, 'fail');
+ assert.equal(gate(report, 'cash-authority').status, 'fail');
+ assert.match(gate(report, 'cash-authority').detail, /Refusing trusted-relay/);
+});
+
+test('a perfectly-shaped future bridge still stays blocked in this build', () => {
+ const report = reportFor(completeEvidence());
+
+ assert.equal(report.summary.status, 'blocked');
+ assert.equal(gate(report, 'cash-authority').status, 'blocked');
+ assert.match(gate(report, 'cash-authority').detail, /does not enable a supported Product CASH bridge/);
+});
diff --git a/web/src/features/payments/paymentModel.ts b/web/src/features/payments/paymentModel.ts
index 63710ae6..b662e5fd 100644
--- a/web/src/features/payments/paymentModel.ts
+++ b/web/src/features/payments/paymentModel.ts
@@ -112,5 +112,5 @@ export function createUnsupportedCashAccessPaymentIntent(input: {
}
export function cashSettlementUnavailableReason(): string {
- return 'Product CASH settlement is not executable yet: CASH lives on People chain while Dotify runtime entitlements live on Asset Hub, and the receipt/bridge model still needs Product confirmation.';
+ return 'Product CASH settlement is not executable yet: CASH is spent through Coinage on People chain while Dotify runtime entitlements live on Asset Hub, and no Product-confirmed proof currently binds the payer, CASH asset, recipient, finality, and runtime access grant.';
}