diff --git a/product-sdk/packages/chain-client/src/chain-names.ts b/product-sdk/packages/chain-client/src/chain-names.ts new file mode 100644 index 00000000..f1d8bb41 --- /dev/null +++ b/product-sdk/packages/chain-client/src/chain-names.ts @@ -0,0 +1,31 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Maps SDK preset chain keys to the host's chain-role identifiers and holds + * the canonical environment list. Everything here is internal to the + * package. + */ + +import type { HostChainIdentifier } from "@parity/product-sdk-host"; +import type { Environment } from "./presets.js"; + +/** Host chain-role identifier for each preset chain key. */ +export const CHAIN_IDENTIFIERS = { + assetHub: "AssetHub", + bulletin: "Bulletin", + individuality: "People", +} as const satisfies Record; + +/** Every known environment. The public {@link Environment} union derives from it. */ +export const ENVIRONMENTS = ["polkadot", "kusama", "paseo", "devnet"] as const; + +if (import.meta.vitest) { + const { test, expect } = import.meta.vitest; + + test("environments are unique and individuality maps to People", () => { + expect(new Set(ENVIRONMENTS).size).toBe(ENVIRONMENTS.length); + // The types cannot prove the right role was picked for the one + // non-obvious pairing. + expect(CHAIN_IDENTIFIERS.individuality).toBe("People"); + }); +} diff --git a/product-sdk/packages/chain-client/src/errors.ts b/product-sdk/packages/chain-client/src/errors.ts new file mode 100644 index 00000000..165edd80 --- /dev/null +++ b/product-sdk/packages/chain-client/src/errors.ts @@ -0,0 +1,72 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Errors raised by `getChainAPI` when host chain discovery + * contradicts what the product asked for or what it bundled. Both carry + * structured fields for programmatic handling, mirroring the shape of + * `ChainNotSupportedError` in `@parity/product-sdk-host`. + */ + +/** The environment passed to `getChainAPI` is not the one the host runs. */ +export class EnvironmentMismatchError extends Error { + /** Environment the caller asked for, e.g. `"paseo"`. */ + readonly requested: string; + /** Network the host reports being configured for. */ + readonly hostNetwork: string; + + constructor(requested: string, hostNetwork: string) { + super( + `Environment mismatch: getChainAPI was called with "${requested}" but the host is configured for "${hostNetwork}". Omit the environment argument to use the host's network, or run the product on a matching host.`, + ); + this.name = "EnvironmentMismatchError"; + this.requested = requested; + this.hostNetwork = hostNetwork; + } +} + +/** A bundled descriptor's genesis hash disagrees with the host's answer. */ +export class GenesisMismatchError extends Error { + /** Preset chain key, e.g. `"assetHub"`. */ + readonly chain: string; + /** Genesis hash baked into the bundled descriptor. */ + readonly descriptorGenesis: string; + /** Genesis hash the host serves for this chain. */ + readonly hostGenesis: string; + + constructor(chain: string, descriptorGenesis: string, hostGenesis: string) { + super( + `Genesis hash mismatch for "${chain}": the bundled descriptor expects ${descriptorGenesis} but the host serves ${hostGenesis}. The descriptor bundle is likely stale, for example after a testnet reset. Update @parity/product-sdk-descriptors or check the host's environment.`, + ); + this.name = "GenesisMismatchError"; + this.chain = chain; + this.descriptorGenesis = descriptorGenesis; + this.hostGenesis = hostGenesis; + } +} + +if (import.meta.vitest) { + const { test, expect } = import.meta.vitest; + + const cases = [ + { + error: new EnvironmentMismatchError("paseo", "devnet"), + name: "EnvironmentMismatchError", + fields: { requested: "paseo", hostNetwork: "devnet" }, + }, + { + error: new GenesisMismatchError("assetHub", "0xaaa", "0xbbb"), + name: "GenesisMismatchError", + fields: { chain: "assetHub", descriptorGenesis: "0xaaa", hostGenesis: "0xbbb" }, + }, + ]; + + test("mismatch errors carry structured fields and name them in the message", () => { + for (const { error, name, fields } of cases) { + expect(error.name).toBe(name); + for (const [field, value] of Object.entries(fields)) { + expect((error as unknown as Record)[field]).toBe(value); + expect(error.message).toContain(value); + } + } + }); +} diff --git a/product-sdk/packages/chain-client/src/index.ts b/product-sdk/packages/chain-client/src/index.ts index 9083d8e0..37eb20df 100644 --- a/product-sdk/packages/chain-client/src/index.ts +++ b/product-sdk/packages/chain-client/src/index.ts @@ -27,6 +27,9 @@ export type { ChainClient, ChainClientConfig, ChainEntry } from "./types.js"; export { WellKnownChain } from "./well-known-chain.js"; export type { WellKnownChainHash } from "./well-known-chain.js"; +// Chain-discovery validation errors +export { EnvironmentMismatchError, GenesisMismatchError } from "./errors.js"; + // Re-export from host export { isInsideContainer, diff --git a/product-sdk/packages/chain-client/src/presets.ts b/product-sdk/packages/chain-client/src/presets.ts index 1d9264b6..3b63dcd1 100644 --- a/product-sdk/packages/chain-client/src/presets.ts +++ b/product-sdk/packages/chain-client/src/presets.ts @@ -1,9 +1,16 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 import type { ChainDefinition } from "polkadot-api"; +import { createLogger } from "@parity/product-sdk-logger"; +import { getHostChainInfo } from "@parity/product-sdk-host"; +import type { HostChainDiscovery } from "@parity/product-sdk-host"; import { createChainClient } from "./clients.js"; +import { CHAIN_IDENTIFIERS, type ENVIRONMENTS } from "./chain-names.js"; +import { EnvironmentMismatchError, GenesisMismatchError } from "./errors.js"; import type { ChainClient } from "./types.js"; +const log = createLogger("chain-client"); + // Type-only imports — erased at compile time, zero bundle cost. // These give us per-chain TypedApi types without importing runtime descriptor data. // Every environment ships its own descriptor for each chain (asset hub, bulletin, @@ -28,7 +35,7 @@ import type { devnet_individuality as DevnetIndividualityDef } from "@parity/pro * chains, community-run by the * Polkadot Community Foundation. */ -export type Environment = "polkadot" | "kusama" | "paseo" | "devnet"; +export type Environment = (typeof ENVIRONMENTS)[number]; /** Environments where all chains (asset hub, bulletin, individuality) are live. */ const AVAILABLE_ENVIRONMENTS: Set = new Set(["paseo", "devnet"]); @@ -126,6 +133,107 @@ type PresetDescriptors = { /** The chain shape returned by {@link getChainAPI} for a given environment. */ export type PresetChains = PresetDescriptors[E]; +/** Import one environment's asset hub descriptor and read its genesis hash. */ +const assetHubGenesis: Record Promise> = { + polkadot: async () => + (await import("@parity/product-sdk-descriptors/polkadot-asset-hub")).polkadot_asset_hub + .genesis, + kusama: async () => + (await import("@parity/product-sdk-descriptors/kusama-asset-hub")).kusama_asset_hub.genesis, + paseo: async () => + (await import("@parity/product-sdk-descriptors/paseo-asset-hub")).paseo_asset_hub.genesis, + devnet: async () => + (await import("@parity/product-sdk-descriptors/devnet-asset-hub")).devnet_asset_hub.genesis, +}; + +/** Live environments first so the probe usually stops at the first bundle. */ +const PROBE_ORDER: readonly Environment[] = ["paseo", "devnet", "polkadot", "kusama"]; + +/** + * Pick the effective environment: the one whose bundled asset hub carries + * the discovered genesis hash. Hosts mint their own network ids, so matching + * is by genesis, never by network string. Probes the requested environment + * first, so the explicit happy path loads nothing extra. + */ +async function resolveEnvironment( + env: Environment | undefined, + discovery: HostChainDiscovery | null, +): Promise { + if (discovery === null) { + if (!env) { + throw new Error( + 'getChainAPI: the host did not report a usable network via chain discovery; pass an explicit environment, e.g. getChainAPI("paseo").', + ); + } + return env; + } + const discovered = discovery.chains.AssetHub?.toLowerCase(); + let matched: Environment | null = null; + if (discovered) { + const candidates = env ? [env, ...PROBE_ORDER.filter((e) => e !== env)] : PROBE_ORDER; + for (const candidate of candidates) { + // A bundle that fails to load is just a candidate that cannot + // match. Every environment is probed, including ones unrelated to + // the host, so one broken chunk must not take down the call. + let genesis: string | undefined; + try { + genesis = await assetHubGenesis[candidate](); + } catch (error) { + log.warn( + `Could not load the "${candidate}" asset hub descriptor while deriving the environment`, + error, + ); + continue; + } + if (genesis?.toLowerCase() === discovered) { + matched = candidate; + break; + } + } + } + if (env) { + if (matched && matched !== env) throw new EnvironmentMismatchError(env, discovery.network); + // No match means the host's asset hub is unknown. Descriptor + // validation below reports that as a genesis mismatch. + return env; + } + if (!matched) { + throw new Error( + `getChainAPI: no bundled descriptors match the host's chains (network "${discovery.network}"); pass an explicit environment.`, + ); + } + return matched; +} + +/** + * Cross-check each bundled descriptor against the host's resolved chains. + * Identifiers the host refused are absent from the discovery result and are + * left to the existing deferred ChainNotSupportedError path. + * + * Only the asset hub is fatal: it anchors the environment, so a mismatch there + * means the whole bundle is the wrong one. The other chains are re-genesised + * individually (paseo individuality has been, with the asset hub untouched), and + * failing the call would take the chains that do match down with them. Those + * warn here and `createChainClient` hands back an api that throws + * `ChainNotSupportedError` on use, which is the same treatment any chain the + * host cannot serve already gets. + */ +function validateDescriptorGenesis( + descriptors: Record, + discovery: HostChainDiscovery, +): void { + for (const [key, identifier] of Object.entries(CHAIN_IDENTIFIERS)) { + const hostGenesis = discovery.chains[identifier]; + const genesis = descriptors[key as keyof typeof CHAIN_IDENTIFIERS]?.genesis; + if (!hostGenesis || !genesis) continue; + if (genesis.toLowerCase() === hostGenesis.toLowerCase()) continue; + if (key === "assetHub") throw new GenesisMismatchError(key, genesis, hostGenesis); + log.warn( + `Bundled "${key}" descriptor expects genesis ${genesis} but the host serves ${hostGenesis}; that chain will throw on use. Update @parity/product-sdk-descriptors.`, + ); + } +} + /** * Get a chain client for a known environment with built-in descriptors. * @@ -136,7 +244,32 @@ export type PresetChains = PresetDescriptors[E]; * Returns the same {@link ChainClient} type as `createChainClient`, with * `assetHub`, `bulletin`, and `individuality` chain keys. * + * When called with no argument, the environment is derived from the host via + * chain discovery. This is the recommended mode inside a + * container. The zero-arg form is typed with the "paseo" shape, and runtime + * descriptors always match the host's actual network. It needs a host that + * serves discovery: outside a container, or on a host that predates it, the + * zero-arg form throws and the environment has to be passed explicitly. + * + * An explicit environment that disagrees with the host's network throws + * {@link EnvironmentMismatchError}. A bundled asset hub descriptor whose + * genesis hash disagrees with the host throws {@link GenesisMismatchError}, + * since it anchors the environment; a mismatch on bulletin or individuality + * warns and leaves that one chain throwing on use. Hosts that predate + * discovery skip validation entirely. + * + * @example + * Let the host decide, the recommended path inside a container: + * ```ts + * import { getChainAPI } from "@parity/product-sdk-chain-client"; + * + * const client = await getChainAPI(); + * const account = await client.assetHub.query.System.Account.getValue(addr); + * client.destroy(); + * ``` + * * @example + * Pin the environment explicitly: * ```ts * import { getChainAPI } from "@parity/product-sdk-chain-client"; * @@ -154,14 +287,21 @@ export type PresetChains = PresetDescriptors[E]; * client.destroy(); * ``` */ +export async function getChainAPI(): Promise>>; export async function getChainAPI( env: E, -): Promise>> { +): Promise>>; +export async function getChainAPI(envArg?: Environment): Promise { + // "Relay" is not probed because there is no relay preset descriptor. + const discovery = await getHostChainInfo(Object.values(CHAIN_IDENTIFIERS)); + const env = await resolveEnvironment(envArg, discovery); + if (!AVAILABLE_ENVIRONMENTS.has(env)) { throw new Error(`Chain API for "${env}" is not yet available`); } const descriptors = await loadDescriptors(env); + if (discovery) validateDescriptorGenesis(descriptors, discovery); return createChainClient({ chains: { @@ -169,12 +309,13 @@ export async function getChainAPI( bulletin: descriptors.bulletin, individuality: descriptors.individuality, }, - }) as Promise>>; + }); } if (import.meta.vitest) { - const { test, expect, beforeEach } = import.meta.vitest; + const { test, expect, beforeEach, vi } = import.meta.vitest; const { destroyAll } = await import("./clients.js"); + const { EnvironmentMismatchError, GenesisMismatchError } = await import("./errors.js"); // Test-only genesis hashes for assertion — not used in production code. const GENESIS = { @@ -190,6 +331,7 @@ if (import.meta.vitest) { beforeEach(() => { destroyAll(); + discoveryState.discovery = null; }); // --- GENESIS constants --- @@ -239,4 +381,121 @@ if (import.meta.vitest) { expect(AVAILABLE_ENVIRONMENTS.has("polkadot")).toBe(false); expect(AVAILABLE_ENVIRONMENTS.has("kusama")).toBe(false); }); + + // --- chain discovery --- + + // Partial mocks: getHostChainInfo is driven by test state; createChainClient + // is captured so success paths don't dial a real host. All other exports stay real. + const discoveryState = vi.hoisted(() => ({ + discovery: null as null | { + network: string; + chains: Partial>; + }, + createChainClientCalls: [] as unknown[], + })); + + vi.mock("@parity/product-sdk-host", async (importOriginal) => ({ + ...(await importOriginal()), + getHostChainInfo: async () => discoveryState.discovery, + })); + + vi.mock("./clients.js", async (importOriginal) => ({ + ...(await importOriginal()), + createChainClient: async (config: unknown) => { + discoveryState.createChainClientCalls.push(config); + return { fake: true }; + }, + })); + + // The network id is deliberately dotli's spelling, not "paseo". Derivation + // must work from the genesis hashes alone. + const HOST_PASEO = { + network: "paseo-next-v2", + chains: { + AssetHub: GENESIS.paseo_asset_hub, + Bulletin: GENESIS.paseo_bulletin, + People: GENESIS.paseo_individuality, + }, + }; + + test("legacy host + no env throws a clear error", async () => { + discoveryState.discovery = null; + await expect(getChainAPI()).rejects.toThrow(/pass an explicit environment/); + }); + + test("legacy host + explicit env behaves as today", async () => { + discoveryState.discovery = null; + discoveryState.createChainClientCalls = []; + await getChainAPI("paseo"); + expect(discoveryState.createChainClientCalls.length).toBe(1); + }); + + test("derives the environment from the discovered asset hub genesis", async () => { + discoveryState.discovery = HOST_PASEO; + discoveryState.createChainClientCalls = []; + await getChainAPI(); + const config = discoveryState.createChainClientCalls[0] as { + chains: Record; + }; + expect(config.chains.assetHub.genesis).toBe(GENESIS.paseo_asset_hub); + }); + + test("explicit env mismatching the host's chains throws EnvironmentMismatchError", async () => { + discoveryState.discovery = HOST_PASEO; + const error = await getChainAPI("devnet").catch((e) => e); + expect(error).toBeInstanceOf(EnvironmentMismatchError); + expect(error.requested).toBe("devnet"); + expect(error.hostNetwork).toBe("paseo-next-v2"); + }); + + test("no matching bundle + no env throws naming the host network", async () => { + discoveryState.discovery = { network: "westend", chains: {} }; + await expect(getChainAPI()).rejects.toThrow(/no bundled descriptors match.*"westend"/); + }); + + test("descriptor genesis disagreeing with the host throws GenesisMismatchError", async () => { + discoveryState.discovery = { + network: "paseo", + chains: { AssetHub: "0xdeadbeef" }, + }; + const error = await getChainAPI("paseo").catch((e) => e); + expect(error).toBeInstanceOf(GenesisMismatchError); + expect(error.chain).toBe("assetHub"); + expect(error.hostGenesis).toBe("0xdeadbeef"); + }); + + test("a non-anchor genesis mismatch warns and keeps the other chains usable", async () => { + // paseo individuality has been re-genesised on its own before, with the + // asset hub untouched (descriptors 0.5.1). Failing the whole call would + // take asset hub and bulletin down with it. + discoveryState.discovery = { + network: "paseo", + chains: { + AssetHub: GENESIS.paseo_asset_hub, + Bulletin: GENESIS.paseo_bulletin, + People: "0xstale", + }, + }; + discoveryState.createChainClientCalls = []; + await getChainAPI("paseo"); + expect(discoveryState.createChainClientCalls.length).toBe(1); + }); + + test("identifiers the host refuses skip validation", async () => { + discoveryState.discovery = { + network: "paseo", + chains: { AssetHub: GENESIS.paseo_asset_hub }, + }; + discoveryState.createChainClientCalls = []; + await getChainAPI("paseo"); + expect(discoveryState.createChainClientCalls.length).toBe(1); + }); + + test("derived reserved environments still throw not-yet-available", async () => { + discoveryState.discovery = { + network: "polkadot", + chains: { AssetHub: GENESIS.polkadot_asset_hub }, + }; + await expect(getChainAPI()).rejects.toThrow("not yet available"); + }); } diff --git a/product-sdk/packages/host/src/accounts.ts b/product-sdk/packages/host/src/accounts.ts index 08ddc072..c1ea964d 100644 --- a/product-sdk/packages/host/src/accounts.ts +++ b/product-sdk/packages/host/src/accounts.ts @@ -44,6 +44,7 @@ import type { VersionedHostAccountGetError, VersionedHostAccountListRingVrfKeysError, VersionedHostAccountRegisterRingVrfKeyError, + VersionedHostAccountRingVrfSignError, VersionedHostAccountSignVrfError, VersionedHostGetLegacyAccountsError, VersionedHostGetUserIdError, @@ -288,6 +289,17 @@ export interface AccountsProvider { location: RingLocation, message: Uint8Array, ): ResultAsync>; + /** + * Sign `message` directly with an explicitly registered ring-VRF key. + * + * Unlike {@link createRingVRFProof} this proves nothing about ring + * membership; it is the plain signature under the member key, for + * protocols that carry their own proof. + */ + ringVrfSign( + keyHandle: RingVrfKeyHandle, + message: Uint8Array, + ): ResultAsync>; /** * Produce an sr25519 VRF signature from a product account (RFC-0023). * @@ -457,6 +469,14 @@ function adaptAccountsProvider(client: TrUApiClient): AccountsProvider { ringRevision: response.ringRevision, })); }, + ringVrfSign(keyHandle, message) { + return account + .ringVrfSign({ + keyHandle: keyHandle as unknown as ProductAccountId, + message: toHex(message), + }) + .map(fromHex); + }, signVrf(account_, transcriptLabel, items) { return account .signVrf({ @@ -583,6 +603,7 @@ if (import.meta.vitest) { getUserId: method("getUserId", { primaryUsername: "alice.dot" }), getAccount: method("getAccount", { account: { publicKey: "0xaa" } }), registerRingVrfKey: method("registerRingVrfKey", "0x0304"), + ringVrfSign: method("ringVrfSign", "0xba5eba11"), listRingVrfKeys: method("listRingVrfKeys", [ { handle: { @@ -761,6 +782,28 @@ if (import.meta.vitest) { expect(alias).toEqual({ context: fromHex("0x01"), alias: fromHex("0x02") }); }); + test("ringVrfSign passes the selected handle and decodes the signature", async () => { + const calls: Array<[string, unknown]> = []; + const provider = adaptAccountsProvider( + makeFakeClient({ onCall: (method, args) => calls.push([method, args]) }), + ); + const keys = await provider.listRingVrfKeys("people.dot").match( + (value) => value, + () => [], + ); + calls.length = 0; + const keyHandle = keys[1].handle; + const signature = await provider.ringVrfSign(keyHandle, new Uint8Array([1, 2, 3])).match( + (value) => value, + () => null, + ); + expect(calls[0]).toEqual([ + "ringVrfSign", + { keyHandle, message: toHex(new Uint8Array([1, 2, 3])) }, + ]); + expect(signature).toEqual(fromHex("0xba5eba11")); + }); + test("createRingVRFProof hex-encodes the message and decodes the proof response", async () => { const calls: Array<[string, unknown]> = []; const client = makeFakeClient({ onCall: (m, a) => calls.push([m, a]) }); diff --git a/product-sdk/packages/host/src/chain-discovery.ts b/product-sdk/packages/host/src/chain-discovery.ts new file mode 100644 index 00000000..ad6b4c40 --- /dev/null +++ b/product-sdk/packages/host/src/chain-discovery.ts @@ -0,0 +1,287 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Host chain discovery. Resolves chain roles to genesis hashes against the + * host's configured environment instead of hard-coding them. + * + * The wire method takes one identifier per call, so the facade fires one + * concurrent call per requested identifier and caches the combined result + * for the lifetime of the connection. Consumed internally by chain-client. + * Products normally never call this directly. + * + * @module + */ + +import type { + ChainIdentifier, + HexString, + TrUApiClient, + VersionedRemoteChainInfoError, + scale, +} from "@parity/truapi"; +import { createLogger } from "@parity/product-sdk-logger"; +import { formatHostError } from "./errors.js"; +import { getClient } from "./transport.js"; + +const log = createLogger("host"); + +/** + * Chain-role identifier. A closed protocol enum, not a free-form name. The + * host maps each role to the concrete chain of its configured environment. + */ +export type HostChainIdentifier = ChainIdentifier; + +/** The host's configured environment plus per-identifier resolved genesis hashes. */ +export interface HostChainDiscovery { + /** Ecosystem the host is configured for, e.g. `"polkadot"`, `"paseo"`. */ + network: string; + /** Present for every requested identifier the host serves. */ + chains: Partial>; +} + +/** Error channel of `chain.getChainInfo`. */ +type GetChainInfoError = scale.CallErrorValue; + +/** + * Marks a transient probe failure. These are evicted from the cache so the + * next call re-probes. Stable "no discovery" answers stay cached. + */ +const TRANSIENT_FAILURE = Symbol("transient-failure"); + +/** + * Hosts that predate the wire-id reservation never answer the probe at all, + * so a silent host must resolve as "no discovery" instead of hanging. The + * answer comes from host config with no chain I/O, so a short deadline is + * enough. A timeout is treated as transient, never cached: a host that + * supports discovery but started slowly would otherwise be recorded as + * pre-discovery for the life of the client. The cost is that a genuinely + * legacy host pays the deadline again on the next call. + */ +const PROBE_TIMEOUT_MS = 3_000; + +const discoveryCache = new WeakMap>>(); + +/** + * Resolve chain roles against the current host. + * + * Returns `null` when discovery is unavailable: outside a container, on a + * legacy host, or when the host serves none of the requested identifiers. + * Callers treat `null` as "fall back to configured constants". + * + * One concurrent `getChainInfo` call is made per identifier. Identifiers + * the host answers `NotSupported` for are absent from `chains`. Stable + * answers are cached per client and identifier set. Unexpected wire failures + * and probe timeouts are logged, return `null` and are not cached, so a later + * call re-probes. + */ +export async function getHostChainInfo( + identifiers: readonly HostChainIdentifier[], +): Promise { + const client = await getClient(); + if (!client) return null; + let bySet = discoveryCache.get(client); + if (!bySet) { + bySet = new Map(); + discoveryCache.set(client, bySet); + } + const key = [...identifiers].sort().join(","); + let cached = bySet.get(key); + if (!cached) { + cached = fetchChainInfo(client, identifiers).then((result) => { + if (result === TRANSIENT_FAILURE) { + // Evict so the next caller re-probes. + bySet.delete(key); + return null; + } + return result; + }); + bySet.set(key, cached); + } + return cached; +} + +async function fetchChainInfo( + client: TrUApiClient, + identifiers: readonly HostChainIdentifier[], +): Promise { + try { + let timer: ReturnType | undefined; + const probe = Promise.all( + identifiers.map((id) => + client.chain.getChainInfo({ chain: id }).match( + (value) => ({ id, ok: value }) as const, + (error) => ({ id, err: error }) as const, + ), + ), + ); + const outcomes = await Promise.race([ + probe, + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), PROBE_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timer)); + if (outcomes === "timeout") { + log.warn("getChainInfo probe timed out, treating the host as pre-discovery for now"); + return TRANSIENT_FAILURE; + } + let network: string | undefined; + const chains: Partial> = {}; + for (const outcome of outcomes) { + if ("ok" in outcome) { + network = outcome.ok.network; + chains[outcome.id] = outcome.ok.genesisHash; + continue; + } + // "Unsupported" means the host predates the method entirely. + // "NotSupported" means this one identifier is not served. + if (outcome.err.tag === "Unsupported") return null; + if (isNotSupported(outcome.err)) continue; + log.warn(`getChainInfo failed: ${formatHostError(outcome.err)}`); + return TRANSIENT_FAILURE; + } + // Every identifier was refused, so the host never revealed its network. + if (network === undefined) return null; + return { network, chains }; + } catch (error) { + log.warn(`getChainInfo failed: ${formatHostError(error)}`); + return TRANSIENT_FAILURE; + } +} + +/** True when a domain error is the unit `NotSupported` variant. */ +function isNotSupported(error: GetChainInfoError): boolean { + return error.tag === "Domain" && error.value.value.tag === "NotSupported"; +} + +if (import.meta.vitest) { + const { test, expect, afterEach, vi } = import.meta.vitest; + const { setTruApiClient } = await import("./transport.js"); + type ChainInfoResponse = import("@parity/truapi").RemoteChainInfoResponse; + + type Served = Partial>; + + const NOT_SUPPORTED: GetChainInfoError = { + tag: "Domain", + value: { tag: "V1", value: { tag: "NotSupported" } }, + }; + + type FakeBehavior = { network: string; served: Served } | { err: GetChainInfoError }; + + /** Fake client answering from a served-chains map or a per-call behavior function. */ + function fakeClient( + behavior: FakeBehavior | ((call: number) => FakeBehavior), + calls: { count: number; requests: HostChainIdentifier[] } = { count: 0, requests: [] }, + ): TrUApiClient { + return { + chain: { + getChainInfo: (request: { chain: HostChainIdentifier }) => { + const current = + typeof behavior === "function" ? behavior(calls.count) : behavior; + calls.count += 1; + calls.requests.push(request.chain); + return { + match: async ( + onOk: (v: ChainInfoResponse) => A, + onErr: (e: GetChainInfoError) => B, + ) => { + if ("err" in current) return onErr(current.err); + const genesisHash = current.served[request.chain]; + if (!genesisHash) return onErr(NOT_SUPPORTED); + return onOk({ + network: current.network, + chain: request.chain, + genesisHash, + }); + }, + }; + }, + }, + } as unknown as TrUApiClient; + } + + const PASEO_SERVED: Served = { + AssetHub: "0xaa" as HexString, + Bulletin: "0xbb" as HexString, + People: "0xcc" as HexString, + }; + + afterEach(() => { + setTruApiClient(null); + vi.restoreAllMocks(); + }); + + test("resolves each requested identifier once, unserved ones absent", async () => { + const calls = { count: 0, requests: [] as HostChainIdentifier[] }; + setTruApiClient( + fakeClient({ network: "paseo", served: { AssetHub: "0xaa" as HexString } }, calls), + ); + const result = await getHostChainInfo(["AssetHub", "Bulletin", "People"]); + expect(result).toEqual({ network: "paseo", chains: { AssetHub: "0xaa" } }); + expect(calls.requests).toEqual(["AssetHub", "Bulletin", "People"]); + }); + + test("returns null when discovery is unavailable and caches the answer", async () => { + // Outside a container. + expect(await getHostChainInfo(["AssetHub"])).toBeNull(); + // Every identifier refused, so the network is never revealed. + const refused = { count: 0, requests: [] as HostChainIdentifier[] }; + setTruApiClient(fakeClient({ network: "devnet", served: {} }, refused)); + expect(await getHostChainInfo(["Bulletin"])).toBeNull(); + expect(await getHostChainInfo(["Bulletin"])).toBeNull(); + expect(refused.count).toBe(1); + // Legacy host answering Unsupported. + const legacy = { count: 0, requests: [] as HostChainIdentifier[] }; + setTruApiClient(fakeClient({ err: { tag: "Unsupported" } }, legacy)); + expect(await getHostChainInfo(["AssetHub"])).toBeNull(); + expect(await getHostChainInfo(["AssetHub"])).toBeNull(); + expect(legacy.count).toBe(1); + }); + + test("caches per client and identifier set, ignoring order", async () => { + const calls = { count: 0, requests: [] as HostChainIdentifier[] }; + setTruApiClient(fakeClient({ network: "paseo", served: PASEO_SERVED }, calls)); + await getHostChainInfo(["AssetHub", "Bulletin"]); + await getHostChainInfo(["Bulletin", "AssetHub"]); + expect(calls.count).toBe(2); + await getHostChainInfo(["People"]); + expect(calls.count).toBe(3); + }); + + test("a silent host times out to null and the next call re-probes", async () => { + vi.useFakeTimers(); + try { + const calls = { count: 0 }; + setTruApiClient({ + chain: { + getChainInfo: () => { + calls.count += 1; + return { match: () => new Promise(() => {}) }; + }, + }, + } as unknown as TrUApiClient); + const pending = getHostChainInfo(["AssetHub"]); + await vi.advanceTimersByTimeAsync(3_000); + expect(await pending).toBeNull(); + // A host that merely started slowly must not stay classified as + // pre-discovery, so the timeout is never cached. + const retry = getHostChainInfo(["AssetHub"]); + await vi.advanceTimersByTimeAsync(3_000); + expect(await retry).toBeNull(); + expect(calls.count).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + test("transient wire failures return null but are not cached, the next call re-probes", async () => { + setTruApiClient( + fakeClient((call) => + call === 0 + ? { err: { tag: "HostFailure", value: { reason: "boom" } } } + : { network: "paseo", served: PASEO_SERVED }, + ), + ); + expect(await getHostChainInfo(["AssetHub"])).toBeNull(); + expect((await getHostChainInfo(["AssetHub"]))?.network).toBe("paseo"); + }); +} diff --git a/product-sdk/packages/host/src/index.ts b/product-sdk/packages/host/src/index.ts index ea99bb29..aa5d4b9b 100644 --- a/product-sdk/packages/host/src/index.ts +++ b/product-sdk/packages/host/src/index.ts @@ -53,6 +53,10 @@ export type { RemotePermission, } from "./truapi.js"; +// Host chain discovery. +export { getHostChainInfo } from "./chain-discovery.js"; +export type { HostChainDiscovery, HostChainIdentifier } from "./chain-discovery.js"; + // Result type + typed host errors (the throw→Result boundary) export { ok, err } from "./result.js"; export type { Result } from "./result.js"; @@ -76,9 +80,9 @@ export type { ProductAccountLookup, ContextualAlias, ProductProofContext, + RegisteredRingVrfKey, RingLocation, RingVRFProof, - RegisteredRingVrfKey, RingVrfKeyDisclosure, RingVrfKeyHandle, RingVrfPublicKey, diff --git a/product-sdk/packages/host/src/testing.ts b/product-sdk/packages/host/src/testing.ts index 5405dcd5..c17e5f27 100644 --- a/product-sdk/packages/host/src/testing.ts +++ b/product-sdk/packages/host/src/testing.ts @@ -10,17 +10,21 @@ * makes a default `SignerManager`, `local-storage` auto-detection, and the * `statement-store` / `cloud-storage` host paths testable. * - * Not modeled: the PAPI `chain` JSON-RPC surface behind `getHostProvider()` — - * there's no chain-read fake, by design; the host owns RPC selection — and the - * `chat` / `coinPayment` / `entropy` / `notifications` / `payment` / - * `permissions` / `resourceAllocation` / `theme` domains. Touching an unmodeled - * domain throws a descriptive error rather than failing with `undefined is not - * a function`. + * Of the `chain` domain only `getChainInfo` is modeled, so host chain discovery + * (and `getChainAPI()` on top of it) resolves in tests; see the `chainInfo` + * option. Not modeled: the rest of the PAPI `chain` JSON-RPC surface behind + * `getHostProvider()` — there's no chain-read fake, by design; the host owns RPC + * selection — and the `chat` / `coinPayment` / `entropy` / `notifications` / + * `payment` / `permissions` / `resourceAllocation` / `theme` domains. Touching + * an unmodeled domain throws a descriptive error rather than failing with + * `undefined is not a function`. * * @packageDocumentation */ import type { ObservableLike, Observer, Subscription, TrUApiClient } from "@parity/truapi"; -import { okAsync } from "neverthrow"; +import { errAsync, okAsync } from "neverthrow"; + +import type { HostChainIdentifier } from "./chain-discovery.js"; import { setTruApiClient } from "./transport.js"; @@ -91,10 +95,17 @@ function oneShotObservable(item: Item): ObservableLike { * member access throws with a pointer here, instead of the bare TypeError an * empty stub would give. The empty-object cast is the one concession a Proxy * needs; every modeled domain is checked structurally. + * + * `modeled` carries the members that _are_ implemented, for a domain where the + * fake covers some calls but not the whole surface. */ -function notModeled(domain: D): PublicSurface { - return new Proxy({} as PublicSurface, { - get(_target, member) { +function notModeled( + domain: D, + modeled?: Partial>, +): PublicSurface { + return new Proxy((modeled ?? {}) as PublicSurface, { + get(target, member) { + if (member in target) return target[member as keyof typeof target]; // Stay quiet for inspection probes (console.log, await-resolution). if (typeof member === "symbol" || member === "then") return undefined; throw new Error( @@ -114,6 +125,22 @@ function preimageKey(hexValue: string): `0x${string}` { return `0x${(h >>> 0).toString(16).padStart(8, "0")}`; } +/** + * What a fake host reports through `chain.getChainInfo`: the network id it is + * configured for, and a genesis hash per chain role it serves. + * + * Roles left out are answered `NotSupported`, exactly as a host that does not + * serve them. Use the genesis hashes the descriptors expose (e.g. + * `paseo_asset_hub.genesis`) if the test drives `getChainAPI()`, since the + * environment is derived by matching the asset hub genesis against the bundle. + */ +export interface FakeChainInfo { + /** Ecosystem the fake host claims, e.g. `"paseo"`. */ + network: string; + /** Genesis hash per chain role served. */ + chains: Partial>; +} + /** Options for {@link createFakeTruApiClient}. */ export interface CreateFakeTruApiClientOptions { /** `account.getUserId` primary username. Default `"alice.dot"`. */ @@ -130,6 +157,12 @@ export interface CreateFakeTruApiClientOptions { legacyAccounts?: Array<{ publicKey: Uint8Array; name: string }>; /** Seed the in-memory preimage store, keyed by the `0x` preimage key. */ preimages?: Record; + /** + * What `chain.getChainInfo` reports. Omit to model a host predating chain + * discovery: the call is refused as `Unsupported`, so `getHostChainInfo` + * resolves `null` and `getChainAPI` needs an explicit environment. + */ + chainInfo?: FakeChainInfo; } /** @@ -143,6 +176,7 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): const publicKey = toHex(options?.publicKey ?? new Uint8Array(32).fill(0x11)); const signature = toHex(options?.signature ?? new Uint8Array(64).fill(0x22)); const chainSupported = options?.chainSupported ?? true; + const chainInfo = options?.chainInfo; // Real in-memory KV (hex values) so getHostLocalStorage()/createLocalKvStore() round-trip. const kv = new Map(); @@ -180,9 +214,6 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): getUserId: () => okAsync({ primaryUsername }), requestLogin: () => okAsync("Success"), getAccount: () => okAsync({ account: { publicKey } }), - registerRingVrfKey: () => okAsync(publicKey), - listRingVrfKeys: () => okAsync([]), - ringVrfSign: () => okAsync(signature), getAccountAlias: () => okAsync({ context: toHex(new Uint8Array([1])), alias: toHex(new Uint8Array([2])) }), getLegacyAccounts: () => okAsync({ accounts: legacyAccounts }), @@ -196,6 +227,9 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): ringIndex: 0, ringRevision: 0, }), + registerRingVrfKey: () => okAsync(publicKey), + listRingVrfKeys: () => okAsync([]), + ringVrfSign: () => okAsync(signature), signVrf: () => okAsync({ preOutput: publicKey, proof: signature }), connectionStatusSubscribe: () => inertObservable(), }, @@ -229,7 +263,20 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): return okAsync(key); }, }, - chain: notModeled("chain"), + // Only `getChainInfo` is modeled; the rest of the domain still throws. + chain: notModeled("chain", { + getChainInfo: ({ chain }) => { + if (!chainInfo) return errAsync({ tag: "Unsupported" } as const); + const genesisHash = chainInfo.chains[chain]; + if (!genesisHash) { + return errAsync({ + tag: "Domain", + value: { tag: "V1", value: { tag: "NotSupported" } }, + } as const); + } + return okAsync({ network: chainInfo.network, chain, genesisHash }); + }, + }), chat: notModeled("chat"), coinPayment: notModeled("coinPayment"), entropy: notModeled("entropy"), @@ -320,6 +367,7 @@ if (import.meta.vitest) { "./container.js" ); const { getAccountsProvider } = await import("./accounts.js"); + const { getHostChainInfo } = await import("./chain-discovery.js"); const { getPreimageManager } = await import("./truapi.js"); const lookupOnce = ( @@ -383,6 +431,30 @@ if (import.meta.vitest) { expect(signature?.proof).toHaveLength(64); }); + test("chain discovery is refused by default, so a legacy host is modeled", async () => { + createFakeHost(); + // Refused, not unmodeled: a bare `getChainAPI("paseo")` in a consumer + // test must not warn about the fake on every call. + expect(await getHostChainInfo(["AssetHub"])).toBeNull(); + }); + + test("chainInfo drives discovery, with unserved roles left out", async () => { + createFakeHost({ + chainInfo: { network: "paseo", chains: { AssetHub: "0xaa", Bulletin: "0xbb" } }, + }); + expect(await getHostChainInfo(["AssetHub", "Bulletin", "People"])).toEqual({ + network: "paseo", + chains: { AssetHub: "0xaa", Bulletin: "0xbb" }, + }); + }); + + test("the rest of the chain domain still reports itself unmodeled", async () => { + const host = createFakeHost(); + expect(() => (host.client.chain as { chainName?: unknown }).chainName).toThrow( + /not modeled by the fake/, + ); + }); + test("statement store resolves", async () => { createFakeHost(); expect(await getStatementStore()).not.toBeNull(); diff --git a/product-sdk/packages/host/src/truapi.ts b/product-sdk/packages/host/src/truapi.ts index 7695e735..b2a7b7da 100644 --- a/product-sdk/packages/host/src/truapi.ts +++ b/product-sdk/packages/host/src/truapi.ts @@ -184,7 +184,8 @@ export async function createHostPreimageManager(): Promise ```typescript import { getChainAPI } from "@parity/product-sdk-chain-client"; -const client = await getChainAPI("paseo"); +// Inside a container, omit the environment: the host reports which chains it +// serves and the matching descriptors are loaded for you. +const client = await getChainAPI(); + +// Pin it explicitly when you need a specific environment, or when the host +// predates chain discovery (where the zero-arg form throws). +const paseo = await getChainAPI("paseo"); // Query balance const account = await client.assetHub.query.System.Account.getValue( @@ -48,7 +54,7 @@ client.destroy(); | | `getChainAPI` (Preset) | `createChainClient` (BYOD) | |---|---|---| -| **When** | Known environments (paseo, polkadot, kusama) | Custom chains or a subset of chains | +| **When** | Known environments (paseo, devnet; polkadot and kusama reserved), or whatever the host reports when called with no argument | Custom chains or a subset of chains | | **Descriptors** | Built-in, lazy-loaded | You import and provide them | | **Chains** | Always assetHub + bulletin + individuality | Any combination you choose | | **Bundle size** | Slightly larger (~6.3 MB for all 3 chains) | Minimal (only what you import) | @@ -128,6 +134,14 @@ const runtime = createContractRuntime(client.raw.assetHub, { atBest: true }); | polkadot (mainnet) | Planned | Planned | Planned | | kusama (canary) | Planned | Planned | Planned | +Call `getChainAPI()` with no argument to derive the environment from the host instead of +hard-coding it. The host reports a genesis hash per chain role (`AssetHub`, `Bulletin`, +`People`) and the environment is the bundle whose asset hub genesis matches, so a host on +its own network id still resolves correctly. An explicit environment is cross-checked the +same way: it throws `EnvironmentMismatchError` when it is not the network the host runs, +and `GenesisMismatchError` when a bundled descriptor's genesis disagrees with the host +(a stale descriptor bundle, for example after a testnet reset). + > **`"paseo"` is not the public Paseo testnet.** It targets the Paseo Next v2 chain > instances (`*-next-*.polkadot.io`). For the community-run products devnet on the > long-lived Paseo testnet system chains (Asset Hub 1000, People 1004, Bulletin 1010), diff --git a/product-sdk/skills/product-sdk-chain-connection/references/chain-client-api.md b/product-sdk/skills/product-sdk-chain-connection/references/chain-client-api.md index 0656e049..54c0e7c3 100644 --- a/product-sdk/skills/product-sdk-chain-connection/references/chain-client-api.md +++ b/product-sdk/skills/product-sdk-chain-connection/references/chain-client-api.md @@ -7,20 +7,36 @@ Package: `@parity/product-sdk-chain-client` Create a chain client for a preset environment with zero configuration. ```typescript +// Derive the environment from the host via chain discovery +async function getChainAPI(): Promise>> +// Or pin it explicitly async function getChainAPI(env: E): Promise>> ``` **Parameters:** -- `env` - Environment name: `"paseo"`, `"polkadot"`, or `"kusama"` +- `env` - Environment name: `"paseo"`, `"devnet"`, `"polkadot"`, or `"kusama"`. Omit it to + derive the environment from the host, by matching the host's asset hub genesis hash + against the bundled descriptors. The zero-arg form is the recommended path inside a + container; it is typed with the `"paseo"` shape, while the descriptors loaded at + runtime always follow the host. **Returns:** A `ChainClient` with typed APIs for all chains in the environment. -**Throws:** If the environment is not yet available (only `"paseo"` is currently supported). +**Throws:** +- If the environment is not yet available (`"paseo"` and `"devnet"` are live today). +- `EnvironmentMismatchError` if the explicit environment is not the one the host runs. +- `GenesisMismatchError` if a bundled descriptor's genesis hash disagrees with the host, + for example after a testnet reset with a stale descriptor bundle. +- A plain `Error` if the zero-arg form is used where discovery is unavailable: outside a + container, or on a host predating discovery. Pass an explicit environment there. ```typescript import { getChainAPI } from "@parity/product-sdk-chain-client"; -const client = await getChainAPI("paseo"); +// Inside a container, let the host pick the environment +const client = await getChainAPI(); + +// Or pin it: await getChainAPI("paseo") // Access typed APIs client.assetHub.query.System.Account.getValue(address);