diff --git a/docs/rfcs/0026-supported-chains.md b/docs/rfcs/0026-supported-chains.md new file mode 100644 index 00000000..3d7096c2 --- /dev/null +++ b/docs/rfcs/0026-supported-chains.md @@ -0,0 +1,151 @@ +--- +title: "Host chain discovery and name resolution" +owner: "@valentinfernandez1" +--- + +# RFC 0026: Host chain discovery and name resolution + +| | | +| --------------- | -------------------------------------------------------------------------------------------------- | +| **RFC Number** | 26 | +| **Start Date** | 2026-08-06 | +| **Description** | A `Chain` method resolving protocol-defined chain identifiers to genesis hashes against the host's environment. | +| **Authors** | Valentin Fernandez | + +## Summary + +Add one method to the `Chain` trait. `get_chain_info` takes one chain identifier, drawn from a closed role enum (`Relay`, `AssetHub`, `People`, `Bulletin`), and returns the ecosystem the host is configured for (for example `"paseo"`) plus the chain's genesis hash, resolved against that environment. It is answered in-core from a single new platform syscall, so each host implements exactly one callback over configuration it already has. Products needing several chains issue concurrent calls; the transport multiplexes them over one round trip. + +## Motivation + +Every chain-scoped TrUAPI call is keyed by `genesisHash`, and today products obtain those hashes by hard-coding them: `@parity/truapi` ships constants like `PASEO_NEXT_V2_ASSET_HUB` in `well-known-chains.ts`, and the product SDK carries its own `WellKnownChain` table. Hard-coded hashes fail in three recurring ways. + +**Testnet wipes.** When a testnet is wiped or restarted its genesis hash changes. Every product's baked-in constant goes stale at once, and nothing recovers until each product ships a new bundle with the new hash. The host already knows the new hash the moment its own configuration updates, but products have no way to ask for it. + +**Guessing the host's network.** A product cannot ask which environment the host is on, so it guesses. If the product assumes one network and the host is configured for another, every chain call fails at runtime with no better diagnostic than an unsupported genesis hash. + +**Environment moves.** Pointing a product at a different environment (a new testnet iteration, a devnet) means editing constants and shipping a new build, even though the host-side change is a config edit. + +The fix is to make the host's chain set discoverable over the wire. Hosts already hold this data in enumerable form: dotli's network config has named slots (`relay`, `assethub`, `bulletin`, `people`) per environment, each with a genesis hash and RPC endpoints. This RFC exposes that mapping to products. Tracking issue: [paritytech/truapi#352](https://github.com/paritytech/truapi/issues/352). + +## Detailed Design + +### `chain.getChainInfo` + +```rust +/// Resolve a chain identifier to its genesis hash against the host's +/// configured environment. +/// +/// ```ts +/// const result = await truapi.chain.getChainInfo({ +/// chain: "AssetHub", +/// }); +/// assert(result.isOk(), "getChainInfo failed:", result); +/// console.log("network:", result.value.network); +/// console.log("asset hub genesis:", result.value.genesisHash); +/// ``` +#[wire(request_id = 166)] +async fn get_chain_info( + &self, + _cx: &CallContext, + _request: RemoteChainInfoRequest, +) -> Result> { + Err(CallError::unavailable()) +} +``` + +```rust +/// Role of a chain within the host's configured environment. +enum ChainIdentifier { + /// The relay chain. + Relay, + /// The asset hub system chain. + AssetHub, + /// The people chain. + People, + /// The bulletin chain. + Bulletin, +} + +/// Request to resolve one chain identifier against the host's environment. +struct RemoteChainInfoRequest { + /// Chain to resolve. + chain: ChainIdentifier, +} + +/// Response carrying the resolved chain data. +struct RemoteChainInfoResponse { + /// Ecosystem the host is configured for, e.g. "polkadot", "kusama", "paseo". + network: String, + /// Chain this response resolves, echoed from the request. + chain: ChainIdentifier, + /// Genesis hash identifying the chain in all chain-scoped calls. + genesis_hash: [u8; 32], +} + +/// Error from get_chain_info. +enum RemoteChainInfoError { + /// The host does not serve the requested chain. + NotSupported, + /// Catch-all. + Unknown(GenericError), +} +``` + +`ChainIdentifier` is a closed protocol enum of chain roles, not chain instances; the host maps each role to the concrete chain of its configured environment. Adding a new role is an additive enum variant. The method resolves one identifier per call; the transport multiplexes concurrent requests, so a product needing several chains resolves them in parallel with no extra round trips and no batching semantics in the protocol. + +The request deliberately carries no network selector. A product does not get to choose which network it operates on; the host is configured for exactly one environment (polkadot in production), and the identifier resolves against that. Asking the product to name the environment would reintroduce the guessing this RFC removes. + +### Semantics and invariants + +- **Serviceability.** A genesis hash returned by `get_chain_info` is a chain the host will serve `chain.*` and `signing.*` calls for. A `NotSupported` identifier will not be served. +- **Stability.** An identifier resolves to the same chain for the lifetime of a connection. There is no subscription; a product observes host-side changes (such as a testnet wipe) by reconnecting. + +`network` is informational, not a selector: it tells a product or SDK which environment the host is running, so tooling can derive the environment from the host instead of asking the developer to configure it. It is an open ecosystem string ("polkadot", "kusama", "paseo", "devnet"), not a `Mainnet`/`Testnet` enum, because a binary flag cannot distinguish two testnets. + +The response echoes the requested identifier so a response is self-describing in logs and debugging tools rather than only meaningful next to the request that produced it. It deliberately excludes host-assigned name strings, display names, and token properties: the identifier already keys the chain unambiguously, and once a product holds the genesis hash the display metadata is reachable through `getSpecChainName` and `getSpecProperties`. + +### Typical product flow + +```ts +const [assetHub, people] = await Promise.all([ + truapi.chain.getChainInfo({ chain: "AssetHub" }), + truapi.chain.getChainInfo({ chain: "People" }), +]); +assert(assetHub.isOk() && people.isOk(), "getChainInfo failed"); + +const name = await truapi.chain.getSpecChainName({ genesisHash: assetHub.value.genesisHash }); +assert(name.isOk(), "getSpecChainName failed:", name); +console.log(`connected to ${name.value.chainName} on ${assetHub.value.network}`); +``` + +The product never embeds a hash. After a testnet wipe the host updates its config, the product reconnects, and the same code path picks up the new hash. + +### Implementation shape + +The core does not own the chain set. `system.featureSupported(Chain { genesis_hash })` is already a thin shim in `rust/crates/truapi-server/src/host_logic/features.rs` delegating to `truapi_platform::Features`, and `ChainProvider::connect(genesis_hash)` opens JSON-RPC pipes on demand. This RFC follows the same delegation pattern: + +- `truapi-platform` gains one syscall on `Features` returning the host's network string and its full identifier-to-chain mapping. +- `truapi-server` answers `get_chain_info` in-core from that syscall, resolving the requested identifier and mapping a miss to `NotSupported`. + +Hosts therefore implement exactly one callback, backed by configuration they already maintain. dotli's per-environment named slots (`relay`, `assethub`, `bulletin`, `people`, each with a genesis hash) map one-to-one onto `ChainIdentifier` variants; the iOS `TrUAPIHost` and the host CLI expose their equivalent config the same way. + +The change is purely additive: one new method with a fresh wire id, no changes to existing calls or types. Existing products keep working unchanged, including their hard-coded constants, and can migrate at their own pace. + +## Non-goals + +- Changing the `genesisHash` parameter on existing chain-scoped calls. Genesis hashes stay the wire-level chain identifier everywhere else. +- Product SDK integration. The SDK will wrap these calls behind its own chain-selection API in its own repo, hiding the raw methods from application code. + +## Drawbacks + +- Adding a new chain role requires a protocol release (an additive `ChainIdentifier` variant) and host support for it. The closed enum trades that coupling for typo-proof, host-portable identifiers. +- No change notification. A host that reconfigures mid-session cannot inform connected products; they observe the change only on reconnect. This keeps the API subscription-free and matches how host config changes actually roll out (host restarts). + +## Alternatives + +- **Take chain names instead of `genesisHash` in every chain-scoped call.** This was discarded as it is a breaking change across the Rust trait, codegen, the TS client, dotli, the iOS host, and the product SDK. The genesis hash also remains necessary internally, since connections are keyed by it and signed payloads embed it via `CheckGenesis`. +- **Separate discovery and lookup methods (`getSupportedChains` + `resolveChain`).** This was discarded during review: a product that needs one chain should not fetch and filter the host's full mapping. +- **A batch request (`chains: Vec`).** This was discarded during review: the transport multiplexes concurrent requests, so batching adds ordering and partial-failure semantics without saving a round trip. A generalized batching layer, if ever needed, belongs to the transport and would cover every method. +- **Free-form string identifiers.** This was discarded because names minted by host configuration form a de facto registry with no governance: two hosts could name the same chain differently, and typos fail only at runtime. The closed role enum is typo-proof, identical across hosts, and versioned with the protocol. +- **A `network` selector on the request.** This was discarded because the product does not choose its network, the host's configuration does. Asking the product to name the environment would make it encode the environment again, which is the hard-coding this RFC removes. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 15b95f4e..105af6bd 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -25,3 +25,4 @@ created: 2026-03-13 | 0021 | [Add Coins variant to PaymentTopUpSource](0021-payment-topup-coins.md) | accepted | @filippovecchiato | — | | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | +| 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/truapi/pull/354) | diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index b11224ef..a65471f8 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -598,7 +598,7 @@ where }); } { - let host = host; + let host = host.clone(); dispatcher.on_request(wire_table::CHAIN_STOP_TRANSACTION, move |request_id: String, bytes: Vec| { let host = host.clone(); Box::pin(async move { @@ -625,6 +625,34 @@ where }) }); } + { + let host = host; + dispatcher.on_request(wire_table::CHAIN_GET_CHAIN_INFO, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainInfoRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainInfoResponse = match host.get_chain_info(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } } fn register_chat

