Wire the Product CDM manifest and contract resolver - #113
Merged
Conversation
The Host signRaw wire format is not pinned by the SDK: HostSignPayloadResponse carries an untagged signature, and a Substrate host may sign a raw payload verbatim or inside the conventional <Bytes> envelope. Verification assumed one shape, so a wrong guess would have failed every Product key request with an error indistinguishable from a wrong signer. Accept a bounded set instead: the canonical message verbatim or <Bytes>-wrapped, and a bare 64-byte or MultiSignature-tagged 65-byte sr25519 signature. Every variant carries the identical domain-bound message, so this adds no replay, cross-app, cross-chain, or cross-track surface; a non-sr25519 tag still fails closed. Route schemas widen to 128 or 130 hex so the tag is checked by the verifier rather than rejected before it. Reject EVM-derived account ids for product-sr25519-v1. A 20-byte H160 padded with 0xee derives back to the H160 it contains, so accepting that shape let a caller name any paying EVM listener as the requester and rested the boundary on the curve check alone. A real Product account is a native AccountId32. A key that parses and derives to the requester but verifies under no variant now returns PRODUCT_SIGNATURE_REJECTED, kept distinct from SIGNATURE_INVALID so an envelope problem is separable from a wrong-account problem in logs. Pin @scure/sr25519 exactly, matching @noble/hashes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wiring the Product key signatures changed the behaviour but three places still described the old one. The wallet modal told Product-host users that protected playback required an EVM signer, the runbook asked operators to confirm no key is released through the Product identity, and the architecture matrix said the shipped UI used EIP-191 or a session token - contradicted by its own prose two sections later. All three now describe what ships: a connected Product account requests protected keys through product-sr25519-v1, and paid access plus artist publishing remain on the EVM signer. This matters beyond tidiness - the stale runbook step would have had an operator sign off on a denial as correct behaviour, hiding a real signing failure. Record the signing envelope decision and the EVM-derived key rejection, and turn the runbook step into an evidence capture that names which envelope the live host actually produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Track and split counts come from contract storage and the directory enumerates
runtimes Dotify does not control, so Array.from({ length: Number(count) })
allocated before anything could reject a malformed or hostile value. Both
adapters now validate counts first and throw rather than truncate, since a
silent cap would present a partial catalog as complete. The catalog loader
already isolates per-runtime failures, so one bad runtime degrades to a missing
artist.
Mark the two unverified spots in the CDM adapter that must be settled before it
can be selected: waitForTransaction returns immediately where the viem writer
awaits a receipt, and the payForAccess value-transfer shape is inferred rather
than confirmed against generated contract types.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dotify has no CDM-registered packages and no `cdm install`, but it does not need them. Its Solidity contracts are deployed through Asset Hub's eth-rpc, which is a compatibility layer over pallet-revive - the same pallet the Product SDK contract helpers target - so the deployed H160 addresses are already reachable without PolkaVM recompilation or a registry entry. CdmJsonContract needs only version, address, and abi for getContract(), and `new ContractManager(...)` is documented as snapshot-only. The generator emits exactly that snapshot from the same Hardhat artifacts the viem bindings come from, so the two adapters cannot disagree about an ABI. Artist runtimes are deliberately absent from the manifest: a diamond is deployed per artist, so its address is known at call time, not build time, and a placeholder would misrepresent the deployment. Their merged facet ABI is emitted separately and bound to an address by createContract. The generator lives in web/ because it needs the SDK's codegen, which is a frontend dependency; adding the Product SDK tree to contracts/evm just to emit types would be a worse trade. Unnamed Solidity getter params are named positionally for codegen only - generateContractTypes interpolates the name into a tuple label and emits `args: [: HexString]`, which does not parse. The manifest ABI stays byte-faithful to the artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the contract resolver the CDM adapter was missing. Fixed-address contracts resolve from the generated manifest through ContractManager; per-artist runtime diamonds bind the merged facet ABI to their call-time address through createContract. Two SDK constraints shape this. First, createChainClient routes exclusively through the Product host provider with no direct-WebSocket fallback, so Product mode cannot work in a standalone build - validateProductionEnvironment now rejects product-cdm unless the host mode is enabled. Second, the host decides which chain an environment resolves to, and Dotify's runtimes live on Polkadot Hub TestNet, reached through the `paseo` preset rather than `devnet`. verifyDeployment queries the directory before any catalog read so a wrong-chain connection fails with a named error instead of looking like artists with no releases. Selection is build-time rather than runtime, for two reasons. Switching the authority for access policy is a deployment decision made with evidence, not something a page should flip. And Vite inlines the value, so a viem build tree-shakes the whole Product graph away: 4.4 MB against 10 MB when opted in. The difference is @parity/product-sdk-descriptors, whose shared descriptors module references every chain's metadata - only one chunk is ever fetched, but all are published, and Bulletin storage is a finite quota. Both shipped builds stay at 4.4 MB. Reads only. Writes stay on the viem signer path in every mode, because routing a payment or a publication through a signer with no host transaction evidence is not a reasonable default. A failed Product setup rejects every read rather than falling back to viem: the adapter in use must never be ambiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blocking unknown for Product contract mode is no longer the manifest or generated types - both now exist and are wired. It is whether the Product host serves a chain that holds Dotify's runtimes, since the host controls that mapping and the contracts are on Polkadot Hub TestNet rather than Product DevNet Asset Hub. Also records the measured build-size trade-off, so an operator weighs it against the Bulletin quota before enabling product-cdm for a .dot deployment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous default was wrong in a way that would have produced an empty catalog. Product DevNet is not a separate network: it is a preset over the Paseo system parachains - Asset Hub (1000), People (1004), Bulletin (1010) - at EVM chain 420420417. That is exactly where Dotify is already deployed. Verified read-only against both endpoints for the ArtistDirectory at 0xcf1534c6e2b0e43b9436c1e86a076466dc0f2108: eth-rpc-testnet.polkadot.io and paseo-assethub-rpc.laissez-faire.trade both report chain id 0x190f1b41, blocks one apart, and byte-identical contract code. They are two providers for one chain, so no contract redeploy is needed to port Dotify to DevNet. The SDK's `paseo` preset is the trap: it targets Paseo Next (Asset Hub Next 1500 / People Next 1502), which the Product docs call a different network where "funds sent there will not appear on this Devnet". Dotify has no deployment there, so ProductChainEnvironment now admits only `devnet` - selecting a chain that cannot hold the catalog is a bug, not a configuration option. Also realigns the environment reference tables, clearing the markdownlint MD060 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Jul 29, 2026
knzeng-e
marked this pull request as ready for review
August 3, 2026 13:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Outcome
The Product CDM runtime adapter is now backed by a real contract resolver over
a generated
cdm.jsonsnapshot, selectable withVITE_DOTIFY_RUNTIME_ADAPTER=product-cdm. It stays off by default. Catalogreads can route through Product SDK contract handles; contract writes stay on
the viem signer path.
This is the Phase 4 "wire a generated CDM manifest/types into Product mode"
item from
docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md(#85). Stacked on #112.
Issue and context
The previous branch left
ProductCdmRuntimeAdapterunreachable: it knew how tomap Dotify's runtime surface onto Product contract handles, but nothing could
produce those handles.
createProductCdmRuntimeContractResolverdocumented thegap explicitly.
The roadmap framed the blocker as needing CDM-installed packages. That turned
out to be wrong, and the correction is the main design decision here.
Architecture and key concepts
A generated snapshot manifest, not
cdm install. Dotify's Soliditycontracts are deployed through Asset Hub's
eth-rpc, which is a compatibilitylayer over
pallet-revive- the same pallet the Product SDK contract helperstarget. The deployed H160 addresses are therefore already reachable through
@parity/product-sdk-contractswith no PolkaVM recompilation and no registryentry.
CdmJsonContractneeds onlyversion,address, andabiforgetContract(), andnew ContractManager(...)is documented as snapshot-only.web/scripts/generate-cdm-manifest.mjsemits that snapshot from the sameHardhat artifacts the viem bindings come from, so the two adapters cannot
disagree about an ABI.
cdm.json@dotify/artist-directory,@dotify/artist-runtime-factoryat theirdeployments.jsonaddressessmartRuntime.tscdm.d.tsContractsmodule augmentation for typed handlesArtist runtimes are deliberately absent from the manifest: a diamond is deployed
per artist, so its address is known at call time, not build time. A placeholder
address would misrepresent the deployment.
Design decisions and tradeoffs
Build-time selection, not runtime. Two reasons. Switching the authority for
access policy is a deployment decision an operator makes with evidence, not
something a page should be able to flip. And Vite inlines the value, so a
viembuild tree-shakes the entire Product contract graph away:viemproduct-cdmThe 5.6 MB difference is
@parity/product-sdk-descriptors, whose shareddescriptors module references every chain's metadata. Only one chunk is fetched
at runtime, but all are published, and Bulletin storage is a finite quota.
Both shipped builds stay at 4.4 MB.
Reads only.
RuntimeWritePorthas no equivalent provider on purpose.Routing a payment or a publication through a signer with no host transaction
evidence is not a reasonable default.
No fallback on failure. A failed Product setup rejects every read with that
error rather than degrading to viem. The adapter in use must never be
ambiguous, because the artist runtime is the authority on access policy.
A lazy facade instead of call-site churn. Every
RuntimeReadPortmethodalready returns a promise, so the Product adapter's async setup hides behind a
facade rather than turning ~16 call sites into double-awaits.
Two SDK constraints that shaped this
createChainClientroutes exclusively through the Product host providerand throws when none is present - there is no direct-WebSocket fallback.
Product mode is therefore impossible in a standalone build, and
validateProductionEnvironmentnow rejectsproduct-cdmunlessVITE_DOTIFY_HOST_MODEis enabled.The host decides which chain an environment resolves to, and only
devnetis correct - see the follow-up commit below. A wrong environmentresolves every manifest address to an account with no code, which reads as
artists with no releases.
verifyDeployment()queriesartistCountbeforeany catalog read and fails closed with a named error.
createChainClientis used rather than the zero-configgetChainAPIbecausethe preset table references every environment's assetHub, bulletin, and
individuality descriptors.
Corrections to the previous branch's notes
Both "unverified" markers I added on #112 were resolved by reading the SDK
types, and the code was already right:
payForAccess-TxOptionscarriesvalue?: bigintand methods takepositional args followed by an options object, so
[contentHash, { value }]is correct.
waitForTransaction-.tx()resolves at best-block by default andTxResultcarries the including block, sotxContracthas already awaitedinclusion. The no-op is safe by construction, not an omission. That is the
same point viem's
waitForTransactionReceiptresolves at.The adapter's structural type mirror also matches the real SDK exactly:
Resultis{ok,value}|{ok,error}andQueryResultis{success,value,gasRequired}.Separately, Polkadot Hub TestNet is live again - head at block 11,544,296,
past the 10,612,201 freeze, with the ArtistDirectory still returning code. The
existing
deployments.jsondeployment survived; no redeploy was needed.Review guide
web/scripts/generate-cdm-manifest.mjs- manifest shape, facet mergecollision check, why unnamed params are renamed for codegen only.
web/src/features/runtime/productCdmContracts.ts- the two resolution pathsand
verifyDeployment.web/src/features/runtime/runtimeReaderProvider.ts- the build-time gate andthe no-fallback property.
web/src/features/runtime/runtimeAdapterConfig.ts+deploymentSafety.ts- fail-closed selection and the host-mode requirement.Verify carefully
viembuild still contains no Product contract or descriptor chunks.product-cdmwithout a host mode is rejected at build time.viemrather than disabling reads.cdm.jsonaddresses matchdeployments.json.Validation
cd web && npm run test:unitmain, covering adapter selection, the resolver, the build-time gate, and the no-fallback property)cd web && npm run lintApp.tsx/ArtistShell.tsxwarnings remaincd web && npm run buildcd web && npm run build:product-devnetVITE_DOTIFY_RUNTIME_ADAPTER=product-cdm npm run build:product-devnetcd web && npm run smoke:production-envcd services/api && npm run typecheck && npm testcd contracts/evm && npm run fmt:checknode scripts/backlog-sync.mjs --check --offlinegit diff --checkChain liveness confirmed read-only against
eth-rpc-testnet.polkadot.io:eth_blockNumber= 11544296,eth_chainId= 420420417, andeth_getCodeonthe directory returns bytecode.
Known limitations and follow-ups
Not smoke-tested against a real Product host. Every Product path here is
exercised through injected dependencies, not a live container. The resolver
cannot be run outside a host at all, by SDK design.
The chain question is settled - and the answer removed a blocker. Product
DevNet is not a separate network: it is a preset over the Paseo system
parachains (Asset Hub 1000, People 1004, Bulletin 1010) at EVM chain
420420417, which is exactly where Dotify is already deployed. Verifiedread-only:
eth-rpc-testnet.polkadot.ioandpaseo-assethub-rpc.laissez-faire.tradereturn the same chain id, blocks oneapart, and byte-identical ArtistDirectory code. No contract redeploy is
needed to port Dotify to DevNet.
The correction is that the SDK's
paseopreset targets Paseo Next (Asset HubNext 1500 / People Next 1502) - a different network per the Product docs. The
default has been changed to
devnetandpaseois no longer selectable.The only gate left for Product contract mode is
pallet-reviveaccount mappingplus real host-signed transaction evidence, before writes can move off the EVM
wallet path.
npm run generate:cdmmust be re-run after any contract redeploy, orcdm.jsonaddresses go stale. This is not yet enforced by CI the way
generate:abisis.Follow-up:
9246537corrects the chain presetReview round on this branch found the default
productEnvironmentwas wrong,in a way that would have produced an empty catalog rather than an error at
build time.
Product DevNet is a preset, not a network. It targets the Paseo system
parachains - Asset Hub (1000), People (1004), Bulletin (1010) - at EVM chain
420420417. Verified read-only on 2026-07-29 against the ArtistDirectory at0xcf1534c6e2b0e43b9436c1e86a076466dc0f2108:eth_chainIdhttps://eth-rpc-testnet.polkadot.io/0x190f1b4136707b24…https://paseo-assethub-rpc.laissez-faire.trade0x190f1b4136707b24…Two providers, one chain. Dotify's
deployments.jsonaddresses are alreadyDevNet addresses; no redeploy is required.
The SDK's
paseopreset is Paseo Next v2 (Asset Hub Next 1500 / PeopleNext 1502). The Product documentation states those "belong to a different
network" and "funds sent there will not appear on this Devnet". Dotify has no
deployment there, so
ProductChainEnvironmentnow admits onlydevnetand theconfig guard refuses anything else - a chain that cannot hold the catalog is
not a configuration option.
Also realigns the environment reference tables, clearing three markdownlint
MD060 warnings.
Re-verified after the correction: 216 web tests, 92 API tests, 0 lint errors,
both shipped builds still 4.4 MB, backlog sync clean.