feat: verify Product CDM unlock read-back - #127
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d319103e97
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const [hasPaid, canAccess] = await Promise.all([ | ||
| input.reader.hasPaid(input.intent.runtimeAddress, input.intent.contentHash, input.listenerAddress), | ||
| input.reader.canAccess(input.intent.runtimeAddress, input.intent.contentHash, input.listenerAddress) | ||
| ]); |
There was a problem hiding this comment.
Retry read-back against the included chain state
In a product-cdm build, the reader and writer are backed by independently created Product chain clients (runtimeReaderProvider.ts:41 and runtimeWriterProvider.ts:139), while this helper performs only one immediate read after the writer reports best-block inclusion. If the reader is still on the preceding head—or either query fails transiently—a successfully charged first-time unlock is reported as failed; retrying the payment then reverts as already paid. Poll against the included state with a bound before failing closed.
AGENTS.md reference: AGENTS.md:L69-L70
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed. The Product CDM post-inclusion read-back now uses bounded polling via verifyRuntimeAccessPayment() before failing closed, because the Product writer and reader can observe different heads. The smoke event now includes attempts plus the last observed read-back/error.
| if (readbackError) { | ||
| throw new Error( | ||
| `Product CDM payment smoke failed: ${readbackError} Keep Product writes disabled until native value forwarding and account mapping are verified in the Product host.` | ||
| ); |
There was a problem hiding this comment.
Preserve the confirmed transaction in read-back errors
When either post-inclusion value is false, this throw reaches the existing catch, which replaces the feedback with title: 'Payment failed' and drops txHash. At this point waitForTransaction has already completed and the payment may have been distributed—for example, hasPaid can be true while current access is denied—so the message misstates the financial outcome and hides the receipt users need to verify it. Report an included-payment/access-verification failure separately and retain the hash.
AGENTS.md reference: AGENTS.md:L69-L71
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed. The payment flow now separates pre-inclusion payment failure from post-inclusion access-verification failure. If a transaction was included but Product CDM read-back cannot verify access, the UI reports Payment included, access not verified and preserves the included txHash as the user receipt.
d319103 to
e069b4f
Compare
Outcome
This PR makes the opt-in Product CDM Classic unlock smoke test falsifiable. In a
product-cdmbuild, Dotify no longer marks a paid Classic track as open immediately after the Product host transaction path returns a hash. It waits for inclusion, then polls the runtime read-back for the same Product-derived H160 account and requires bothmusicAccHasPaid(contentHash, listenerH160)andmusicAccCanAccess(contentHash, listenerH160)to betrue.After review, the post-inclusion path now treats Product read lag as expected: it polls with a bound before failing closed. If a payment was already included but access cannot be verified, the UI reports Payment included, access not verified and keeps the transaction hash visible as the user's receipt instead of collapsing the result into a generic Payment failed state.
Issue and context
Refs #85.
This PR now targets
devdirectly after #126 merged. #126 made Product CDM writes fail closed before submission when the Product host signer does not match the connected Dotify Product identity. The next risk is a false positive after submission: a host transaction could be included or return a hash, but native value forwarding, account mapping, read-head freshness, or access-state mutation could still be wrong.That is a money/access boundary. Dotify should not show Work opened, request the protected key, or treat Product writes as ready unless the runtime itself confirms the entitlement for the same H160 account.
Local scope document:
docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md.Architecture and key concepts
The existing runtime port boundary stays intact:
The read-back is Product-CDM-only because
viemremains the production default and already has the current e2e evidence.product-cdmis explicitly experimental, so it should be stricter: transaction inclusion is necessary, but not sufficient, for a Classic unlock smoke pass.How it works
paymentReadback.tsadds a typed verification helper that readshasPaidandcanAccessfor an executable runtime-native payment intent and the exact listener H160 account. The helper retries briefly because the Product writer and reader use separate host chain clients, so a writer can observe inclusion before the reader observes the same runtime head.useCatalog.payForTrackAccess()now adds one Product CDM step after transaction inclusion:verifyRuntimeAccessPayment()with bounded read-back polling;dotify:product-cdm-payment-smokewithtxHash,runtimeAddress,contentHash,listenerAddress,hasPaid,canAccess,attempts,ok, anderror;Review comments addressed
discussion_r3887991845: implemented bounded Product read-back polling after inclusion so a charged first-time unlock is not failed just because the Product read client is one head behind the writer.discussion_r3887991848: separated included-payment/access-verification failure from pre-inclusion payment failure, keepingtxHashin the feedback when a transaction was included but read-back evidence failed.Design decisions and tradeoffs
I kept the verification in the catalog/payment flow rather than adding another runtime write method because the runtime port already has the required read and write primitives. This avoids turning Product smoke evidence into a second transaction API.
I did not change the default adapter. Product CDM remains opt-in behind
VITE_DOTIFY_RUNTIME_ADAPTER=product-cdmuntil a real Product-host run proves native value forwarding, approval UX, and post-payment read-back.I did not upgrade Product SDK packages in this PR. npm was checked again on 2026-08-30:
@parity/product-sdkis now0.25.0, host0.18.0, statement-store0.6.7, descriptors0.11.0, andpolkadot-api3.0.0. Dotify stays pinned on the current SDK set here because this PR changes smoke semantics, not the dependency graph. The upgrade belongs in a dedicated compatibility PR.Security, failure, and operations
The runtime remains authoritative. Product CDM payment success now requires the runtime to confirm the entitlement after inclusion. Ambiguous, lagging, or inconsistent read-back fails closed and keeps the shipped Product profile on
viem.No Netlify/Fly/Product env variable changes are required. The operational smoke checklist now asks testers to capture the
dotify:product-cdm-payment-smokebrowser event, including the number of read-back attempts, when running a Product CDM write build.Review guide
Suggested order
web/src/features/payments/paymentReadback.ts- verify bounded polling and failure results preserve enough evidence without mutating UI state.web/src/hooks/useCatalog.ts- check the Product-CDM-only branch afterwaitForTransaction, especially the ordering before local unlock state is set and the included-payment failure copy.web/src/features/payments/paymentReadback.test.ts- confirm successful, lagging, transient-error, missing-grant, and inconsistent-state cases are covered.docs/operations/product-devnet-deployment.md- verify the manual Product host smoke instructions now match the app behavior.Verify carefully
product-cdmbuild cannot show Work opened whenhasPaidorcanAccessis false after transaction inclusion.runtimeAddress,contentHash, and listener H160 from the payment intent/account.viemClassic unlock behavior is unchanged.Validation
npm view @parity/product-sdk version && npm view @parity/product-sdk-host version && npm view @parity/product-sdk-statement-store version && npm view @parity/product-sdk-descriptors version && npm view @polkadot-community-foundation/polkadot-app-deploy version && npm view polkadot-api versionnpm run test:unit -- src/features/payments/paymentReadback.test.ts src/features/runtime/runtimeWriterProvider.test.ts src/features/runtime/productCdmRuntimeAdapter.test.ts src/features/payments/paymentModel.test.tsnpm run test:unitnpm run buildnpm run build:product-devnetnpm run lintApp.tsxandArtistShell.tsx.npm run test:e2e -- classic-unlock.spec.tsgit diff --checkKnown limitations and follow-ups
This PR still does not close #85. Remaining evidence:
pricePlanckvalue reachesmusicRoyPayAccess;dotify:product-cdm-payment-smokeevent;0.25.xcompatibility PR after this smoke path is stable;Metadata checklist
Dotify sprints)