(dispatcher: &mut Dispatcher, host: Arc

) diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d042..5c32c3c1 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -466,6 +466,12 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `chain_get_chain_info`. +pub const CHAIN_GET_CHAIN_INFO: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +735,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "chain_get_chain_info", + kind: WireKind::Request(CHAIN_GET_CHAIN_INFO), + }, ]; diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 1cefce40..fa1b9a0f 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -718,7 +718,7 @@ where }); } { - let host = host; + let host = host.clone(); dispatcher.on_request(wire_table::CHAIN_STOP_TRANSACTION, move |request_id: String, bytes: Vec| { let host = host.clone(); Box::pin(async move { @@ -745,6 +745,42 @@ where }) }); } + { + let host = host; + dispatcher.on_request( + wire_table::CHAIN_GET_CHAIN_INFO, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainInfoRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainInfoError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainInfoResponse = + match host.get_chain_info(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } } fn register_chat

(dispatcher: &mut Dispatcher, host: Arc

) diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d042..5c32c3c1 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -466,6 +466,12 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `chain_get_chain_info`. +pub const CHAIN_GET_CHAIN_INFO: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +735,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "chain_get_chain_info", + kind: WireKind::Request(CHAIN_GET_CHAIN_INFO), + }, ]; diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 779408e4..28a7f478 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -67,7 +67,8 @@ pub trait Account: Send + Sync { /// Retrieve the contextual alias for a context and ring. /// /// ```ts - /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// const people = await truapi.chain.getChainInfo({ chain: "People" }); + /// assert(people.isOk(), "getChainInfo failed:", people); /// /// const PEOPLE_COLLECTION_ID = /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; @@ -75,7 +76,7 @@ pub trait Account: Send + Sync { /// const result = await truapi.account.getAccountAlias({ /// context: { productId: "truapi-playground.dot", suffix: { tag: "Index", value: 0 } }, /// ringLocation: { - /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// chainId: people.value.genesisHash, /// junctions: [ /// { tag: "PalletInstance", value: 67 }, /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, @@ -97,7 +98,8 @@ pub trait Account: Send + Sync { /// Generate a ring VRF proof; the host selects the member key for the ring. /// /// ```ts - /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// const people = await truapi.chain.getChainInfo({ chain: "People" }); + /// assert(people.isOk(), "getChainInfo failed:", people); /// /// const PEOPLE_COLLECTION_ID = /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; @@ -105,7 +107,7 @@ pub trait Account: Send + Sync { /// const result = await truapi.account.createAccountProof({ /// context: { productId: "truapi-playground.dot", suffix: { tag: "Index", value: 0 } }, /// ringLocation: { - /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// chainId: people.value.genesisHash, /// junctions: [ /// { tag: "PalletInstance", value: 67 }, /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index a37a7cf1..f08dbf53 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -9,6 +9,7 @@ use crate::versioned::chain::{ RemoteChainHeadStopOperationRequest, RemoteChainHeadStopOperationResponse, RemoteChainHeadStorageError, RemoteChainHeadStorageRequest, RemoteChainHeadStorageResponse, RemoteChainHeadUnpinError, RemoteChainHeadUnpinRequest, RemoteChainHeadUnpinResponse, + RemoteChainInfoError, RemoteChainInfoRequest, RemoteChainInfoResponse, RemoteChainSpecChainNameError, RemoteChainSpecChainNameRequest, RemoteChainSpecChainNameResponse, RemoteChainSpecGenesisHashError, RemoteChainSpecGenesisHashRequest, RemoteChainSpecGenesisHashResponse, @@ -27,15 +28,16 @@ pub trait Chain: Send + Sync { /// Follow the chain head and receive block events. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, from } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const item = await firstValueFrom( /// from( /// truapi.chain.followHeadSubscribe({ /// request: { - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// withRuntime: false, /// }, /// }), @@ -55,13 +57,14 @@ pub trait Chain: Send + Sync { /// Fetch a block header. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId, hash }) => /// truapi.chain.getHeadHeader({ genesisHash, followSubscriptionId, hash }), @@ -83,13 +86,14 @@ pub trait Chain: Send + Sync { /// Fetch a block body. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId, hash }) => /// truapi.chain.getHeadBody({ genesisHash, followSubscriptionId, hash }), @@ -111,13 +115,14 @@ pub trait Chain: Send + Sync { /// Query runtime storage at a specific block. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId, hash }) => /// truapi.chain.getHeadStorage({ @@ -144,13 +149,14 @@ pub trait Chain: Send + Sync { /// Invoke a runtime call at a specific block. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// withRuntime: true, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId, hash }) => @@ -179,13 +185,14 @@ pub trait Chain: Send + Sync { /// Release pinned blocks. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId, hash }) => /// truapi.chain.unpinHead({ @@ -211,13 +218,14 @@ pub trait Chain: Send + Sync { /// Continue a paused chain-head operation. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId }) => /// truapi.chain.continueHead({ @@ -243,13 +251,14 @@ pub trait Chain: Send + Sync { /// Stop a chain-head operation. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; - /// /// import { firstValueFrom, mergeMap } from "rxjs"; /// + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); + /// /// const result = await firstValueFrom( /// withChainHeadFollow({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }).pipe( /// mergeMap(({ genesisHash, followSubscriptionId }) => /// truapi.chain.stopHeadOperation({ @@ -276,10 +285,11 @@ pub trait Chain: Send + Sync { /// Fetch the canonical genesis hash for a chain. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.chain.getSpecGenesisHash({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }); /// assert(result.isOk(), "getSpecGenesisHash failed:", result); /// console.log("genesis hash:", result.value); @@ -297,10 +307,11 @@ pub trait Chain: Send + Sync { /// Fetch the display name of a chain. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.chain.getSpecChainName({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }); /// assert(result.isOk(), "getSpecChainName failed:", result); /// console.log("chain name:", result.value); @@ -317,10 +328,11 @@ pub trait Chain: Send + Sync { /// Fetch the JSON-encoded properties of a chain. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.chain.getSpecProperties({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }); /// assert(result.isOk(), "getSpecProperties failed:", result); /// console.log("chain properties:", result.value); @@ -337,10 +349,11 @@ pub trait Chain: Send + Sync { /// Broadcast a signed transaction. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.chain.broadcastTransaction({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// transaction: "0x", /// }); /// assert(result.isOk(), "broadcastTransaction failed:", result); @@ -361,10 +374,11 @@ pub trait Chain: Send + Sync { /// Stop a transaction broadcast. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const broadcast = await truapi.chain.broadcastTransaction({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// transaction: "0x", /// }); /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); @@ -374,7 +388,7 @@ pub trait Chain: Send + Sync { /// ); /// /// const result = await truapi.chain.stopTransaction({ - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// operationId: broadcast.value.operationId, /// }); /// assert(result.isOk(), "stopTransaction failed:", result); @@ -389,4 +403,24 @@ pub trait Chain: Send + Sync { { Err(CallError::unavailable()) } + + /// Resolve a chain identifier to its genesis hash against the host's + /// configured environment (RFC 0026). + /// + /// ```ts + /// const result = await truapi.chain.getChainInfo({ + /// chain: "AssetHub", + /// }); + /// assert(result.isOk(), "getChainInfo failed:", result); + /// console.log("network:", result.value.network); + /// console.log("asset hub genesis:", result.value.genesisHash); + /// ``` + #[wire(request_id = 166)] + async fn get_chain_info( + &self, + _cx: &CallContext, + _request: RemoteChainInfoRequest, + ) -> Result> { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 903b3d77..6a6b3860 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -21,14 +21,15 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a product account. /// /// ```ts - /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// const people = await truapi.chain.getChainInfo({ chain: "People" }); + /// assert(people.isOk(), "getChainInfo failed:", people); /// /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", /// derivationIndex: { tag: "Index", value: 0 }, /// }, - /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// genesisHash: people.value.genesisHash, /// callData: "0x000000", /// }); /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); @@ -49,7 +50,8 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a non-product (legacy) account. /// /// ```ts - /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// const people = await truapi.chain.getChainInfo({ chain: "People" }); + /// assert(people.isOk(), "getChainInfo failed:", people); /// /// const accountResult = await truapi.account.getAccount({ /// productAccountId: { @@ -64,7 +66,7 @@ pub trait Signing: Send + Sync { /// dotNsIdentifier: "truapi-playground.dot", /// derivationIndex: { tag: "Index", value: 0 }, /// }, - /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// genesisHash: people.value.genesisHash, /// callData: "0x000000", /// }); /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); @@ -117,7 +119,8 @@ pub trait Signing: Send + Sync { /// Sign an extrinsic payload with a non-product account. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const accountResult = await truapi.account.getAccount({ /// productAccountId: { @@ -133,7 +136,7 @@ pub trait Signing: Send + Sync { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", /// era: "0x00", - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// method: "0x00003448656c6c6f2c20776f726c6421", /// nonce: "0x00000000", /// signedExtensions: [], @@ -185,7 +188,8 @@ pub trait Signing: Send + Sync { /// Sign an extrinsic payload. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.signing.signPayload({ /// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: { tag: "Index", value: 0 } }, @@ -193,7 +197,7 @@ pub trait Signing: Send + Sync { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", /// era: "0x00", - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// method: "0x00003448656c6c6f2c20776f726c6421", /// nonce: "0x00000000", /// signedExtensions: [], diff --git a/rust/crates/truapi/src/api/system.rs b/rust/crates/truapi/src/api/system.rs index b08c5da9..93d90963 100644 --- a/rust/crates/truapi/src/api/system.rs +++ b/rust/crates/truapi/src/api/system.rs @@ -38,12 +38,13 @@ pub trait System: Send + Sync { /// Query whether the host supports a specific feature. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// const assetHub = await truapi.chain.getChainInfo({ chain: "AssetHub" }); + /// assert(assetHub.isOk(), "getChainInfo failed:", assetHub); /// /// const result = await truapi.system.featureSupported({ /// tag: "Chain", /// value: { - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// genesisHash: assetHub.value.genesisHash, /// }, /// }); /// assert(result.isOk(), "featureSupported failed:", result); diff --git a/rust/crates/truapi/src/v01/chain.rs b/rust/crates/truapi/src/v01/chain.rs index f8dd524b..85704960 100644 --- a/rust/crates/truapi/src/v01/chain.rs +++ b/rust/crates/truapi/src/v01/chain.rs @@ -1,5 +1,7 @@ use parity_scale_codec::{Decode, Encode}; +use super::common::GenericError; + /// One entry of a runtime's supported API list. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RuntimeApi { @@ -353,3 +355,43 @@ pub struct RemoteChainTransactionBroadcastResponse { /// Broadcast operation identifier, if available. pub operation_id: Option, } + +/// Role of a chain within the host's configured environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum ChainIdentifier { + /// The relay chain. + Relay, + /// The asset hub system chain. + AssetHub, + /// The people chain. + People, + /// The bulletin chain. + Bulletin, +} + +/// Request to resolve one chain identifier against the host's environment. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RemoteChainInfoRequest { + /// Chain to resolve. + pub chain: ChainIdentifier, +} + +/// Response carrying the resolved chain data. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RemoteChainInfoResponse { + /// Ecosystem the host is configured for, e.g. "polkadot", "kusama", "paseo". + pub network: String, + /// Chain this response resolves, echoed from the request. + pub chain: ChainIdentifier, + /// Genesis hash identifying the chain in all chain-scoped calls. + pub genesis_hash: [u8; 32], +} + +/// Error from [`crate::api::Chain::get_chain_info`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteChainInfoError { + /// The host does not serve the requested chain. + NotSupported, + /// Catch-all. + Unknown(GenericError), +} diff --git a/rust/crates/truapi/src/versioned/chain.rs b/rust/crates/truapi/src/versioned/chain.rs index b8a2ccb3..d9627572 100644 --- a/rust/crates/truapi/src/versioned/chain.rs +++ b/rust/crates/truapi/src/versioned/chain.rs @@ -41,4 +41,7 @@ truapi_macros::versioned_type! { pub enum RemoteChainTransactionStopRequest { V1 => v01::RemoteChainTransactionStopRequest } pub enum RemoteChainTransactionStopResponse { V1 } pub enum RemoteChainTransactionStopError { V1 => v01::GenericError } + pub enum RemoteChainInfoRequest { V1 => v01::RemoteChainInfoRequest } + pub enum RemoteChainInfoResponse { V1 => v01::RemoteChainInfoResponse } + pub enum RemoteChainInfoError { V1 => v01::RemoteChainInfoError } }