diff --git a/docs/rfcs/0014-contacts-api.md b/docs/rfcs/0014-contacts-api.md new file mode 100644 index 00000000..788a9d94 --- /dev/null +++ b/docs/rfcs/0014-contacts-api.md @@ -0,0 +1,160 @@ +# RFC-0014: Contacts API + +| | | +| --------------- | --------------------------------------------------------------- | +| **Start Date** | 2026-04-17 | +| **Description** | Expose the user's contact list to products via TrUAPI | +| **Authors** | Filippo Vecchiato | + +## Summary + +Products can read the user's host-managed address book. Each contact pairs local metadata with a context-scoped map keyed by `ProductAccountId` (`DotNsIdentifier` + `DerivationIndex`) — the same namespace used for Ring VRF alias derivation. By default a product only sees entries for its own context; cross-context access is a separate privilege. + +## Motivation + +Our privacy model gives each user a different alias and account in each product context, so no single handle identifies a person across products. A contact list matters because it is the most convenient way for a user to maintain a private notebook of mappings — local name ("Alice") to whichever identifier represents her in each product. + +The host already manages an address book, but does not expose it to products. Without this API, products cannot leverage the user's social circle to provide useful features — users must paste raw keys or scan QR codes for every interaction. Think of it like Spotify connecting to your Facebook friends, or WhatsApp reading your phone's contact list: letting products see the user's contacts (with permission) unlocks a class of social features that are otherwise impossible. + +Exposing the contact list: + +1. **Unlocks social features** — products can use the host's contact list to show who among the user's contacts is relevant in their context (e.g. "friends who also use this app"), without the user re-entering information. +2. **Per-product views of shared contacts** — multiple products see the same contact through their own context lens, each resolving to the appropriate alias and account for that product. +3. **Lets users navigate contextual identities** — a contact has different aliases and accounts per DotNS context; the API lets users see and navigate these mappings while preserving unlinkability across products. + +## Detailed Design + +### Data Model + +Each host already has its own contact schema (e.g. desktop uses `P2PPeer { type, accountId, name }`, mobile uses `Chat.Contact { accountId, username, ... }`). This RFC does not replace those internal schemas — it defines the product-facing API shape that hosts translate their internal data into. + +```rust +type ContactContext = ProductAccountId; // (DotNsIdentifier, DerivationIndex) + +struct ContextContactInfo { + alias: Option>, + account_id: Option +} + +struct LocalContactInfo { + display_name: Option +} + +struct Contact { + local: LocalContactInfo, + entries: Map +} +``` + +`ContactContext` is a `ProductAccountId` (`DotNsIdentifier` + `DerivationIndex`). The `DerivationIndex` is needed since there can be multiple derivations for a given account. The host derives the `[u8; 32]` Ring VRF context by hashing this identifier internally — note that the Ring VRF context type (`[u8; 32]`) differs from `ProductAccountId` in format; the conversion is a host-internal concern. + +`ContextContactInfo` fields are optional; either or both may be present. + +### Access Tiers + +#### Tier 1: Own-context (default) + +The host filters `entries` to only the requesting product's `ProductAccountId`. `LocalContactInfo` is always included. The product sees only identifiers scoped to its own context — it cannot learn the user's aliases or accounts in other products. + +Note: while a product already knows the *current user's own* alias in its context, it does not know the aliases of *other users* in that same context. Tier 1 reveals those peer aliases within the product's context only, which is the minimum needed for social features like showing "friends who also use this app." + +#### Tier 2: Cross-context (privileged) + +Returns the full `entries` map. Required for host-privileged products that aggregate identities across contexts (e.g. Browse, profile, honour). The host MAY grant implicit tier 2 access to built-in host products that need it for their core function (e.g. a contact management UI). + +### API + +```rust +enum ContactsErr { + NotConnected, // user has no active session + Rejected, // user denied the permission prompt + Unknown(GenericErr) +} + +fn host_contacts_get( + context: Option +) -> Result, ContactsErr>; + +fn host_contacts_subscribe( + callback: fn(Vec) +) -> Result; +``` + +Both require authentication (RFC-0009). The host prompts for permission before returning. `host_contacts_subscribe` delivers the full filtered list on each callback; hosts MAY debounce. + +When `context` is `None`, the host uses the calling product's own `DotNsIdentifier` (tier 1). When `context` is `Some(identifier)` and matches the calling product, it is equivalent to `None` (tier 1). When `context` names a different product, the host requires `DevicePermission::ContactsCrossContext` (tier 2) and filters entries to that product's context. + +This API returns only contacts the user has explicitly saved in their address book. It is not a global name resolution service — resolving arbitrary accounts to DotNS names is a separate concern (on-chain DotNS lookup). + +### Permission Model + +Extends `DevicePermission` from RFC-0002 with two new variants. These are defined here rather than amending RFC-0002, since they are specific to this feature. Hosts add them to their existing `DevicePermission` enum. + +```rust +enum DevicePermission { + // ... existing variants from RFC-0002 ... + Contacts, + ContactsCrossContext +} +``` + +| Permission | Tier | Grants | +|-----------|------|--------| +| `Contacts` | 1 | Own-context entries + local info | +| `ContactsCrossContext` | 2 | Full entries across all contexts | + +The tier 2 prompt SHOULD warn that the product can correlate contacts across contexts. `ContactsCrossContext` implies `Contacts`. + +### Example + +``` +Product ("voting.dot", 0) calls host_contacts_get(): + +→ Host checks DevicePermission::Contacts grant +→ Host filters each contact's entries to key ("voting.dot", 0) +→ Returns: + [ + Contact { + local: { display_name: "Alice" }, + entries: { ("voting.dot", 0): { alias: 0xab.., account_id: 0x12.. } } + }, + Contact { + local: { display_name: "Bob" }, + entries: {} // Bob has no entry in ("voting.dot", 0) context + } + ] +``` + +### Privacy-Preserving Display + +The host can render a contact picker in a privileged overlay using full contact data, returning only the selected contact's own-context entry to the product. This lets users see rich details without the product receiving cross-context data. The overlay mechanism is host-specific and out of scope. + +## Drawbacks + +- **Privacy surface.** Even tier 1 reveals the user's social graph. The permission prompt mitigates but does not eliminate this. +- **Full-list delivery.** No per-contact queries. The overlay pattern partially addresses this for picker UIs. +- **Read-only.** Products cannot add contacts. Deferred intentionally. + +## Alternatives + +### A: Freeform context keys instead of ProductAccountId + +Context keys could be arbitrary strings chosen by each product (e.g. `"my-app-v2"`). This would lose alignment with Ring VRF contexts — there would be no canonical key for "this product's view of a contact," and products could invent colliding keys. Using `ProductAccountId` keeps context keys deterministic and tied to DotNS identity, which the host already understands. + +### B: Per-contact lookup by alias instead of full list + +An API like `host_contact_lookup(alias: Vec) -> Option` would require the product to already know a contact's alias before looking them up, which defeats the discovery use case. The core scenario — "show me which of my contacts are relevant here" — requires browsing the full list. A lookup API could complement the list API but cannot replace it. + +### C: No context scoping — return all entries to all products + +The simplest approach: every product gets every contact's full `entries` map. This breaks unlinkability — a malicious product could correlate aliases across all contexts to learn which contacts the user interacts with in other products, building a cross-product social graph the user never consented to share. The two-tier model preserves unlinkability by default while still allowing privileged products (with explicit permission) to access cross-context data. + +## Unresolved Questions + +1. **How do contacts enter the address book?** This RFC is read-only. The mechanism by which contacts are added (peer discovery, QR scan, manual entry, chat history) is host-specific and not specified here. A follow-up RFC should define a product-facing write API. +2. **Honour.** Needs a protected path so UAs can display honour without exposing the alias to the product. Whether honour is per-product or universal (or both) needs design. Likely a separate RFC. +3. **Common triage contexts.** Should well-known contexts (profile, honour) have a lighter permission model? +4. **Contact mutation.** Write access deferred to a follow-up RFC. +5. **Filtered subscriptions.** Should tier 2 `host_contacts_subscribe` accept a context filter? +6. **Overlay specification.** The exact overlay mechanism needs its own spec. +7. **Pagination.** May be needed for large contact lists — full-list delivery could become a performance concern as address books grow. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 2e478a63..bc6b43f1 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -17,3 +17,4 @@ created: 2026-03-13 | 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | accepted | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) | | 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | @filvecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) | | 0010 | [Root account access Host API](0010-get-root-account.md) | accepted | @johnthecat | [#126](https://github.com/paritytech/triangle-js-sdks/pull/126) | +| 0014 | [Contacts API](0014-contacts-api.md) | draft | @filvecchiato | [#137](https://github.com/paritytech/triangle-js-sdks/pull/137) | diff --git a/rust/crates/truapi/src/api/contacts.rs b/rust/crates/truapi/src/api/contacts.rs new file mode 100644 index 00000000..32912c02 --- /dev/null +++ b/rust/crates/truapi/src/api/contacts.rs @@ -0,0 +1,59 @@ +//! Unified [`Contacts`] trait. + +use crate::versioned::contacts::{ + HostContactsGetError, HostContactsGetRequest, HostContactsGetResponse, + HostContactsSubscribeError, HostContactsSubscribeItem, HostContactsSubscribeRequest, +}; +use crate::wire; +use crate::{CallContext, CallError, Subscription}; + +/// Read access to the user's host-managed address book (RFC 0014). +/// +/// Each contact pairs host-local metadata with a context-scoped map keyed by +/// `ProductAccountId`. By default a product only sees entries for its own +/// context (tier 1); cross-context access requires the +/// `ContactsCrossContext` device permission (tier 2). +pub trait Contacts: Send + Sync { + /// Retrieve the user's contact list. + /// + /// When `context` is `None`, the host filters entries to the calling + /// product's own context (tier 1). When `context` names a different + /// product, the host requires `ContactsCrossContext` permission (tier 2). + /// + /// ```ts + /// const result = await truapi.contacts.get({}); + /// assert(result.isOk(), "contacts.get failed:", result); + /// console.log("contacts:", result.value.contacts); + /// ``` + #[wire(request_id = 162)] + async fn get( + &self, + _cx: &CallContext, + _request: HostContactsGetRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Subscribe to contact list updates. + /// + /// Delivers the full filtered list on each callback; hosts may debounce. + /// Uses the same access-tier logic as [`Contacts::get`]. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const contacts = await firstValueFrom( + /// from(truapi.contacts.subscribe({ request: {} })), + /// ); + /// console.log("contacts update:", contacts); + /// ``` + #[wire(start_id = 164)] + async fn subscribe( + &self, + _cx: &CallContext, + _request: HostContactsSubscribeRequest, + ) -> Result, CallError> + { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api/mod.rs new file mode 100644 index 00000000..c961782d --- /dev/null +++ b/rust/crates/truapi/src/api/mod.rs @@ -0,0 +1,80 @@ +//! Unified TrUAPI trait set. + +pub mod account; +pub mod chain; +pub mod chat; +pub mod coin_payment; +pub mod contacts; +pub mod entropy; +pub mod local_storage; +pub mod notifications; +pub mod payment; +pub mod permissions; +pub mod preimage; +pub mod resource_allocation; +pub mod signing; +pub mod statement_store; +pub mod system; +pub mod theme; + +pub use account::Account; +pub use chain::Chain; +pub use chat::Chat; +pub use coin_payment::CoinPayment; +pub use contacts::Contacts; +pub use entropy::Entropy; +pub use local_storage::LocalStorage; +pub use notifications::Notifications; +pub use payment::Payment; +pub use permissions::Permissions; +pub use preimage::Preimage; +pub use resource_allocation::ResourceAllocation; +pub use signing::Signing; +pub use statement_store::StatementStore; +pub use system::System; +pub use theme::Theme; + +/// The unified TrUAPI contract. +pub trait TrUApi: + Account + + Chain + + Chat + + CoinPayment + + Contacts + + Entropy + + LocalStorage + + Notifications + + Payment + + Permissions + + Preimage + + ResourceAllocation + + Signing + + StatementStore + + System + + Theme + + Send + + Sync +{ +} + +impl TrUApi for T where + T: Account + + Chain + + Chat + + CoinPayment + + Contacts + + Entropy + + LocalStorage + + Notifications + + Payment + + Permissions + + Preimage + + ResourceAllocation + + Signing + + StatementStore + + System + + Theme + + Send + + Sync +{ +} diff --git a/rust/crates/truapi/src/v01/contacts.rs b/rust/crates/truapi/src/v01/contacts.rs new file mode 100644 index 00000000..1886265c --- /dev/null +++ b/rust/crates/truapi/src/v01/contacts.rs @@ -0,0 +1,80 @@ +use parity_scale_codec::{Decode, Encode}; + +use crate::v01::ProductAccountId; + +/// Context key for a contact entry, scoped to a specific product account. +pub type ContactContext = ProductAccountId; + +/// A contact's identity within a specific product context. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ContextContactInfo { + /// Ring VRF alias in this context, if known. + pub alias: Option>, + /// Account public key in this context, if known. + pub account_id: Option>, +} + +/// Host-local metadata for a contact (not context-scoped). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct LocalContactInfo { + /// User-chosen display name for this contact. + pub display_name: Option, +} + +/// A single contact from the user's address book. +/// +/// Pairs host-local metadata with a map of context-scoped entries keyed by +/// [`ProductAccountId`]. Depending on the caller's access tier, `entries` may +/// contain only the requesting product's context (tier 1) or entries across +/// all contexts (tier 2). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Contact { + /// Host-local metadata (display name, etc.). + pub local: LocalContactInfo, + /// Context-scoped entries keyed by `ProductAccountId`. + pub entries: Vec, +} + +/// A single context-scoped entry within a [`Contact`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ContactEntry { + /// The product context this entry belongs to. + pub context: ContactContext, + /// Identity information within this context. + pub info: ContextContactInfo, +} + +/// Request to retrieve the user's contact list. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsGetRequest { + /// Optional context filter. When `None`, the host uses the calling + /// product's own `DotNsIdentifier` (tier 1). When `Some`, the host + /// filters entries to that product's context — cross-context access + /// requires `ContactsCrossContext` permission. + pub context: Option, +} + +/// Response containing the user's filtered contact list. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsGetResponse { + /// Contacts from the user's address book, filtered by access tier. + pub contacts: Vec, +} + +/// Subscription item delivering an updated snapshot of the contact list. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostContactsSubscribeItem { + /// Full filtered contact list at the time of the update. + pub contacts: Vec, +} + +/// Error returned by contacts operations. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostContactsError { + /// User is not logged in. + NotConnected, + /// User denied the permission prompt. + Rejected, + /// Catch-all. + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01/mod.rs new file mode 100644 index 00000000..667746d9 --- /dev/null +++ b/rust/crates/truapi/src/v01/mod.rs @@ -0,0 +1,39 @@ +//! TrUAPI Protocol v0.1 type definitions. + +mod account; +mod chain; +mod chat; +mod coin_payment; +mod common; +mod contacts; +mod entropy; +mod local_storage; +mod notifications; +mod payment; +mod permissions; +mod preimage; +mod resource_allocation; +mod signing; +mod statement_store; +mod system; +mod theme; +mod transaction; + +pub use account::*; +pub use chain::*; +pub use chat::*; +pub use coin_payment::*; +pub use common::*; +pub use contacts::*; +pub use entropy::*; +pub use local_storage::*; +pub use notifications::*; +pub use payment::*; +pub use permissions::*; +pub use preimage::*; +pub use resource_allocation::*; +pub use signing::*; +pub use statement_store::*; +pub use system::*; +pub use theme::*; +pub use transaction::*; diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs new file mode 100644 index 00000000..3058861e --- /dev/null +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -0,0 +1,84 @@ +use derive_more::Display; +use parity_scale_codec::{Decode, Encode}; + +/// Device-capability permission requested from the host (RFC 0002). +/// +/// The user's decision is persisted indefinitely after the first prompt and +/// survives app restarts, whether the decision was grant or deny; the host +/// does not re-prompt on subsequent requests for the same capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Display)] +#[allow(clippy::upper_case_acronyms)] +pub enum HostDevicePermissionRequest { + #[display("notifications")] + Notifications, + #[display("camera")] + Camera, + #[display("microphone")] + Microphone, + #[display("bluetooth")] + Bluetooth, + #[display("NFC")] + NFC, + #[display("location")] + Location, + #[display("clipboard")] + Clipboard, + #[display("open URL")] + OpenUrl, + #[display("biometrics")] + Biometrics, + /// Own-context contact list access (RFC 0014, tier 1). + #[display("contacts")] + Contacts, + /// Cross-context contact list access (RFC 0014, tier 2). Implies `Contacts`. + #[display("contacts (cross-context)")] + ContactsCrossContext, +} + +/// One remote-operation permission requested by the product (RFC 0002). +/// +/// `ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered +/// implicitly by the corresponding business calls when not yet granted. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] +pub enum RemotePermission { + /// Outbound HTTP/WebSocket access to a set of domains. + #[display("access to {}", domains.join(", "))] + Remote { + /// Domain patterns requested by the product. + domains: Vec, + }, + /// WebRTC media access. + #[display("WebRTC connections")] + WebRtc, + /// Submitting transactions on behalf of the user via `remote_chain_transaction_broadcast`. + #[display("submit chain transactions")] + ChainSubmit, + /// Submitting preimages on behalf of the user via `remote_preimage_submit`. + #[display("submit preimages")] + PreimageSubmit, + /// Submitting statements on behalf of the user via `remote_statement_store_submit`. + #[display("submit statements")] + StatementSubmit, +} + +/// remote-permission request (RFC 0002). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] +#[display("{permission}")] +pub struct RemotePermissionRequest { + /// Permission requested by the product. + pub permission: RemotePermission, +} + +/// Outcome of a device-permission request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostDevicePermissionResponse { + /// Whether the permission was granted. + pub granted: bool, +} + +/// Outcome of a remote-permission request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RemotePermissionResponse { + /// Whether the permission was granted. + pub granted: bool, +} diff --git a/rust/crates/truapi/src/versioned/contacts.rs b/rust/crates/truapi/src/versioned/contacts.rs new file mode 100644 index 00000000..15b4f57d --- /dev/null +++ b/rust/crates/truapi/src/versioned/contacts.rs @@ -0,0 +1,12 @@ +//! Versioned wrappers for [`Contacts`](crate::api::Contacts) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostContactsGetRequest { V1 => v01::HostContactsGetRequest } + pub enum HostContactsGetResponse { V1 => v01::HostContactsGetResponse } + pub enum HostContactsGetError { V1 => v01::HostContactsError } + pub enum HostContactsSubscribeRequest { V1 => v01::HostContactsGetRequest } + pub enum HostContactsSubscribeItem { V1 => v01::HostContactsSubscribeItem } + pub enum HostContactsSubscribeError { V1 => v01::HostContactsError } +} diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs new file mode 100644 index 00000000..2014917d --- /dev/null +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -0,0 +1,118 @@ +//! Versioned request and response wrappers for the unified TrUAPI contract. +//! +//! A versioned envelope is a SCALE enum whose variants (`V1`, `V2`, ...) are +//! successive versions of one logical message, newest last. A server normalizes +//! incoming values to [`Versioned::Latest`] with [`IntoLatest`], handles them in +//! latest terms, then maps results back to the caller's version with +//! [`FromLatest`]. The envelopes themselves are generated by `versioned_type!`. + +/// A versioned message envelope. +pub trait Versioned: Sized { + /// The newest version's payload. Handlers operate exclusively on this. + type Latest; + + /// Version number of the newest variant. + const LATEST: u8; + + /// Version number of the variant currently held. + fn version(&self) -> u8; +} + +/// Upgrade a received envelope to its latest payload. Total by construction. +pub trait IntoLatest: Versioned { + /// Convert whatever version is held into the latest payload. + fn into_latest(self) -> Self::Latest; +} + +/// Downgrade a latest payload into the variant a peer at `target` understands. +pub trait FromLatest: Versioned { + /// Build the envelope for protocol version `target` (highest variant ≤ target). + fn from_latest(latest: Self::Latest, target: u8) -> Self; +} + +pub mod account; +pub mod chain; +pub mod chat; +pub mod coin_payment; +pub mod contacts; +pub mod entropy; +pub mod local_storage; +pub mod notifications; +pub mod payment; +pub mod permissions; +pub mod preimage; +pub mod resource_allocation; +pub mod signing; +pub mod statement_store; +pub mod system; +pub mod theme; + +#[cfg(test)] +mod tests { + use parity_scale_codec::{Decode, Encode}; + + #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] + struct ProbeV1 { + a: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] + struct ProbeV2 { + b: Vec, + } + + truapi_macros::versioned_type! { + enum MultiVersionProbe { + V1 => ProbeV1, + V2 => ProbeV2, + } + } + + // Multi-version envelopes assign positional SCALE codec indices (V1 -> 0, + // V2 -> 1) and 1-based version numbers. + #[test] + fn multi_version_codec_indices_are_positional() { + use super::Versioned; + + let v1 = MultiVersionProbe::V1(ProbeV1 { a: 7 }); + let v2 = MultiVersionProbe::V2(ProbeV2 { + b: b"hello".to_vec(), + }); + + assert_eq!(v1.encode()[0], 0, "V1 encodes codec index 0"); + assert_eq!(v2.encode()[0], 1, "V2 encodes codec index 1"); + assert_eq!(v1.version(), 1); + assert_eq!(v2.version(), 2); + assert_eq!(MultiVersionProbe::LATEST, 2); + } + + #[test] + fn v1_discriminant_is_zero() { + let v1 = super::permissions::HostDevicePermissionRequest::V1( + crate::v01::HostDevicePermissionRequest::Camera, + ); + assert_eq!(v1.encode()[0], 0, "V1 must encode discriminant 0"); + } + + #[test] + fn unit_response_roundtrip() { + let original = super::system::HostNavigateToResponse::V1; + let decoded = super::system::HostNavigateToResponse::decode(&mut &original.encode()[..]) + .expect("decode"); + assert_eq!(original, decoded); + } + + #[test] + fn struct_variant_roundtrip() { + let original = super::local_storage::HostLocalStorageWriteRequest::V1( + crate::v01::HostLocalStorageWriteRequest { + key: "greeting".into(), + value: b"hello".to_vec(), + }, + ); + let decoded = + super::local_storage::HostLocalStorageWriteRequest::decode(&mut &original.encode()[..]) + .expect("decode"); + assert_eq!(original, decoded); + } +}