diff --git a/docs/rfcs/0027-capability-detection.md b/docs/rfcs/0027-capability-detection.md new file mode 100644 index 000000000..089fc37cf --- /dev/null +++ b/docs/rfcs/0027-capability-detection.md @@ -0,0 +1,179 @@ +--- +title: "Method-level capability detection for protocol extension" +owner: "@ryanleecode" +type: rfc +status: draft +created: 2026-08-16 +--- + +# RFC 0027 — Method-level capability detection for protocol extension + +| | | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 27 | +| **Start Date** | 2026-08-16 | +| **Description** | Enable runtime discovery of host method support on existing wire IDs, and ensure unregistered method calls fail explicitly instead of hanging indefinitely. | +| **Authors** | Ryan Lee | + +## Summary + +When TrUAPI adds new methods, products have no mechanism to check whether their embedding host supports them. Calling an unknown method causes the host dispatcher to silently drop the unrecognised wire discriminant. Because the client transport lacks request timeouts, the pending call hangs forever. + +This RFC introduces three coordinated fixes: +1. **Backward-compatible probing:** Adds a `Method { id: u8 }` query variant to `system.feature_supported` (wire ID 2, registered by all hosts). Older hosts return a decode error (`MalformedFrame`) instead of dropping the frame, allowing products to reliably infer host capabilities even on pre-RFC deployments. +2. **Explicit failure for unregistered discriminants:** Hosts reply to unregistered wire IDs with a reserved `UNSUPPORTED_METHOD` frame instead of dropping them, settling pending client requests immediately. +3. **Typed error semantics:** Canonicalises `CallError::Unsupported` as the standard response for unwired trait methods, replacing ambiguous `HostFailure { reason: "unavailable" }` strings. + +## Motivation + +Protocol extension is frequent in TrUAPI (e.g., v0.2 introduced eleven new methods across three groups). Upcoming extensions like the `Swarm` capability (BitTorrent-based content streaming) highlight critical gaps in version compatibility. + +### 1. Inconsistent Observability on Unknown Features + +When a product targets an older host lacking a new feature, behavior depends arbitrarily on where the unknown identifier appears: + +| Call Type | Example | Host Behavior | Product Observability | +| :--- | :--- | :--- | :--- | +| **Unknown Enum Variant** | `permissions.remote_permission(Swarm)` | Decoder rejects variant index; returns `CallError::MalformedFrame`. | **Settles immediately** with error. | +| **Unknown Wire ID** | `swarm.fetch(...)` | Dispatcher finds no handler; drops frame silently (`dispatcher.rs`). | **Hangs indefinitely**; transport never resolves. | + +### 2. Unbounded Client Hangs + +The client transport (`js/packages/truapi/src/client.ts`) tracks requests in a `pending` map. Entries are settled only when a response matching the expected ID arrives, the transport throws, or the connection closes. Because many methods involve user interaction (e.g., wallet authorization, biometric prompts) with unbounded duration, global client timeouts cannot be safely applied. Dropped frames therefore cause indefinite hangs indistinguishable from crashed hosts. + +### 3. Asymmetric Deployment Cadence + +Wire IDs are append-only to preserve backward compatibility (newer hosts support older products). However, products are deployed as web bundles that update continuously, whereas native host runtimes (iOS SPM, Android Kotlin packages) update on slower native app release cycles. As a result, newer products routinely run against older hosts. + +### 4. Ambiguous "Unavailable" Errors + +Unimplemented default trait methods previously returned `CallError::unavailable()`, which mapped to `HostFailure { reason: "unavailable" }`. This conflated permanent lack of feature support with transient operational failures. + +--- + +## Detailed Design + +### 1. Capability Probing via `system.feature_supported` + +`HostFeatureSupportedRequest` in `truapi::v01::system` is extended with a new variant: + +```rust +pub enum HostFeatureSupportedRequest { + /// Query whether the host supports the chain identified by genesis hash. + Chain { + /// Chain genesis hash. + genesis_hash: Vec, + }, + /// Query whether the host has registered a handler for a wire discriminant. + Method { + /// Request or subscription-start discriminant from the wire table. + id: u8, + }, +} +``` + +Because `system.feature_supported` (`#[wire(request_id = 2)]`) is registered by all hosts across all published surface versions (`0.2.0` to `0.9.0`): + +- **Modern Hosts:** Hosts implementing this RFC inspect the build's wire table — the same table the dispatcher registers its routing maps from — and return `HostFeatureSupportedResponse { supported }`. The host MUST compute `supported` from that table rather than a separate static list, and MUST NOT report `true` for unregistered discriminants. In this repository the equivalence between the wire table and the registered dispatch surface is gated by a test. `id` is a request discriminant or a product-facing subscription-start discriminant — the two frame kinds a product can begin a call with; response, stop, interrupt, receive and host-initiated-subscription ids are not product-callable ids the host MUST answer `false` for. +- **Legacy Hosts:** Hosts predating this RFC attempt to decode payload bytes against the single-variant enum. The decoder rejects variant index 1, and the generated dispatcher wrapper returns `CallError::MalformedFrame`. + +#### Conclusive Probing Protocol + +To distinguish an old host from payload corruption, callers SHOULD execute a concurrent control probe: + +```ts +const [methodProbe, controlProbe] = await Promise.all([ + truapi.system.featureSupported({ tag: "Method", value: { id: targetWireId } }), + truapi.system.featureSupported({ tag: "Chain", value: { genesisHash: knownChainHash } }), +]); + +// Control OK + Method MalformedFrame -> Host predates RFC 0027 (Method unsupported) +// Method OK -> Response value is authoritative +``` + +The client SDK exposes a high-level helper resolving method names to wire discriminants at codegen time: + +```ts +const isAvailable = await truapi.system.supportsMethod("swarm_fetch"); +``` + +--- + +### 2. Explicit Response for Unregistered Discriminants + +A reserved wire discriminant `UNSUPPORTED_METHOD` is allocated in the server wire table. When `Dispatcher::dispatch` encounters an unknown discriminant, it sends an explicit error frame: + +```rust +// Fall-through branch for unrecognized discriminants: +transport.send(ProtocolMessage { + request_id: message.request_id, + payload: Payload { + id: wire_table::UNSUPPORTED_METHOD, + value: encode_versioned_err_payload(CallError::::Unsupported, 1), + }, +}); +``` + +- The host MUST echo the caller's `request_id`. +- The host MUST send this frame for any message that matches no registered request, subscription start, or cancel handler. +- The client transport routes `UNSUPPORTED_METHOD` frames directly to the pending request or subscription entry, rejecting the promise with `CallError::Unsupported`. + +--- + +### 3. Canonical `Unsupported` Error Variant + +The default implementation of `CallError::unsupported()` is updated across all API trait definitions: + +```rust +impl CallError { + /// Convenience for default handlers whose implementation is not wired. + /// + /// Returns [`CallError::Unsupported`] (RFC 0027): the host will not serve + /// this method for the lifetime of the connection, so a caller must not + /// retry. `HostFailure` stays reserved for a host that attempted the + /// operation and failed, which a caller may retry. + pub fn unsupported() -> Self { + Self::Unsupported + } +} +``` + +- A host MUST NOT return `HostFailure` for an unimplemented method. +- `Unsupported` signals that the operation is permanently unhandled on the current host session; callers MUST NOT retry. +- `HostFailure` remains correct for a host that attempted the operation and failed transiently; callers MAY retry that. + +--- + +### Summary of Host Reachability & Compatibility + +| Mechanism | Legacy Hosts (Pre-RFC 0027) | Modern Hosts (RFC 0027) | +| :--- | :--- | :--- | +| `feature_supported(Method { id })` | Returns `MalformedFrame` (interpreted as unsupported via control probe) | Returns `HostFeatureSupportedResponse { supported }` | +| Direct Call to Unregistered Wire ID | Frame dropped; client hangs | Returns `UNSUPPORTED_METHOD` frame; client promise rejects | +| Unwired Default Trait Method | Returns `HostFailure { reason: "unavailable" }` | Returns typed `CallError::Unsupported` | + +--- + +## Drawbacks + +1. **Inference Overhead:** Legacy detection relies on interpreting `MalformedFrame` as "host predates capability". While the paired control query prevents misclassification, it requires dual queries on legacy hosts. +2. **Registration vs Implementation Gap:** A positive response (`supported: true`) indicates the method is registered in the host binary dispatcher table, but does not guarantee the host provided a custom implementation instead of the default trait fallback (`CallError::Unsupported`). Products must still handle `Unsupported` at call time. +3. **Discriminant Allocation:** Consumes one permanent `u8` discriminant (`UNSUPPORTED_METHOD`) from the finite 256-value wire table space. +4. **Behavioral Change for `unavailable()`:** Existing client code asserting on string matching for `HostFailure { reason: "unavailable" }` will observe `CallError::Unsupported`. + +--- + +## Alternatives + +- **Monolithic `system.get_capabilities` Method:** Exposing a single method returning all supported capabilities was considered. Rejected because legacy hosts drop unknown wire IDs, causing the initial capability fetch itself to hang on the very hosts requiring detection. +- **Protocol Version Negotiation Bump (`Versioned::V2`):** Bumping the root handshake version to negotiate a feature bitmap was rejected because old hosts fail the handshake completely rather than allowing graceful partial-feature fallback. +- **Client-Side Request Timeouts:** Implementing blanket client timeouts was rejected because methods requiring user interaction (biometrics, manual authorization, hardware key signing) have indefinite completion times. +- **Method Name String Queries (`Method { name: String }`):** Using method names instead of `u8` IDs in `HostFeatureSupportedRequest` was rejected to avoid wire bloat and string parsing overhead on resource-constrained embedded runtimes. + +--- + +## Unresolved Questions + +1. **Discriminant ID Assignment:** Which exact `u8` index should be assigned to `UNSUPPORTED_METHOD` in `wire_table.rs`? +2. **Implementation Introspection:** Can proc-macro code generation distinguish overridden trait methods from default trait bodies at compile time to avoid reporting `supported: true` for default `Unsupported` stubs? +3. **RFC 0002 Reconciliation:** RFC 0002 recommended returning `false` for unknown permission enum variants rather than errors. Should that recommendation be formally scoped strictly to permission evaluation? diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 105af6bd1..74a043d2f 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,3 +26,4 @@ created: 2026-03-13 | 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) | +| 0027 | [Method-level capability detection for protocol extension](0027-capability-detection.md) | draft | Ryan Lee | — | diff --git a/docs/solutions/architecture-patterns/capability-probe-source-of-truth.md b/docs/solutions/architecture-patterns/capability-probe-source-of-truth.md new file mode 100644 index 000000000..1bc204b58 --- /dev/null +++ b/docs/solutions/architecture-patterns/capability-probe-source-of-truth.md @@ -0,0 +1,99 @@ +--- +title: "Capability probes must answer from the routed subset of the product-side wire table, gated exhaustively" +date: 2026-08-16 +category: architecture-patterns +module: truapi-server +problem_type: architecture_pattern +component: tooling +severity: critical +applies_when: + - "Adding a wire-protocol capability probe or discovery query answered from a generated id table" + - "Asserting that two tables or maps are the same set by construction" +tags: wire-protocol, capability-probe, codegen, registration-gate, rfc-0027 +--- + +# Capability probes must answer from the routed subset of the wire table, gated exhaustively + +## Context + +RFC 0027 adds `system.feature_supported(Method { id })`: a product probes a +wire discriminant and the host answers whether the id opens a call. The first +design answered from the generated `WIRE_TABLE` directly, assuming the table +and the dispatcher's registered handler set were "the same set by +construction" — `register()` binds every registrar unconditionally. A review +found the table contains entries the product-facing dispatcher never +registers: `#[wire(host_initiated)]` subscriptions (e.g. +`chat_custom_message_render`) are started by the host and served to the +product, so a product cannot begin a call with their ids. The probe answered +`true` for a host-initiated start id while dispatch dropped the frame — the +probe-then-hang failure RFC 0027 exists to eliminate — with the gate still +green because it sampled only one request id and one unallocated id. + +## Guidance + +1. When a probe asks "does this id open a call on this build", derive the + answer from the same table the dispatcher routes from — but only the + product-callable subset. Host-initiated entries must be excluded. In this + repository the generator parses `#[wire(host_initiated)]` and emits a + `host_initiated` flag on every `WireEntry` row; `method_entry_registered` + answers `false` for those rows. The platform-facing funnel forwards + `Chain` queries and answers `Method` queries in-core, so no host learns + wire discriminants. +2. Never assert a table/registration equivalence by sampling a few ids. An + equivalence claim that protects a guarantee ("advertised answers equal + routed behavior") must be asserted exhaustively: every one of the 256 + possible ids, both directions, against the live registered request/start + key set (exposed by a test-only accessor on the dispatcher). A wrong + answer at any id fails the build. +3. Wire-format invariants belong in commit messages; code comments state the + current invariant. "Variant index 1; a host that cannot decode it answers + MalformedFrame" is current state; "appended after Chain so older + encodings keep their bytes" is history, and the repository forbids + migration-narrating doc comments. + +## Why This Matters + +A capability probe is a promise: a caller that probes `true` will send the +frame or begin the call and expects it to be served or rejected, never +silently dropped. Sampling gates pass wrong answers on the ids they do not +sample — the same false confidence the probe itself is meant to prevent. +Exhaustive comparison against the live registration surface turns the +guarantee into a build failure. + +## When to Apply + +- Any new discovery/probe endpoint whose answer derives from a generated id + or route table. +- Any equivalence claim ("these collections cannot diverge by construction") + between a generated table and a live registration map — verify the + construction, then pin the equivalence with a test that fails rather than + samples. +- When wire ids extend into host-initiated or host-only channels on a + product-facing protocol, mark them so callable-id answers exclude them. + +## Examples + +Probe semantics before/after the fix (simplified): + +```rust +// BEFORE: table membership == supported (lies for host-initiated rows). +pub fn method_entry_registered(id: u8) -> bool { + WIRE_TABLE.iter().any(|entry| match entry.kind { + Request(ids) => ids.request_id == id, + Subscription(ids) => ids.start_id == id, + }) +} + +// AFTER: host-initiated rows (generator-emitted flag) are never callable. +pub fn method_entry_registered(id: u8) -> bool { + WIRE_TABLE.iter().any(|entry| match (entry.kind, entry.host_initiated) { + (Request(ids), _) => ids.request_id == id, + (Subscription(ids), false) => ids.start_id == id, + (Subscription(_), true) => false, + }) +} +``` + +## Related + +- docs/rfcs/0027-capability-detection.md — the RFC whose probe semantics this pattern pins \ No newline at end of file diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift index ff633c5b1..2d3098e5e 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift @@ -5191,6 +5191,20 @@ public enum HostFeatureSupportedRequest: Equatable, Hashable { * Chain genesis hash. */genesisHash: Data ) + /** + * Ask whether `id` opens a method on this host build (RFC 0027). + * + * `id` is a request discriminant or a product-facing + * subscription-start discriminant — the two frame kinds a product can + * begin a call with. Variant index 1; a host that cannot decode it + * answers `CallError::MalformedFrame`, which is RFC 0027's no-support + * signal. `Chain` stays variant index 0. + */ + case method( + /** + * Request or subscription-start discriminant from the wire table. + */id: UInt8 + ) @@ -5215,6 +5229,9 @@ public struct FfiConverterTypeHostFeatureSupportedRequest: FfiConverterRustBuffe case 1: return .chain(genesisHash: try FfiConverterData.read(from: &buf) ) + case 2: return .method(id: try FfiConverterUInt8.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -5227,6 +5244,11 @@ public struct FfiConverterTypeHostFeatureSupportedRequest: FfiConverterRustBuffe writeInt(&buf, Int32(1)) FfiConverterData.write(genesisHash, into: &buf) + + case let .method(id): + writeInt(&buf, Int32(2)) + FfiConverterUInt8.write(id, into: &buf) + } } } diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index 8696b5756..afc5a7d61 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -30,6 +30,7 @@ struct SubEntry { stop_id: u8, interrupt_id: u8, receive_id: u8, + host_initiated: bool, } #[derive(Debug, Clone, Copy)] @@ -122,6 +123,7 @@ fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result stop_id, interrupt_id, receive_id, + host_initiated: wire.host_initiated, })) } } @@ -154,6 +156,7 @@ fn insert_entry( stop_id, interrupt_id, receive_id, + .. }) => vec![ (start_id, format!("{method_name}_start")), (stop_id, format!("{method_name}_stop")), @@ -212,6 +215,8 @@ fn render(methods: &[(String, MethodEntry)]) -> Result { pub method: &'static str, /// What kind of slot this entry describes. pub kind: WireKind, + /// Whether this is a host-initiated subscription. + pub host_initiated: bool, }} /// Wire-slot shape: request/response pair or subscription quartet. @@ -246,6 +251,7 @@ fn render(methods: &[(String, MethodEntry)]) -> Result { stop_id, interrupt_id, receive_id, + .. }) => formatdoc! { r#" /// Wire discriminants for `{name}`. @@ -274,15 +280,18 @@ fn render(methods: &[(String, MethodEntry)]) -> Result { .unwrap(); for (name, entry) in methods { let konst = const_name(name); - let variant = match entry { - MethodEntry::Request(_) => "Request", - MethodEntry::Subscription(_) => "Subscription", + let (variant, host_initiated) = match entry { + MethodEntry::Request(_) => ("Request", false), + MethodEntry::Subscription(SubEntry { host_initiated, .. }) => { + ("Subscription", *host_initiated) + } }; let block = formatdoc! { r#" WireEntry {{ method: "{name}", kind: WireKind::{variant}({konst}), + host_initiated: {host_initiated}, }}, "# }; diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 00710aca5..1724ea2f8 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -864,6 +864,12 @@ export interface CoreStorage { export interface Features { /** * Report whether the requested feature is supported. + * + * Only `Chain` queries reach a platform: the core answers `Method` + * queries (RFC 0027) from its wire table before dispatch. The parameter + * type still admits `Method` because narrowing it would change a callback + * signature every embedder implements; the invariant is held by the core + * and covered by tests, not by this type. */ featureSupported( request: HostFeatureSupportedRequest, diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index dccb9d61a..dc132b290 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -36,6 +36,8 @@ pub struct WireEntry { pub method: &'static str, /// What kind of slot this entry describes. pub kind: WireKind, + /// Whether this is a host-initiated subscription. + pub host_initiated: bool, } /// Wire-slot shape: request/response pair or subscription quartet. @@ -496,277 +498,346 @@ pub const WIRE_TABLE: &[WireEntry] = &[ WireEntry { method: "system_handshake", kind: WireKind::Request(SYSTEM_HANDSHAKE), + host_initiated: false, }, WireEntry { method: "system_feature_supported", kind: WireKind::Request(SYSTEM_FEATURE_SUPPORTED), + host_initiated: false, }, WireEntry { method: "notifications_send_push_notification", kind: WireKind::Request(NOTIFICATIONS_SEND_PUSH_NOTIFICATION), + host_initiated: false, }, WireEntry { method: "system_navigate_to", kind: WireKind::Request(SYSTEM_NAVIGATE_TO), + host_initiated: false, }, WireEntry { method: "permissions_request_device_permission", kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), + host_initiated: false, }, WireEntry { method: "permissions_request_remote_permission", kind: WireKind::Request(PERMISSIONS_REQUEST_REMOTE_PERMISSION), + host_initiated: false, }, WireEntry { method: "local_storage_read", kind: WireKind::Request(LOCAL_STORAGE_READ), + host_initiated: false, }, WireEntry { method: "local_storage_write", kind: WireKind::Request(LOCAL_STORAGE_WRITE), + host_initiated: false, }, WireEntry { method: "local_storage_clear", kind: WireKind::Request(LOCAL_STORAGE_CLEAR), + host_initiated: false, }, WireEntry { method: "account_connection_status_subscribe", kind: WireKind::Subscription(ACCOUNT_CONNECTION_STATUS_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "account_get_account", kind: WireKind::Request(ACCOUNT_GET_ACCOUNT), + host_initiated: false, }, WireEntry { method: "account_get_account_alias", kind: WireKind::Request(ACCOUNT_GET_ACCOUNT_ALIAS), + host_initiated: false, }, WireEntry { method: "account_create_account_proof", kind: WireKind::Request(ACCOUNT_CREATE_ACCOUNT_PROOF), + host_initiated: false, }, WireEntry { method: "account_get_legacy_accounts", kind: WireKind::Request(ACCOUNT_GET_LEGACY_ACCOUNTS), + host_initiated: false, }, WireEntry { method: "signing_create_transaction", kind: WireKind::Request(SIGNING_CREATE_TRANSACTION), + host_initiated: false, }, WireEntry { method: "signing_create_transaction_with_legacy_account", kind: WireKind::Request(SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "signing_sign_raw_with_legacy_account", kind: WireKind::Request(SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "signing_sign_payload_with_legacy_account", kind: WireKind::Request(SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "chat_create_room", kind: WireKind::Request(CHAT_CREATE_ROOM), + host_initiated: false, }, WireEntry { method: "chat_register_bot", kind: WireKind::Request(CHAT_REGISTER_BOT), + host_initiated: false, }, WireEntry { method: "chat_list_subscribe", kind: WireKind::Subscription(CHAT_LIST_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chat_post_message", kind: WireKind::Request(CHAT_POST_MESSAGE), + host_initiated: false, }, WireEntry { method: "chat_action_subscribe", kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chat_custom_message_render", kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER), + host_initiated: true, }, WireEntry { method: "statement_store_subscribe", kind: WireKind::Subscription(STATEMENT_STORE_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "statement_store_create_proof", kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF), + host_initiated: false, }, WireEntry { method: "statement_store_submit", kind: WireKind::Request(STATEMENT_STORE_SUBMIT), + host_initiated: false, }, WireEntry { method: "preimage_lookup_subscribe", kind: WireKind::Subscription(PREIMAGE_LOOKUP_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "preimage_submit", kind: WireKind::Request(PREIMAGE_SUBMIT), + host_initiated: false, }, WireEntry { method: "chain_follow_head_subscribe", kind: WireKind::Subscription(CHAIN_FOLLOW_HEAD_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chain_get_head_header", kind: WireKind::Request(CHAIN_GET_HEAD_HEADER), + host_initiated: false, }, WireEntry { method: "chain_get_head_body", kind: WireKind::Request(CHAIN_GET_HEAD_BODY), + host_initiated: false, }, WireEntry { method: "chain_get_head_storage", kind: WireKind::Request(CHAIN_GET_HEAD_STORAGE), + host_initiated: false, }, WireEntry { method: "chain_call_head", kind: WireKind::Request(CHAIN_CALL_HEAD), + host_initiated: false, }, WireEntry { method: "chain_unpin_head", kind: WireKind::Request(CHAIN_UNPIN_HEAD), + host_initiated: false, }, WireEntry { method: "chain_continue_head", kind: WireKind::Request(CHAIN_CONTINUE_HEAD), + host_initiated: false, }, WireEntry { method: "chain_stop_head_operation", kind: WireKind::Request(CHAIN_STOP_HEAD_OPERATION), + host_initiated: false, }, WireEntry { method: "chain_get_spec_genesis_hash", kind: WireKind::Request(CHAIN_GET_SPEC_GENESIS_HASH), + host_initiated: false, }, WireEntry { method: "chain_get_spec_chain_name", kind: WireKind::Request(CHAIN_GET_SPEC_CHAIN_NAME), + host_initiated: false, }, WireEntry { method: "chain_get_spec_properties", kind: WireKind::Request(CHAIN_GET_SPEC_PROPERTIES), + host_initiated: false, }, WireEntry { method: "chain_broadcast_transaction", kind: WireKind::Request(CHAIN_BROADCAST_TRANSACTION), + host_initiated: false, }, WireEntry { method: "chain_stop_transaction", kind: WireKind::Request(CHAIN_STOP_TRANSACTION), + host_initiated: false, }, WireEntry { method: "theme_subscribe", kind: WireKind::Subscription(THEME_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "entropy_derive", kind: WireKind::Request(ENTROPY_DERIVE), + host_initiated: false, }, WireEntry { method: "account_get_user_id", kind: WireKind::Request(ACCOUNT_GET_USER_ID), + host_initiated: false, }, WireEntry { method: "account_request_login", kind: WireKind::Request(ACCOUNT_REQUEST_LOGIN), + host_initiated: false, }, WireEntry { method: "signing_sign_raw", kind: WireKind::Request(SIGNING_SIGN_RAW), + host_initiated: false, }, WireEntry { method: "signing_sign_payload", kind: WireKind::Request(SIGNING_SIGN_PAYLOAD), + host_initiated: false, }, WireEntry { method: "payment_balance_subscribe", kind: WireKind::Subscription(PAYMENT_BALANCE_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "payment_top_up", kind: WireKind::Request(PAYMENT_TOP_UP), + host_initiated: false, }, WireEntry { method: "payment_request", kind: WireKind::Request(PAYMENT_REQUEST), + host_initiated: false, }, WireEntry { method: "payment_status_subscribe", kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "resource_allocation_request", kind: WireKind::Request(RESOURCE_ALLOCATION_REQUEST), + host_initiated: false, }, WireEntry { method: "statement_store_create_proof_authorized", kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF_AUTHORIZED), + host_initiated: false, }, WireEntry { method: "notifications_cancel_push_notification", kind: WireKind::Request(NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION), + host_initiated: false, }, WireEntry { method: "coin_payment_create_purse", kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_query_purse", kind: WireKind::Request(COIN_PAYMENT_QUERY_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_rebalance_purse", kind: WireKind::Subscription(COIN_PAYMENT_REBALANCE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_delete_purse", kind: WireKind::Subscription(COIN_PAYMENT_DELETE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_create_receivable", kind: WireKind::Request(COIN_PAYMENT_CREATE_RECEIVABLE), + host_initiated: false, }, WireEntry { method: "coin_payment_create_cheque", kind: WireKind::Request(COIN_PAYMENT_CREATE_CHEQUE), + host_initiated: false, }, WireEntry { method: "coin_payment_deposit", kind: WireKind::Subscription(COIN_PAYMENT_DEPOSIT), + host_initiated: false, }, WireEntry { method: "coin_payment_refund", kind: WireKind::Subscription(COIN_PAYMENT_REFUND), + host_initiated: false, }, WireEntry { method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), + host_initiated: false, }, WireEntry { method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), + host_initiated: false, }, WireEntry { method: "chain_get_chain_info", kind: WireKind::Request(CHAIN_GET_CHAIN_INFO), + host_initiated: false, }, WireEntry { method: "account_register_ring_vrf_key", kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + host_initiated: false, }, WireEntry { method: "account_list_ring_vrf_keys", kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + host_initiated: false, }, WireEntry { method: "account_ring_vrf_sign", kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + host_initiated: false, }, ]; diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 1ea295ca0..5ad4e49a7 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -612,7 +612,11 @@ impl Features for CliPlatform { &self, request: api::HostFeatureSupportedRequest, ) -> Result { - let api::HostFeatureSupportedRequest::Chain { genesis_hash } = request; + let api::HostFeatureSupportedRequest::Chain { genesis_hash } = request else { + return Err(api::GenericError { + reason: "method-support queries are answered by the core".to_string(), + }); + }; let supported = self .chains .chains @@ -1251,6 +1255,19 @@ mod tests { assert!(!supported(vec![0u8; 32])); } + #[test] + fn method_query_at_the_platform_is_a_typed_error_not_a_false_answer() { + let platform = CliPlatform::new(test_network(), None, ApprovalPolicy::AutoAccept, None); + let err = futures::executor::block_on( + platform.feature_supported(api::HostFeatureSupportedRequest::Method { id: 2 }), + ) + .expect_err("a Method query must not reach a platform"); + assert_eq!( + err.reason, + "method-support queries are answered by the core" + ); + } + #[test] fn supported_chains_answers_the_configured_network() { let platform = CliPlatform::new(test_network(), None, ApprovalPolicy::AutoAccept, None); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 701e4f0ef..d1c9a05c8 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -559,6 +559,12 @@ pub struct HostChainSet { #[async_trait] pub trait Features: Send + Sync { /// Report whether the requested feature is supported. + /// + /// Only `Chain` queries reach a platform: the core answers `Method` + /// queries (RFC 0027) from its wire table before dispatch. The parameter + /// type still admits `Method` because narrowing it would change a callback + /// signature every embedder implements; the invariant is held by the core + /// and covered by tests, not by this type. async fn feature_supported( &self, request: HostFeatureSupportedRequest, diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index dcd452117..56a67de66 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -241,6 +241,21 @@ mod tests { ) } + #[test] + fn probe_answers_match_registered_dispatch_surface_exhaustively() { + let core = make_core(); + let registered = core.dispatcher.callable_id_set(); + + for id in 0..=u8::MAX { + let advertised = crate::frame::method_entry_registered(id); + assert_eq!( + advertised, + registered.contains(&id), + "probe answer for id {id} disagrees with the registered dispatch surface" + ); + } + } + #[test] fn local_storage_read_round_trips_none() { let core = make_core(); diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index 5cf49a3de..5dd2b98af 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -107,6 +107,14 @@ impl Dispatcher { ) } + /// Every request and subscription-start id this dispatcher routes. + #[cfg(test)] + pub(crate) fn callable_id_set(&self) -> std::collections::BTreeSet { + let mut ids: std::collections::BTreeSet = self.by_request.keys().copied().collect(); + ids.extend(self.by_start.keys().copied()); + ids + } + /// Register a subscription handler, keyed on `ids.start_id`, and record /// `ids.stop_id` so a matching `_stop` frame tears the subscription down. /// Returns the previously registered entry if any. diff --git a/rust/crates/truapi-server/src/frame.rs b/rust/crates/truapi-server/src/frame.rs index c324200a4..808af9eaf 100644 --- a/rust/crates/truapi-server/src/frame.rs +++ b/rust/crates/truapi-server/src/frame.rs @@ -143,6 +143,20 @@ pub fn subscription_ids(method: &str) -> Option { }) } +/// Whether `id` opens a product call on this build (RFC 0027). +/// +/// Walks the generated [`WIRE_TABLE`]; the gate test asserts this callable +/// set exhaustively equals what the dispatcher routes. +pub fn method_entry_registered(id: u8) -> bool { + WIRE_TABLE + .iter() + .any(|entry| match (&entry.kind, entry.host_initiated) { + (WireKind::Request(ids), _) => ids.request_id == id, + (WireKind::Subscription(ids), false) => ids.start_id == id, + (WireKind::Subscription(_), true) => false, + }) +} + /// Unique ID generator with a prefix. pub struct IdFactory { prefix: String, diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index dccb9d61a..dc132b290 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -36,6 +36,8 @@ pub struct WireEntry { pub method: &'static str, /// What kind of slot this entry describes. pub kind: WireKind, + /// Whether this is a host-initiated subscription. + pub host_initiated: bool, } /// Wire-slot shape: request/response pair or subscription quartet. @@ -496,277 +498,346 @@ pub const WIRE_TABLE: &[WireEntry] = &[ WireEntry { method: "system_handshake", kind: WireKind::Request(SYSTEM_HANDSHAKE), + host_initiated: false, }, WireEntry { method: "system_feature_supported", kind: WireKind::Request(SYSTEM_FEATURE_SUPPORTED), + host_initiated: false, }, WireEntry { method: "notifications_send_push_notification", kind: WireKind::Request(NOTIFICATIONS_SEND_PUSH_NOTIFICATION), + host_initiated: false, }, WireEntry { method: "system_navigate_to", kind: WireKind::Request(SYSTEM_NAVIGATE_TO), + host_initiated: false, }, WireEntry { method: "permissions_request_device_permission", kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), + host_initiated: false, }, WireEntry { method: "permissions_request_remote_permission", kind: WireKind::Request(PERMISSIONS_REQUEST_REMOTE_PERMISSION), + host_initiated: false, }, WireEntry { method: "local_storage_read", kind: WireKind::Request(LOCAL_STORAGE_READ), + host_initiated: false, }, WireEntry { method: "local_storage_write", kind: WireKind::Request(LOCAL_STORAGE_WRITE), + host_initiated: false, }, WireEntry { method: "local_storage_clear", kind: WireKind::Request(LOCAL_STORAGE_CLEAR), + host_initiated: false, }, WireEntry { method: "account_connection_status_subscribe", kind: WireKind::Subscription(ACCOUNT_CONNECTION_STATUS_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "account_get_account", kind: WireKind::Request(ACCOUNT_GET_ACCOUNT), + host_initiated: false, }, WireEntry { method: "account_get_account_alias", kind: WireKind::Request(ACCOUNT_GET_ACCOUNT_ALIAS), + host_initiated: false, }, WireEntry { method: "account_create_account_proof", kind: WireKind::Request(ACCOUNT_CREATE_ACCOUNT_PROOF), + host_initiated: false, }, WireEntry { method: "account_get_legacy_accounts", kind: WireKind::Request(ACCOUNT_GET_LEGACY_ACCOUNTS), + host_initiated: false, }, WireEntry { method: "signing_create_transaction", kind: WireKind::Request(SIGNING_CREATE_TRANSACTION), + host_initiated: false, }, WireEntry { method: "signing_create_transaction_with_legacy_account", kind: WireKind::Request(SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "signing_sign_raw_with_legacy_account", kind: WireKind::Request(SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "signing_sign_payload_with_legacy_account", kind: WireKind::Request(SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT), + host_initiated: false, }, WireEntry { method: "chat_create_room", kind: WireKind::Request(CHAT_CREATE_ROOM), + host_initiated: false, }, WireEntry { method: "chat_register_bot", kind: WireKind::Request(CHAT_REGISTER_BOT), + host_initiated: false, }, WireEntry { method: "chat_list_subscribe", kind: WireKind::Subscription(CHAT_LIST_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chat_post_message", kind: WireKind::Request(CHAT_POST_MESSAGE), + host_initiated: false, }, WireEntry { method: "chat_action_subscribe", kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chat_custom_message_render", kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER), + host_initiated: true, }, WireEntry { method: "statement_store_subscribe", kind: WireKind::Subscription(STATEMENT_STORE_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "statement_store_create_proof", kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF), + host_initiated: false, }, WireEntry { method: "statement_store_submit", kind: WireKind::Request(STATEMENT_STORE_SUBMIT), + host_initiated: false, }, WireEntry { method: "preimage_lookup_subscribe", kind: WireKind::Subscription(PREIMAGE_LOOKUP_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "preimage_submit", kind: WireKind::Request(PREIMAGE_SUBMIT), + host_initiated: false, }, WireEntry { method: "chain_follow_head_subscribe", kind: WireKind::Subscription(CHAIN_FOLLOW_HEAD_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "chain_get_head_header", kind: WireKind::Request(CHAIN_GET_HEAD_HEADER), + host_initiated: false, }, WireEntry { method: "chain_get_head_body", kind: WireKind::Request(CHAIN_GET_HEAD_BODY), + host_initiated: false, }, WireEntry { method: "chain_get_head_storage", kind: WireKind::Request(CHAIN_GET_HEAD_STORAGE), + host_initiated: false, }, WireEntry { method: "chain_call_head", kind: WireKind::Request(CHAIN_CALL_HEAD), + host_initiated: false, }, WireEntry { method: "chain_unpin_head", kind: WireKind::Request(CHAIN_UNPIN_HEAD), + host_initiated: false, }, WireEntry { method: "chain_continue_head", kind: WireKind::Request(CHAIN_CONTINUE_HEAD), + host_initiated: false, }, WireEntry { method: "chain_stop_head_operation", kind: WireKind::Request(CHAIN_STOP_HEAD_OPERATION), + host_initiated: false, }, WireEntry { method: "chain_get_spec_genesis_hash", kind: WireKind::Request(CHAIN_GET_SPEC_GENESIS_HASH), + host_initiated: false, }, WireEntry { method: "chain_get_spec_chain_name", kind: WireKind::Request(CHAIN_GET_SPEC_CHAIN_NAME), + host_initiated: false, }, WireEntry { method: "chain_get_spec_properties", kind: WireKind::Request(CHAIN_GET_SPEC_PROPERTIES), + host_initiated: false, }, WireEntry { method: "chain_broadcast_transaction", kind: WireKind::Request(CHAIN_BROADCAST_TRANSACTION), + host_initiated: false, }, WireEntry { method: "chain_stop_transaction", kind: WireKind::Request(CHAIN_STOP_TRANSACTION), + host_initiated: false, }, WireEntry { method: "theme_subscribe", kind: WireKind::Subscription(THEME_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "entropy_derive", kind: WireKind::Request(ENTROPY_DERIVE), + host_initiated: false, }, WireEntry { method: "account_get_user_id", kind: WireKind::Request(ACCOUNT_GET_USER_ID), + host_initiated: false, }, WireEntry { method: "account_request_login", kind: WireKind::Request(ACCOUNT_REQUEST_LOGIN), + host_initiated: false, }, WireEntry { method: "signing_sign_raw", kind: WireKind::Request(SIGNING_SIGN_RAW), + host_initiated: false, }, WireEntry { method: "signing_sign_payload", kind: WireKind::Request(SIGNING_SIGN_PAYLOAD), + host_initiated: false, }, WireEntry { method: "payment_balance_subscribe", kind: WireKind::Subscription(PAYMENT_BALANCE_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "payment_top_up", kind: WireKind::Request(PAYMENT_TOP_UP), + host_initiated: false, }, WireEntry { method: "payment_request", kind: WireKind::Request(PAYMENT_REQUEST), + host_initiated: false, }, WireEntry { method: "payment_status_subscribe", kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), + host_initiated: false, }, WireEntry { method: "resource_allocation_request", kind: WireKind::Request(RESOURCE_ALLOCATION_REQUEST), + host_initiated: false, }, WireEntry { method: "statement_store_create_proof_authorized", kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF_AUTHORIZED), + host_initiated: false, }, WireEntry { method: "notifications_cancel_push_notification", kind: WireKind::Request(NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION), + host_initiated: false, }, WireEntry { method: "coin_payment_create_purse", kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_query_purse", kind: WireKind::Request(COIN_PAYMENT_QUERY_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_rebalance_purse", kind: WireKind::Subscription(COIN_PAYMENT_REBALANCE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_delete_purse", kind: WireKind::Subscription(COIN_PAYMENT_DELETE_PURSE), + host_initiated: false, }, WireEntry { method: "coin_payment_create_receivable", kind: WireKind::Request(COIN_PAYMENT_CREATE_RECEIVABLE), + host_initiated: false, }, WireEntry { method: "coin_payment_create_cheque", kind: WireKind::Request(COIN_PAYMENT_CREATE_CHEQUE), + host_initiated: false, }, WireEntry { method: "coin_payment_deposit", kind: WireKind::Subscription(COIN_PAYMENT_DEPOSIT), + host_initiated: false, }, WireEntry { method: "coin_payment_refund", kind: WireKind::Subscription(COIN_PAYMENT_REFUND), + host_initiated: false, }, WireEntry { method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), + host_initiated: false, }, WireEntry { method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), + host_initiated: false, }, WireEntry { method: "chain_get_chain_info", kind: WireKind::Request(CHAIN_GET_CHAIN_INFO), + host_initiated: false, }, WireEntry { method: "account_register_ring_vrf_key", kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + host_initiated: false, }, WireEntry { method: "account_list_ring_vrf_keys", kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + host_initiated: false, }, WireEntry { method: "account_ring_vrf_sign", kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + host_initiated: false, }, ]; diff --git a/rust/crates/truapi-server/src/host_logic/features.rs b/rust/crates/truapi-server/src/host_logic/features.rs index f4e44c43d..18e419259 100644 --- a/rust/crates/truapi-server/src/host_logic/features.rs +++ b/rust/crates/truapi-server/src/host_logic/features.rs @@ -4,7 +4,9 @@ //! host owns the set of chains it can service. This module is a thin shim //! that forwards through to [`truapi_platform::Features`], plus the in-core //! RFC-0026 resolution that answers `get_chain_info` from the host's chain -//! set so per-request semantics (ordering, `NotSupported`) stay core-owned. +//! set so per-request semantics (ordering, `NotSupported`) stay core-owned, +//! and the RFC-0027 resolution that answers `Method` support queries from +//! the wire table. Only `Chain` queries reach the platform. use truapi::latest::{ ChainIdentifier, RemoteChainInfoError, RemoteChainInfoRequest, RemoteChainInfoResponse, @@ -12,12 +14,21 @@ use truapi::latest::{ use truapi::v01::{GenericError, HostFeatureSupportedRequest, HostFeatureSupportedResponse}; use truapi_platform::{Features, HostChainSet}; -/// Forward a feature-support query to the platform implementation. +/// Answer a feature-support query. `Chain` forwards to the platform; +/// `Method` (RFC 0027) is answered from the wire table, so no host learns +/// wire discriminants. pub async fn feature_supported( platform: &P, request: HostFeatureSupportedRequest, ) -> Result { - platform.feature_supported(request).await + match request { + HostFeatureSupportedRequest::Method { id } => Ok(HostFeatureSupportedResponse { + supported: crate::frame::method_entry_registered(id), + }), + request @ HostFeatureSupportedRequest::Chain { .. } => { + platform.feature_supported(request).await + } + } } /// Fetch the host's chain set from the platform implementation. @@ -164,6 +175,52 @@ mod tests { let err = chain_info(&paseo_set(), &request).unwrap_err(); assert_eq!(err, RemoteChainInfoError::NotSupported); } + + #[test] + fn request_and_start_ids_answer_true() { + let request_id = crate::frame::request_ids("system_feature_supported") + .expect("known request method") + .request_id; + let start_id = crate::frame::subscription_ids("chain_follow_head_subscribe") + .expect("known subscription method") + .start_id; + + for id in [request_id, start_id] { + let resp = futures::executor::block_on(feature_supported( + &AlwaysUnsupported, + HostFeatureSupportedRequest::Method { id }, + )) + .unwrap(); + assert!(resp.supported, "id {id} opens a method"); + } + } + + #[test] + fn non_entry_and_unallocated_ids_answer_false() { + use crate::generated::wire_table::CHAT_CUSTOM_MESSAGE_RENDER; + + let response_id = crate::frame::request_ids("system_feature_supported") + .expect("known request method") + .response_id; + let sub_ids = crate::frame::subscription_ids("chain_follow_head_subscribe") + .expect("known subscription method"); + + for id in [ + response_id, + sub_ids.stop_id, + sub_ids.interrupt_id, + sub_ids.receive_id, + CHAT_CUSTOM_MESSAGE_RENDER.start_id, + 0xFA, + ] { + let resp = futures::executor::block_on(feature_supported( + &AlwaysSupported, + HostFeatureSupportedRequest::Method { id }, + )) + .unwrap(); + assert!(!resp.supported, "id {id} does not open a method"); + } + } } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fc08a8113..ae34a120e 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -5,7 +5,7 @@ //! host-agnostic logic owned by the core (the chainHead-v1 runtime behind //! the Chain surface, `dotns` URL parsing for `navigate_to`, and the //! permission cache layer). Methods with no platform backing return -//! `CallError::unavailable()`. +//! `CallError::unsupported()`. mod allowances; /// Core-owned auth/session UI state machine. diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 6b496a9ab..e5edc6212 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -59,13 +59,149 @@ fn feature_supported_ok_response_uses_ok_discriminant() { assert_eq!(response.payload.id, ids.response_id); // Wire payload: [V1 disc=0x00][Ok disc=0x00][encoded response body]. - let mut expected = vec![0x00u8, 0x00u8]; - v01::HostFeatureSupportedResponse { supported: true }.encode_to(&mut expected); - assert_eq!(response.payload.value, expected); + assert_eq!( + response.payload.value, + versioned_result_ok_payload(v01::HostFeatureSupportedResponse { supported: true }) + ); assert_eq!(response.payload.value.first(), Some(&0x00)); assert_eq!(response.payload.value.get(1), Some(&0x00)); } +#[test] +fn method_query_is_answered_by_the_core_not_the_platform() { + let core = make_core(); + let ids = request_ids("system_feature_supported").expect("known request method"); + + for (id, expected) in [(0xFAu8, false), (ids.request_id, true)] { + let request = + HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Method { id }); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + assert_eq!(response.payload.id, ids.response_id); + + assert_eq!( + response.payload.value, + versioned_result_ok_payload(v01::HostFeatureSupportedResponse { + supported: expected + }), + "probe of id {id}" + ); + } +} + +#[test] +fn probe_answer_matches_what_the_dispatcher_accepts() { + let core = make_core(); + let ids = request_ids("system_feature_supported").expect("known request method"); + + for id in [ids.request_id, 0xFAu8] { + let probe = + HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Method { id }); + let probed = dispatch( + &core, + ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: probe.encode(), + }, + }, + ); + let advertised = probed.payload.value + == versioned_result_ok_payload(v01::HostFeatureSupportedResponse { supported: true }); + + let routed = futures::executor::block_on( + core.receive_from_product( + &ProtocolMessage { + request_id: "p:2".into(), + payload: Payload { + id, + value: HostFeatureSupportedRequest::V1( + v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }, + ) + .encode(), + }, + } + .encode(), + ), + ) + .is_some(); + + assert_eq!(advertised, routed, "id {id}: advertised vs routed"); + } +} + +/// A probe payload the core cannot decode answers `CallError::MalformedFrame` +/// on the wire — the signal RFC 0027 relies on from hosts that predate the +/// `Method` variant — instead of hanging. +#[test] +fn undecodable_method_probe_answers_malformed_frame() { + let core = make_core(); + let ids = request_ids("system_feature_supported").expect("known request method"); + + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + // V1 + variant index 1 (`Method`) with no `id` byte: the + // payload cannot decode on any host. + value: vec![0x00u8, 0x01u8], + }, + }, + ); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!( + &response.payload.value[..3], + &[0x00, 0x01, 0x03], + "V1 + Err discriminant + CallError::MalformedFrame" + ); + assert!(response.payload.value.len() > 3, "a reason string follows"); +} + +/// An unwired trait method answers `CallError::Unsupported` (variant index +/// 2) on the wire, not the old `HostFailure { reason: "unavailable" }` +/// string (RFC 0027). `CoinPayment` has no `ProductRuntimeHost` backing, so +/// its default bodies run end-to-end. +#[test] +fn unwired_method_answers_unsupported_on_the_wire() { + let core = make_core(); + let ids = request_ids("coin_payment_create_purse").expect("known request method"); + let request = truapi::versioned::coin_payment::HostCoinPaymentCreatePurseRequest::V1( + v01::HostCoinPaymentCreatePurseRequest { + name: "probe".to_string(), + }, + ); + + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!( + response.payload.value, + vec![0x00u8, 0x01u8, 0x02u8], + "V1 + Err discriminant + CallError::Unsupported" + ); +} + #[test] fn get_chain_info_ok_response_round_trips_over_the_wire() { let core = make_core(); @@ -166,6 +302,14 @@ where expected } +/// Wire layout for a successful V1 response: `[V1 disc=0x00][Ok disc=0x00]` +/// followed by the encoded response body. +fn versioned_result_ok_payload(body: impl Encode) -> Vec { + let mut expected = vec![0x00u8, 0x00u8]; + body.encode_to(&mut expected); + expected +} + fn versioned_interrupt_err_payload(error: E) -> Vec where E: Clone + Encode + Versioned, diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index e06597b5d..4bd6025c3 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -65,7 +65,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostAccountGetRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Retrieve the contextual alias for a context and ring. @@ -106,7 +106,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostAccountGetAliasRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Generate a ring VRF proof with an explicitly registered member key. @@ -148,7 +148,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostAccountCreateProofRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Produce an sr25519 (schnorrkel) VRF signature from a product account. @@ -179,7 +179,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostAccountSignVrfRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Register a ring-VRF key owned by the calling product. @@ -209,7 +209,7 @@ pub trait Account: Send + Sync { _request: HostAccountRegisterRingVrfKeyRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// List registered ring-VRF keys owned by a product. @@ -229,7 +229,7 @@ pub trait Account: Send + Sync { _request: HostAccountListRingVrfKeysRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Sign bytes directly with a registered ring-VRF member key. @@ -251,7 +251,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostAccountRingVrfSignRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// List non-product accounts the user owns. @@ -270,7 +270,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostGetLegacyAccountsRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Fetch the user's primary identity. @@ -286,7 +286,7 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostGetUserIdRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Request the host to present the login flow to the user. @@ -307,6 +307,6 @@ pub trait Account: Send + Sync { _cx: &CallContext, _request: HostRequestLoginRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index f08dbf531..55fe6bb2c 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -80,7 +80,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadHeaderRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Fetch a block body. @@ -109,7 +109,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadBodyRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Query runtime storage at a specific block. @@ -143,7 +143,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadStorageRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Invoke a runtime call at a specific block. @@ -179,7 +179,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadCallRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Release pinned blocks. @@ -212,7 +212,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadUnpinRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Continue a paused chain-head operation. @@ -245,7 +245,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainHeadContinueRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Stop a chain-head operation. @@ -279,7 +279,7 @@ pub trait Chain: Send + Sync { _request: RemoteChainHeadStopOperationRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Fetch the canonical genesis hash for a chain. @@ -301,7 +301,7 @@ pub trait Chain: Send + Sync { _request: RemoteChainSpecGenesisHashRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Fetch the display name of a chain. @@ -322,7 +322,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainSpecChainNameRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Fetch the JSON-encoded properties of a chain. @@ -343,7 +343,7 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainSpecPropertiesRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Broadcast a signed transaction. @@ -368,7 +368,7 @@ pub trait Chain: Send + Sync { RemoteChainTransactionBroadcastResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Stop a transaction broadcast. @@ -401,7 +401,7 @@ pub trait Chain: Send + Sync { _request: RemoteChainTransactionStopRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Resolve a chain identifier to its genesis hash against the host's @@ -421,6 +421,6 @@ pub trait Chain: Send + Sync { _cx: &CallContext, _request: RemoteChainInfoRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index 40f7d2cc5..25ff96939 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -31,7 +31,7 @@ pub trait Chat: Send + Sync { _cx: &CallContext, _request: HostChatCreateRoomRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Register a chat bot. @@ -51,7 +51,7 @@ pub trait Chat: Send + Sync { _cx: &CallContext, _request: HostChatRegisterBotRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Subscribe to the list of chat rooms. @@ -85,7 +85,7 @@ pub trait Chat: Send + Sync { _cx: &CallContext, _request: HostChatPostMessageRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Subscribe to received chat actions. diff --git a/rust/crates/truapi/src/api/coin_payment.rs b/rust/crates/truapi/src/api/coin_payment.rs index 5839b8e3c..35785346b 100644 --- a/rust/crates/truapi/src/api/coin_payment.rs +++ b/rust/crates/truapi/src/api/coin_payment.rs @@ -40,7 +40,7 @@ pub trait CoinPayment: Send + Sync { _request: HostCoinPaymentCreatePurseRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Query product-visible purse metadata and balance. @@ -56,7 +56,7 @@ pub trait CoinPayment: Send + Sync { _cx: &CallContext, _request: HostCoinPaymentQueryPurseRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Transfer balance between local purses. @@ -82,7 +82,7 @@ pub trait CoinPayment: Send + Sync { Subscription, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Delete a purse after draining its balance into another local purse. @@ -108,7 +108,7 @@ pub trait CoinPayment: Send + Sync { Subscription, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Create a receivable public key for depositing into a purse. @@ -127,7 +127,7 @@ pub trait CoinPayment: Send + Sync { HostCoinPaymentCreateReceivableResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Create a cheque paying from a local purse to a receivable. @@ -148,7 +148,7 @@ pub trait CoinPayment: Send + Sync { _request: HostCoinPaymentCreateChequeRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Claim coins from a cheque into the receivable's purse. @@ -175,7 +175,7 @@ pub trait CoinPayment: Send + Sync { _request: HostCoinPaymentDepositRequest, ) -> Result, CallError> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Attempt to return coins associated with a receivable. @@ -202,7 +202,7 @@ pub trait CoinPayment: Send + Sync { _request: HostCoinPaymentRefundRequest, ) -> Result, CallError> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Listen for a cheque delivered through a standard transmission channel. @@ -229,6 +229,6 @@ pub trait CoinPayment: Send + Sync { _request: HostCoinPaymentListenForRequest, ) -> Result, CallError> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/entropy.rs b/rust/crates/truapi/src/api/entropy.rs index 32f510b9b..f20fbf58c 100644 --- a/rust/crates/truapi/src/api/entropy.rs +++ b/rust/crates/truapi/src/api/entropy.rs @@ -24,6 +24,6 @@ pub trait Entropy: Send + Sync { _cx: &CallContext, _request: HostDeriveEntropyRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index eab781c5f..08adc261b 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -32,7 +32,7 @@ pub trait Payment: Send + Sync { Subscription, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Request a payment from the user. @@ -59,7 +59,7 @@ pub trait Payment: Send + Sync { _cx: &CallContext, _request: HostPaymentRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Subscribe to payment lifecycle updates for a specific payment. @@ -99,7 +99,7 @@ pub trait Payment: Send + Sync { Subscription, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Top up the user's payment balance. @@ -118,6 +118,6 @@ pub trait Payment: Send + Sync { _cx: &CallContext, _request: HostPaymentTopUpRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 5037f8cb3..ed7230ece 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -49,6 +49,6 @@ pub trait Preimage: Send + Sync { _cx: &CallContext, _request: RemotePreimageSubmitRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 3f9f90b0f..ab80bd201 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -45,6 +45,6 @@ pub trait ResourceAllocation: Send + Sync { _request: HostRequestResourceAllocationRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 86e19e3ee..1c8a9f28a 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -64,7 +64,7 @@ pub trait Signing: Send + Sync { _cx: &CallContext, _request: HostCreateTransactionRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Construct a transaction for a non-product (legacy) account. @@ -121,7 +121,7 @@ pub trait Signing: Send + Sync { HostCreateTransactionWithLegacyAccountResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Sign raw bytes with a non-product account. @@ -151,7 +151,7 @@ pub trait Signing: Send + Sync { _request: HostSignRawWithLegacyAccountRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Sign an extrinsic payload with a non-product account. @@ -196,7 +196,7 @@ pub trait Signing: Send + Sync { HostSignPayloadWithLegacyAccountResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Sign raw bytes or a message. @@ -220,7 +220,7 @@ pub trait Signing: Send + Sync { _cx: &CallContext, _request: HostSignRawRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Sign an extrinsic payload. @@ -254,6 +254,6 @@ pub trait Signing: Send + Sync { _cx: &CallContext, _request: HostSignPayloadRequest, ) -> Result> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 92756b097..b406250e3 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -66,7 +66,7 @@ pub trait StatementStore: Send + Sync { Subscription, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Create a proof for a statement. @@ -105,7 +105,7 @@ pub trait StatementStore: Send + Sync { RemoteStatementStoreCreateProofResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Create a proof for a statement using a pre-allocated allowance account, @@ -132,7 +132,7 @@ pub trait StatementStore: Send + Sync { RemoteStatementStoreCreateProofAuthorizedResponse, CallError, > { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } /// Submit a signed statement to the network. The request body is the @@ -161,6 +161,6 @@ pub trait StatementStore: Send + Sync { _cx: &CallContext, _request: RemoteStatementStoreSubmitRequest, ) -> Result<(), CallError> { - Err(CallError::unavailable()) + Err(CallError::unsupported()) } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06bb1393f..dbd821659 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -212,10 +212,13 @@ pub enum CallError { impl CallError { /// Convenience for default handlers whose implementation is not wired. - pub fn unavailable() -> Self { - Self::HostFailure { - reason: "unavailable".into(), - } + /// + /// Returns [`CallError::Unsupported`] (RFC 0027): the host will not serve + /// this method for the lifetime of the connection, so a caller must not + /// retry. `HostFailure` stays reserved for a host that attempted the + /// operation and failed, which a caller may retry. + pub fn unsupported() -> Self { + Self::Unsupported } } @@ -440,7 +443,7 @@ impl Subscription { } /// Creates a subscription that yields no items. Useful as a placeholder for - /// default "unavailable" trait bodies where the dispatcher will discard the + /// default "unsupported" trait bodies where the dispatcher will discard the /// stream and emit an Interrupt frame. pub fn empty() -> Self where @@ -480,4 +483,12 @@ mod tests { assert_eq!(reason, CancellationReason::Cancelled); assert!(cloned.is_cancelled()); } + + #[test] + fn unsupported_is_the_unwired_handler_answer() { + assert_eq!( + CallError::::unsupported(), + CallError::Unsupported + ); + } } diff --git a/rust/crates/truapi/src/v01/system.rs b/rust/crates/truapi/src/v01/system.rs index 1a97e5607..4dd2e7220 100644 --- a/rust/crates/truapi/src/v01/system.rs +++ b/rust/crates/truapi/src/v01/system.rs @@ -12,6 +12,17 @@ pub enum HostFeatureSupportedRequest { /// Chain genesis hash. genesis_hash: Vec, }, + /// Ask whether `id` opens a method on this host build (RFC 0027). + /// + /// `id` is a request discriminant or a product-facing + /// subscription-start discriminant — the two frame kinds a product can + /// begin a call with. Variant index 1; a host that cannot decode it + /// answers `CallError::MalformedFrame`, which is RFC 0027's no-support + /// signal. `Chain` stays variant index 0. + Method { + /// Request or subscription-start discriminant from the wire table. + id: u8, + }, } /// Error from [`crate::api::System::navigate_to`]. @@ -57,6 +68,35 @@ pub struct HostFeatureSupportedResponse { pub supported: bool, } +#[cfg(test)] +mod tests { + use parity_scale_codec::{Decode, Encode}; + + use super::HostFeatureSupportedRequest; + + #[test] + fn chain_keeps_variant_index_zero() { + let encoded = HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + } + .encode(); + assert_eq!(encoded.first(), Some(&0x00)); + } + + #[test] + fn every_method_id_round_trips_at_variant_index_one() { + for id in 0..=u8::MAX { + let value = HostFeatureSupportedRequest::Method { id }; + let encoded = value.encode(); + assert_eq!(encoded, vec![0x01, id]); + assert_eq!( + HostFeatureSupportedRequest::decode(&mut &encoded[..]).expect("decode"), + value + ); + } + } +} + /// Request to navigate the host to an external URL. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostNavigateToRequest {