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/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 879e1f2..990ce53 100644 --- a/package.json +++ b/package.json @@ -9,20 +9,19 @@ "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": { "@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-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-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", "multiformats": "^13.4.2", "polkadot-api": "^2.1.6", "react": "^19.0.0", 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 59828a1..a2e8b6c 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, @@ -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"; @@ -21,6 +22,84 @@ 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; +} + +// --------------------------------------------------------------------------- +// 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; +} + +// `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) // --------------------------------------------------------------------------- @@ -31,11 +110,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 +303,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; @@ -322,12 +405,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(); @@ -356,20 +440,26 @@ 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); - _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: ["@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 +489,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; }; }, }); @@ -451,10 +546,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 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`); @@ -466,12 +563,11 @@ async function mapAccountWithRuntime( 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); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const causeCause = (err as any).cause?.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; } @@ -493,12 +589,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];