From b6f9249967e909121f9c2608db827c16fb331aca Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Fri, 24 Jul 2026 20:29:15 +0530 Subject: [PATCH 1/5] chore(deps): upgrade product-sdk to 0.19 and drop the novasama host-api Bump product-sdk ^0.19.1, chain-client ^0.9.1, contracts ^0.9.2, descriptors ^0.8.0, signer ^0.11.1, statement-store ^0.6.2, tx ^0.3.2; drop @novasamatech/host-api(-wrapper). The two remaining wrapper call sites (requestPermission, preimageManager) move to @parity/product-sdk-host. Result-API migration: contract .tx unwrapped in the getContract proxy, fromLiveClient and ensureContractAccountMapped branch on .ok. Statement-store publish now returns a Result; call sites already ignored the old boolean, so semantics are unchanged. Closes #19 --- package.json | 16 ++++++------- src/utils.ts | 64 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 879e1f2..baa7706 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,14 @@ }, "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": "^0.19.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-statement-store": "^0.4.9", - "@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-signer": "^0.11.1", + "@parity/product-sdk-statement-store": "^0.6.2", + "@parity/product-sdk-tx": "^0.3.2", "multiformats": "^13.4.2", "polkadot-api": "^2.1.6", "react": "^19.0.0", diff --git a/src/utils.ts b/src/utils.ts index 59828a1..8d9b4e0 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, @@ -31,11 +31,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); @@ -224,6 +224,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; @@ -359,7 +363,9 @@ async function ensureContractsReady(): Promise { const initRuntime = createContractRuntimeFromClient(client, paseo_asset_hub); await mapAccountWithRuntime(initRuntime, _state.account); - _contractManager = await ContractManager.fromLiveClient( + // Since product-sdk 0.18, fromLiveClient returns a Result instead of + // throwing on resolution failure. + const live = await ContractManager.fromLiveClient( _cdmJson, client, paseo_asset_hub, @@ -370,6 +376,8 @@ async function ensureContractsReady(): Promise { libraries: ["@rps/leaderboard"], }, ); + if (!live.ok) throw live.error; + _contractManager = live.value; _contract = wrapContract(_contractManager.getContract("@rps/leaderboard")); console.log("[CDM] Contract manager ready (live registry resolution)"); })(); @@ -399,7 +407,16 @@ 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); + if (methodProp === "tx") { + if (!outcome.ok) throw outcome.error; + return outcome.value; + } + return outcome; }; }, }); @@ -450,31 +467,32 @@ async function mapAccountWithRuntime( account: AppAccount, ): Promise { if (_mappedAccounts.has(account.address)) return; - try { - const mapped = await ensureContractAccountMapped( - runtime, - account.address as never, - account.signer, - ); - if (mapped === null) { - console.log(`[Revive] Account ${account.address} already mapped`); - } else { - console.log(`[Revive] Account mapped in block #${mapped.block.number}`); - } - _mappedAccounts.add(account.address); - } catch (err) { + // Since product-sdk 0.18, ensureContractAccountMapped returns a Result + // (ok(null) = already mapped) instead of throwing. + const mapped = await ensureContractAccountMapped( + runtime, + account.address as never, + account.signer, + ); + if (!mapped.ok) { + const err = mapped.error; console.error("[Revive] ensureContractAccountMapped failed:", err); // TxAccountMappingError wraps the underlying storage-read failure in `cause`. // The top-level message alone hides whether it's a chainHead/runtime/decoder issue. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (err && typeof err === "object" && "cause" in err) { - console.error("[Revive] underlying cause:", (err as any).cause); + if (err.cause) { + console.error("[Revive] underlying cause:", err.cause); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const causeCause = (err as any).cause?.cause; + const causeCause = (err.cause as any)?.cause; if (causeCause) console.error("[Revive] root cause:", causeCause); } throw err; } + if (mapped.value === null) { + console.log(`[Revive] Account ${account.address} already mapped`); + } else { + console.log(`[Revive] Account mapped in block #${mapped.value.block.number}`); + } + _mappedAccounts.add(account.address); } // pallet-revive on Paseo Next v2 requires every SS58 origin that calls a contract to From 733d44abb915f8e0aabd5f19ee2286532d547cb4 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Thu, 30 Jul 2026 15:19:18 +0530 Subject: [PATCH 2/5] fix: unwrap statement-store publish Results and declare host directly publish returns a Result instead of throwing since product-sdk 0.18, and the multiplayer sends ignored it, so connection and size failures passed silently. Unwrap every publish so a failed send surfaces to the player instead of stalling the round. Declare @parity/product-sdk-host directly and drop the unused @parity/product-sdk umbrella so host no longer resolves only by hoisting. Wrap the account mapping in try/catch so the cause logging runs whether it returns an error or throws. Update the stale @novasamatech/host-api reference in the AI-config files. --- .clinerules | 2 +- .github/copilot-instructions.md | 2 +- .windsurfrules | 2 +- package.json | 2 +- src/pages/MultiplayerGame.tsx | 61 ++++++++++++++++++++------------- src/pages/MultiplayerLobby.tsx | 15 +++++--- src/utils.ts | 61 +++++++++++++++++++-------------- 7 files changed, 88 insertions(+), 57 deletions(-) diff --git a/.clinerules b/.clinerules index 8f218c9..1c9b152 100644 --- a/.clinerules +++ b/.clinerules @@ -2,5 +2,5 @@ RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (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 eec2772..ca2d0df 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,5 +2,5 @@ RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (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 8f218c9..1c9b152 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -2,5 +2,5 @@ RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (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/package.json b/package.json index baa7706..4265b5d 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ }, "dependencies": { "@noble/hashes": "^2.2.0", - "@parity/product-sdk": "^0.19.1", "@parity/product-sdk-address": "^0.1.1", "@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-statement-store": "^0.6.2", "@parity/product-sdk-tx": "^0.3.2", diff --git a/src/pages/MultiplayerGame.tsx b/src/pages/MultiplayerGame.tsx index 1b688ff..045fb86 100644 --- a/src/pages/MultiplayerGame.tsx +++ b/src/pages/MultiplayerGame.tsx @@ -5,7 +5,7 @@ import type { Move, Round, GameData, PlayerData, RoundResult } from "../types.ts import { determineWinner, pointsForResult, uploadToBulletin, ensureMapping, getContract, withTimeout, - IPFS_GATEWAY, short, asBytes20, + IPFS_GATEWAY, short, asBytes20, unwrapResult, } from "../utils.ts"; const MOVE_EMOJI: Record = { rock: "✊", paper: "✋", scissors: "✂️" }; @@ -114,11 +114,16 @@ export default function MultiplayerGame({ account, roomCode, onDone }: { phaseRef.current = "waiting-reveal"; if (myMoveRef.current && mySaltRef.current) { - clientRef.current.publish( - { type: "reveal", round: roundRef.current, move: myMoveRef.current, - salt: mySaltRef.current, peerId: myId, timestamp: Date.now() }, - { channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode }, - ); + // Fire-and-forget send. publish() returns a Result since + // product-sdk 0.18 (no throw), so surface a failure rather + // than letting a dropped reveal stall the round silently. + void clientRef.current + .publish( + { type: "reveal", round: roundRef.current, move: myMoveRef.current, + salt: mySaltRef.current, peerId: myId, timestamp: Date.now() }, + { channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode }, + ) + .then(r => { if (!r.ok) setStatusMsg("Failed to send reveal: " + r.error.message); }); } } } @@ -161,10 +166,12 @@ export default function MultiplayerGame({ account, roomCode, onDone }: { handleMessage(msg); }, { topic2: roomCode }); - await client.publish( + // publish() returns a Result since product-sdk 0.18; unwrap so + // a failed join reaches the catch instead of vanishing. + unwrapResult(await client.publish( { type: "join", peerId: myId, timestamp: Date.now() }, { channel: `${roomCode}/presence/${myId}`, topic2: roomCode }, - ); + )); if (!destroyed) { setPhase("pick"); @@ -193,21 +200,29 @@ export default function MultiplayerGame({ account, roomCode, onDone }: { mySaltRef.current = salt; setPickedMove(move); - await clientRef.current.publish( - { type: "commit", round: roundRef.current, hash, peerId: myId, timestamp: Date.now() }, - { channel: `${roomCode}/commit/${roundRef.current}/${myId}`, topic2: roomCode }, - ); - - if (opponentCommitRef.current) { - setPhase("waiting-reveal"); - phaseRef.current = "waiting-reveal"; - await clientRef.current.publish( - { type: "reveal", round: roundRef.current, move, salt, peerId: myId, timestamp: Date.now() }, - { channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode }, - ); - } else { - setPhase("waiting-commit"); - phaseRef.current = "waiting-commit"; + // publish() returns a Result since product-sdk 0.18 (no throw). Unwrap + // so a dropped commit/reveal surfaces to the player instead of leaving + // the round stuck with nothing logged. + try { + unwrapResult(await clientRef.current.publish( + { type: "commit", round: roundRef.current, hash, peerId: myId, timestamp: Date.now() }, + { channel: `${roomCode}/commit/${roundRef.current}/${myId}`, topic2: roomCode }, + )); + + if (opponentCommitRef.current) { + setPhase("waiting-reveal"); + phaseRef.current = "waiting-reveal"; + unwrapResult(await clientRef.current.publish( + { type: "reveal", round: roundRef.current, move, salt, peerId: myId, timestamp: Date.now() }, + { channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode }, + )); + } else { + setPhase("waiting-commit"); + phaseRef.current = "waiting-commit"; + } + } catch (err) { + console.error("[MPGame] publish failed:", err); + setStatusMsg("Failed to send move: " + (err instanceof Error ? err.message : String(err))); } }; diff --git a/src/pages/MultiplayerLobby.tsx b/src/pages/MultiplayerLobby.tsx index cbb24de..eaa6a54 100644 --- a/src/pages/MultiplayerLobby.tsx +++ b/src/pages/MultiplayerLobby.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { AppAccount } from "../utils.ts"; import { StatementStoreClient } from "@parity/product-sdk-statement-store"; -import { generateRoomCode } from "../utils.ts"; +import { generateRoomCode, unwrapResult } from "../utils.ts"; interface JoinMessage { type: "join"; @@ -56,10 +56,12 @@ export default function MultiplayerLobby({ account, onGameStart }: { } }, { topic2: code }); - await client.publish( + // publish() returns a Result since product-sdk 0.18; unwrap so a + // failed publish reaches the catch below instead of vanishing. + unwrapResult(await client.publish( { type: "join", peerId: account.h160Address, timestamp: Date.now() }, { channel: `${code}/presence/${account.h160Address}`, topic2: code }, - ); + )); setStatusMsg("Waiting for opponent..."); } catch (err) { @@ -89,10 +91,13 @@ export default function MultiplayerLobby({ account, onGameStart }: { accountId: account.productAccountId, }); - await client.publish( + // publish() returns a Result since product-sdk 0.18; unwrap so a + // failed join reaches the catch instead of navigating into a game + // the host never learned about. + unwrapResult(await client.publish( { type: "join", peerId: account.h160Address, timestamp: Date.now() }, { channel: `${code}/presence/${account.h160Address}`, topic2: code }, - ); + )); setStatusMsg("Joined! Starting game..."); setTimeout(() => { diff --git a/src/utils.ts b/src/utils.ts index 8d9b4e0..45c224c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,6 +21,21 @@ import * as raw from "multiformats/codecs/raw"; import type { MultihashDigest } from "multiformats/hashes/interface"; import type { Move, RoundResult } from "./types.ts"; +/** + * 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; +} + // --------------------------------------------------------------------------- // Permissions (RFC-0002) // --------------------------------------------------------------------------- @@ -412,11 +427,7 @@ export function getContract(): any { // channel) so call sites keep their try/catch flow. // `.query(...)` is unchanged upstream. const outcome = await real[methodProp](...args); - if (methodProp === "tx") { - if (!outcome.ok) throw outcome.error; - return outcome.value; - } - return outcome; + return methodProp === "tx" ? unwrapResult(outcome) : outcome; }; }, }); @@ -467,32 +478,32 @@ async function mapAccountWithRuntime( account: AppAccount, ): Promise { if (_mappedAccounts.has(account.address)) return; - // Since product-sdk 0.18, ensureContractAccountMapped returns a Result - // (ok(null) = already mapped) instead of throwing. - const mapped = await ensureContractAccountMapped( - runtime, - account.address as never, - account.signer, - ); - if (!mapped.ok) { - const err = mapped.error; + try { + // Since product-sdk 0.18, ensureContractAccountMapped returns a Result + // (ok(null) = already mapped) instead of throwing. Unwrap it so the + // catch below 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`); + } else { + console.log(`[Revive] Account mapped in block #${mapped.block.number}`); + } + _mappedAccounts.add(account.address); + } catch (err) { console.error("[Revive] ensureContractAccountMapped failed:", err); // TxAccountMappingError wraps the underlying storage-read failure in `cause`. // The top-level message alone hides whether it's a chainHead/runtime/decoder issue. - if (err.cause) { - console.error("[Revive] underlying cause:", err.cause); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const causeCause = (err.cause as any)?.cause; - if (causeCause) console.error("[Revive] root cause:", causeCause); + const cause = err && typeof err === "object" ? (err as { cause?: unknown }).cause : undefined; + if (cause) { + console.error("[Revive] underlying cause:", cause); + const rootCause = (cause as { cause?: unknown }).cause; + if (rootCause) console.error("[Revive] root cause:", rootCause); } throw err; } - if (mapped.value === null) { - console.log(`[Revive] Account ${account.address} already mapped`); - } else { - console.log(`[Revive] Account mapped in block #${mapped.value.block.number}`); - } - _mappedAccounts.add(account.address); } // pallet-revive on Paseo Next v2 requires every SS58 origin that calls a contract to From 30c7b2f26c39ec2061f4d7b441edb4a364c49315 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Fri, 24 Jul 2026 20:31:06 +0530 Subject: [PATCH 3/5] feat: add devnet as a selectable network Two-network config (paseo-next, devnet) selected at build time via VITE_NETWORK; default stays paseo-next. Descriptors load dynamically and the host-routed chain client takes its identity from the loaded descriptor, so no endpoints or genesis are pinned. fromLiveClient now takes the selected network's registry address, so devnet resolves the leaderboard against the devnet registry. Add the cdm devnet preset deploy script and a paseo-next vs devnet note in DEPLOYMENT.md. Closes #20 --- DEPLOYMENT.md | 20 ++++++++++++ package.json | 1 + src/utils.ts | 84 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9462582..20b20d3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -168,6 +168,26 @@ 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 leaderboard 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 leaderboard address from the devnet +> registry at startup and there is no `cdm.json` fallback, so a devnet build +> pointed at a network where `@rps/leaderboard` isn't registered fails at +> contract init with a resolution error. + *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 `@/leaderboard → (address, diff --git a/package.json b/package.json index 4265b5d..990ce53 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "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": { diff --git a/src/utils.ts b/src/utils.ts index 45c224c..316c450 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,7 +13,8 @@ import { } from "@parity/product-sdk-signer"; import { createChainClient } from "@parity/product-sdk-chain-client"; import { ContractManager, 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"; @@ -36,6 +37,67 @@ export function unwrapResult( 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 leaderboard resolves against. */ + registry: string; + loadDescriptor(): Promise; +} + +const NETWORKS: Record<"paseo-next" | "devnet", NetworkConfig> = { + "paseo-next": { + 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, + }, + // Bulletin Paseo has no dedicated HTTP gateway; devnet content is read + // via public IPFS gateways. + devnet: { + label: "Devnet (Paseo testnet)", + 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, + }, +}; + +function resolveNetwork(): NetworkConfig { + const selected = (import.meta.env.VITE_NETWORK ?? "").trim().toLowerCase(); + if (selected === "devnet") return NETWORKS.devnet; + if (selected && selected !== "paseo" && selected !== "paseo-next") { + console.warn(`[Network] Unknown VITE_NETWORK "${selected}", falling back to paseo-next`); + } + return NETWORKS["paseo-next"]; +} + +export const NETWORK = resolveNetwork(); + // --------------------------------------------------------------------------- // Permissions (RFC-0002) // --------------------------------------------------------------------------- @@ -341,12 +403,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(); @@ -375,18 +438,20 @@ async function ensureContractsReady(): Promise { // query origin isn't mapped — surfacing as ContractLiveAddressResolutionError. // Build a plain runtime (no registry query) to perform the mapping first. // (ChainSubmit permission already granted at the top of this init.) - const initRuntime = createContractRuntimeFromClient(client, paseo_asset_hub); + const initRuntime = createContractRuntimeFromClient(client, descriptor); await mapAccountWithRuntime(initRuntime, _state.account); // Since product-sdk 0.18, fromLiveClient returns a Result instead of - // throwing on resolution failure. + // 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: ["@rps/leaderboard"], }, @@ -522,12 +587,7 @@ export async function ensureMapping(account: AppAccount): Promise { // Bulletin reads via public IPFS gateways // --------------------------------------------------------------------------- -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]; From 9b63dbf66ad5772a31750f936a85a720e338430b Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Mon, 3 Aug 2026 16:41:32 +0530 Subject: [PATCH 4/5] fix: fold network selection to one metadata chunk --- src/utils.ts | 74 +++++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 316c450..637c183 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -59,45 +59,47 @@ interface NetworkConfig { loadDescriptor(): Promise; } -const NETWORKS: Record<"paseo-next" | "devnet", NetworkConfig> = { - "paseo-next": { - 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, - }, - // Bulletin Paseo has no dedicated HTTP gateway; devnet content is read - // via public IPFS gateways. - devnet: { - label: "Devnet (Paseo testnet)", - 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, - }, -}; - -function resolveNetwork(): NetworkConfig { - const selected = (import.meta.env.VITE_NETWORK ?? "").trim().toLowerCase(); - if (selected === "devnet") return NETWORKS.devnet; - if (selected && selected !== "paseo" && selected !== "paseo-next") { - console.warn(`[Network] Unknown VITE_NETWORK "${selected}", falling back to paseo-next`); +// `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 raw = import.meta.env.VITE_NETWORK; + if (raw && raw !== "devnet" && raw !== "paseo" && raw !== "paseo-next") { + console.warn(`[Network] Unknown VITE_NETWORK "${raw}", falling back to paseo-next`); } - return NETWORKS["paseo-next"]; } -export const NETWORK = resolveNetwork(); - // --------------------------------------------------------------------------- // Permissions (RFC-0002) // --------------------------------------------------------------------------- From d2b93c26280d36b941a5464d4370ceb683e30e3a Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 4 Aug 2026 13:23:04 +0530 Subject: [PATCH 5/5] fix: rename VITE_NETWORK dev var to avoid shadowing the raw import --- src/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 637c183..a2e8b6c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,9 +94,9 @@ export const NETWORK: NetworkConfig = // 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 raw = import.meta.env.VITE_NETWORK; - if (raw && raw !== "devnet" && raw !== "paseo" && raw !== "paseo-next") { - console.warn(`[Network] Unknown VITE_NETWORK "${raw}", falling back to paseo-next`); + 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`); } }