diff --git a/.clinerules b/.clinerules index 34cf2a8..f55f912 100644 --- a/.clinerules +++ b/.clinerules @@ -2,5 +2,5 @@ Feedback Board is a decentralized sticky-note board on Polkadot — notes are stored on Bulletin and indexed by a shared Asset Hub contract, so every remix sees the same board. (React + Vite + TypeScript). Full agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes. -- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. +- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. - No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c100297..f2bc0ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,5 +2,5 @@ Feedback Board is a decentralized sticky-note board on Polkadot — notes are stored on Bulletin and indexed by a shared Asset Hub contract, so every remix sees the same board. (React + Vite + TypeScript). Full AI agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes. -- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. +- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. - No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC. diff --git a/.windsurfrules b/.windsurfrules index 34cf2a8..f55f912 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -2,5 +2,5 @@ Feedback Board is a decentralized sticky-note board on Polkadot — notes are stored on Bulletin and indexed by a shared Asset Hub contract, so every remix sees the same board. (React + Vite + TypeScript). Full agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes. -- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. +- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded. - No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4188c87..4e214b4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -167,6 +167,32 @@ cdm deploy -n paseo (This repo also ships `npm run deploy`, which runs the same command with the network endpoints pinned explicitly. Either works.) +> **Which network is which:** the CDM `paseo` preset targets **Paseo Next v2** +> (para 1500, a preview network), not the public Paseo testnet. To target +> **devnet** — the public products devnet on the Paseo testnet Asset Hub +> (para 1000, community-operated CDM registry) — use `cdm deploy -n devnet` +> (or `npm run deploy:devnet`; endpoints and registry come from the preset), +> and build the frontend with `VITE_NETWORK=devnet` so it connects to devnet +> and resolves the contract from the devnet registry. +> +> Deploying to devnet, the whole flow uses `-n devnet` in place of `-n paseo` +> (`cdm account map -n devnet`, `cdm account bal -n devnet`, `cdm i -n devnet`), +> and the PAS faucet is the **para-1000** one: +> . The `-n devnet` preset needs +> **CDM v0.9.0+** (`cdm --version`); older CLIs report `unknown preset`. +> +> **Deploy the contract to devnet before shipping a `VITE_NETWORK=devnet` +> build.** The frontend resolves the contract address from the devnet registry +> at startup and there is no `cdm.json` fallback, so a devnet build pointed at +> a network where `@example/feedback` isn't registered fails at contract init +> with a resolution error. +> +> `cdm.json` holds one network's registry, address, version and ABI, and +> `cdm deploy` rewrites them for whichever network you pass. Deploy to a single +> target and commit that manifest; the frontend passes the selected network's +> registry explicitly and re-resolves addresses live, so it tolerates a +> manifest committed for the other network without a code change. + *What's happening, in order:* (1) rebuilds if needed, (2) deploys the bytecode to Paseo Asset Hub via pallet-revive, (3) publishes the contract metadata to Bulletin, (4) registers `@/feedback → (address, @@ -290,7 +316,7 @@ All of these were hit for real while writing this guide. | Symptom | Cause / fix | |---|---| | Build fails asking for `rustup component add rust-src` | run exactly that, then retry `cdm build` | -| `AccountUnmapped` on deploy or contract call | run `cdm account map -n paseo` (needs a funded account) | +| `AccountUnmapped` on deploy or contract call | run `cdm account map -n ` for the network you deploy to (`-n paseo` for paseo-next, `-n devnet` for devnet; needs a funded account) | | `store data: InvalidTxError {"Invalid":{"Payment"}}` at the end of `cdm deploy` | no Bulletin storage allowance; use the Bulletin faucet from step 2, then re-run the deploy | | Deploy fails with a registry/name conflict | the package name in `Cargo.toml` still belongs to someone else; see step 3 | | `cdm deploy` looks finished but doesn't exit | it's finalizing the registry update; give it a couple of minutes. Don't kill it: an interrupted run leaves the registry on your previous version | diff --git a/package.json b/package.json index 484b6d5..aec92d2 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,18 @@ "build:frontend": "tsc -b && vite build", "build:contracts": "cdm build", "deploy": "cdm deploy -n paseo --registry-address 0xf62c2ece29cd8df2e10040ecfa5a894a5c5d9cb0 --assethub-url wss://paseo-asset-hub-next-rpc.polkadot.io --bulletin-url wss://paseo-bulletin-next-rpc.polkadot.io", + "deploy:devnet": "cdm deploy -n devnet", "preview": "vite preview" }, "dependencies": { "@noble/hashes": "^2.2.0", - "@novasamatech/host-api": "^0.8.10", - "@novasamatech/host-api-wrapper": "^0.8.10", - "@parity/product-sdk": "^0.15.1", "@parity/product-sdk-address": "^0.1.1", - "@parity/product-sdk-chain-client": "^0.7.5", - "@parity/product-sdk-contracts": "^0.8.1", - "@parity/product-sdk-descriptors": "^0.6.2", - "@parity/product-sdk-signer": "^0.8.2", - "@parity/product-sdk-tx": "^0.2.15", + "@parity/product-sdk-chain-client": "^0.9.1", + "@parity/product-sdk-contracts": "^0.9.2", + "@parity/product-sdk-descriptors": "^0.8.0", + "@parity/product-sdk-host": "^0.14.1", + "@parity/product-sdk-signer": "^0.11.1", + "@parity/product-sdk-tx": "^0.3.2", "multiformats": "^13.4.2", "polkadot-api": "^2.1.6", "react": "^19.0.0", @@ -36,6 +35,7 @@ "vite": "^8.0.16" }, "overrides": { - "@polkadot-api/json-rpc-provider": "0.2.0" + "@polkadot-api/json-rpc-provider": "0.2.0", + "ws": "^8.21.0" } } diff --git a/src/utils.ts b/src/utils.ts index d206271..58c9dd2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { - preimageManager, + getPreimageManager, requestPermission, -} from "@novasamatech/host-api-wrapper"; +} from "@parity/product-sdk-host"; import { SignerManager, HostProvider, @@ -17,13 +17,92 @@ import { createContractRuntimeFromClient, ensureContractAccountMapped, } from "@parity/product-sdk-contracts"; -import { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; +import type { devnet_asset_hub } from "@parity/product-sdk-descriptors/devnet-asset-hub"; +import type { paseo_asset_hub } from "@parity/product-sdk-descriptors/paseo-asset-hub"; import type { PolkadotClient, PolkadotSigner } from "polkadot-api"; import { blake2b } from "@noble/hashes/blake2.js"; import { CID } from "multiformats/cid"; import * as raw from "multiformats/codecs/raw"; import type { MultihashDigest } from "multiformats/hashes/interface"; +/** + * Unwrap a product-sdk `Result` to its value, re-throwing the `err` channel as + * an `Error`. Since product-sdk 0.18 fallible calls return `Result` instead of + * throwing; this bridges them back onto throw / try-catch control flow. Mirrors + * the CLI's `unwrapResult` (playground-cli #470). + */ +export function unwrapResult( + result: { ok: true; value: T } | { ok: false; error: unknown }, +): T { + if (!result.ok) { + throw result.error instanceof Error ? result.error : new Error(String(result.error)); + } + return result.value; +} + +// --------------------------------------------------------------------------- +// Networks. "paseo-next" is the Paseo Next v2 preview network (para 1500) — +// that's what the CDM `paseo` preset targets, NOT the public Paseo testnet. +// "devnet" is the public products devnet on the Paseo testnet Asset Hub +// (para 1000), with the community-operated CDM registry (reference: +// contract-dependency-manager PR #61). Build with VITE_NETWORK=devnet to +// select it; the default stays paseo-next. Descriptors load dynamically so +// each build ships a single metadata chunk; chain identity comes from the +// descriptor (host-routed chain client, no endpoints or genesis to pin). +// --------------------------------------------------------------------------- + +type AssetHubDescriptor = typeof devnet_asset_hub | typeof paseo_asset_hub; + +interface NetworkConfig { + label: string; + /** IPFS gateways used to read Bulletin content, most specific first. */ + gateways: readonly string[]; + /** CDM ContractRegistry address the contract resolves against. */ + registry: string; + loadDescriptor(): Promise; +} + +// `import.meta.env.VITE_NETWORK` is inlined as a literal at build time, so this +// comparison folds and the unselected network's ~880 kB metadata chunk is +// dropped from the bundle. Keep it a direct literal comparison — routing the +// choice through a lookup table or a normalizing helper leaves both import()s +// reachable, so the build emits both metadata chunks. +export const NETWORK: NetworkConfig = + import.meta.env.VITE_NETWORK === "devnet" + ? { + label: "Devnet (Paseo testnet)", + // Bulletin Paseo has no dedicated HTTP gateway; read via public IPFS gateways. + gateways: [ + "https://ipfs.io/ipfs/", + "https://dweb.link/ipfs/", + "https://nftstorage.link/ipfs/", + ], + registry: "0x59b0245778917af55224e5f8fb55f7f8d452619f", + loadDescriptor: async () => + (await import("@parity/product-sdk-descriptors/devnet-asset-hub")).devnet_asset_hub, + } + : { + label: "Paseo Next", + gateways: [ + "https://paseo-bulletin-next-ipfs.polkadot.io/ipfs/", + "https://dweb.link/ipfs/", + "https://ipfs.io/ipfs/", + "https://nftstorage.link/ipfs/", + ], + registry: "0xf62c2ece29cd8df2e10040ecfa5a894a5c5d9cb0", + loadDescriptor: async () => + (await import("@parity/product-sdk-descriptors/paseo-asset-hub")).paseo_asset_hub, + }; + +// Dev only: a typo'd VITE_NETWORK silently falls back to paseo-next, so say so +// while developing. Kept out of the selection above to preserve the fold. +if (import.meta.env.DEV) { + const configured = import.meta.env.VITE_NETWORK; + if (configured && configured !== "devnet" && configured !== "paseo" && configured !== "paseo-next") { + console.warn(`[Network] Unknown VITE_NETWORK "${configured}", falling back to paseo-next`); + } +} + // --------------------------------------------------------------------------- // Permissions (RFC-0002) // --------------------------------------------------------------------------- @@ -34,11 +113,11 @@ async function ensurePermission(tag: "ChainSubmit" | "PreimageSubmit" | "Stateme if (_grantedPermissions.has(tag)) return; try { const result = await requestPermission({ tag, value: undefined }); - if (result.isOk() && result.value) { + if (result.ok && result.value) { _grantedPermissions.add(tag); console.log(`[Permission] ${tag} granted`); } else { - console.warn(`[Permission] ${tag} denied`, result.isErr() ? result.error : "user rejected"); + console.warn(`[Permission] ${tag} denied`, result.ok ? "user rejected" : result.error); } } catch (err) { console.warn(`[Permission] ${tag} request failed:`, err); @@ -226,6 +305,10 @@ export async function uploadToBulletin(_account: AppAccount, bytes: Uint8Array): await ensurePermission("PreimageSubmit"); const cid = calculateCID(bytes); console.log("[Bulletin] Submitting preimage via host, size:", bytes.length, "expected CID:", cid); + const preimageManager = await getPreimageManager(); + if (!preimageManager) { + throw new Error("Preimage manager unavailable — open this app inside a Polkadot host."); + } await preimageManager.submit(bytes); console.log("[Bulletin] Preimage stored."); return cid; @@ -329,12 +412,13 @@ async function ensureContractsReady(): Promise { // so the host never prompts "Allow Access to Web Domains" for a raw RPC // endpoint, and the chain identity comes from the descriptor — no // hardcoded genesis. + const descriptor = await NETWORK.loadDescriptor(); const chainClient = await createChainClient({ - chains: { assetHub: paseo_asset_hub }, + chains: { assetHub: descriptor }, }); const client = chainClient.raw.assetHub; _polkadotClient = client; - console.log("[CDM] Asset Hub chain client ready (host-routed)"); + console.log(`[CDM] Asset Hub chain client ready (host-routed, ${NETWORK.label})`); console.log("[CDM] Waking Asset Hub chain follow..."); await client.getChainSpecData(); @@ -353,23 +437,29 @@ async function ensureContractsReady(): Promise { // and pallet-revive dry-run-fails that call with `Revive::AccountUnmapped` // when the query origin isn't mapped. Build a plain runtime (no registry // query) to perform the mapping first. - const initRuntime = createContractRuntimeFromClient(client, paseo_asset_hub); + const initRuntime = createContractRuntimeFromClient(client, descriptor); await mapAccountWithRuntime(initRuntime, _state.account); // `fromLiveClient` resolves the deployed contract address from the live // CDM registry on each init instead of trusting the snapshot baked into // cdm.json — a redeploy is picked up without shipping a new cdm.json. - _contractManager = await ContractManager.fromLiveClient( + // Since product-sdk 0.18, fromLiveClient returns a Result instead of + // throwing on resolution failure. The registry address comes from the + // selected network, so devnet resolves against the devnet registry. + const live = await ContractManager.fromLiveClient( _cdmJson, client, - paseo_asset_hub, + descriptor, { defaultOrigin: _state.account.address as never, defaultSigner: _state.account.signer, + registryAddress: NETWORK.registry as never, registryOrigin: _state.account.address as never, libraries: ["@example/feedback"], }, ); + if (!live.ok) throw live.error; + _contractManager = live.value; _contract = wrapContract(_contractManager.getContract("@example/feedback")); console.log("[CDM] Contract manager ready (live registry resolution)"); })(); @@ -393,7 +483,12 @@ export function getContract(): any { if (!_contract) throw new Error("Contract init failed"); const real = _contract[prop as string]; if (!real) throw new Error(`Unknown method: ${String(prop)}`); - return real[methodProp](...args); + // Since product-sdk 0.18, `.tx(...)` returns a Result + // instead of throwing. Unwrap it (re-throw the `err` + // channel) so call sites keep their try/catch flow. + // `.query(...)` is unchanged upstream. + const outcome = await real[methodProp](...args); + return methodProp === "tx" ? unwrapResult(outcome) : outcome; }; }, }); @@ -430,10 +525,12 @@ async function mapAccountWithRuntime( ): Promise { if (_mappedAccounts.has(account.address)) return; try { - const mapped = await ensureContractAccountMapped( - runtime, - account.address as never, - account.signer, + // Since product-sdk 0.18, ensureContractAccountMapped returns a Result + // (ok(null) = already mapped) instead of throwing. Unwrap it so the + // catch handles both a returned `err` and any thrown failure with the + // same cause-chain logging. + const mapped = unwrapResult( + await ensureContractAccountMapped(runtime, account.address as never, account.signer), ); if (mapped === null) { console.log(`[Revive] Account ${account.address} already mapped`); @@ -443,9 +540,8 @@ async function mapAccountWithRuntime( _mappedAccounts.add(account.address); } catch (err) { console.error("[Revive] ensureContractAccountMapped failed:", err); - if (err && typeof err === "object" && "cause" in err) { - console.error("[Revive] underlying cause:", (err as any).cause); - } + const cause = err && typeof err === "object" ? (err as { cause?: unknown }).cause : undefined; + if (cause) console.error("[Revive] underlying cause:", cause); throw err; } } @@ -461,12 +557,7 @@ export async function ensureMapping(account: AppAccount): Promise { // Bulletin reads via public IPFS gateways (Promise.any race). // --------------------------------------------------------------------------- -const GATEWAYS = [ - "https://paseo-bulletin-next-ipfs.polkadot.io/ipfs/", - "https://dweb.link/ipfs/", - "https://ipfs.io/ipfs/", - "https://nftstorage.link/ipfs/", -] as const; +const GATEWAYS = NETWORK.gateways; export const IPFS_GATEWAY = GATEWAYS[0];