Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .clinerules
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .windsurfrules
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 27 additions & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
> <https://faucet.polkadot.io/?parachain=1000>. 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 `@<your-handle>/feedback → (address,
Expand Down Expand Up @@ -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 <network>` 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 |
Expand Down
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
139 changes: 115 additions & 24 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<T>(
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<AssetHubDescriptor>;
}

// `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)
// ---------------------------------------------------------------------------
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -329,12 +412,13 @@ async function ensureContractsReady(): Promise<void> {
// 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();
Expand All @@ -353,23 +437,29 @@ async function ensureContractsReady(): Promise<void> {
// 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)");
})();
Expand All @@ -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;
};
},
});
Expand Down Expand Up @@ -430,10 +525,12 @@ async function mapAccountWithRuntime(
): Promise<void> {
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`);
Expand All @@ -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;
}
}
Expand All @@ -461,12 +557,7 @@ export async function ensureMapping(account: AppAccount): Promise<void> {
// 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];

Expand Down