From 1310d77cd62b80a2cc8d68d62948afb06e46c846 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 23 Apr 2026 13:05:05 +0200 Subject: [PATCH 1/4] RFC-0014: Contacts API --- docs/rfcs/0014-contacts-api.md | 150 +++++++++++++++++++++++++++++++++ docs/rfcs/_index.md | 1 + 2 files changed, 151 insertions(+) create mode 100644 docs/rfcs/0014-contacts-api.md diff --git a/docs/rfcs/0014-contacts-api.md b/docs/rfcs/0014-contacts-api.md new file mode 100644 index 00000000..148b97c1 --- /dev/null +++ b/docs/rfcs/0014-contacts-api.md @@ -0,0 +1,150 @@ +# RFC-0014: Contacts API + +| | | +| --------------- | --------------------------------------------------------------- | +| **Start Date** | 2026-04-17 | +| **Description** | Expose the user's contact list to products via Host API | +| **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 + +Products need to resolve human-readable identities to accounts. The host manages an address book but does not expose it. Without this API, users must paste raw keys or scan QR codes for every interaction. + +Exposing the contact list: + +1. **Removes friction** — products show names instead of raw addresses. +2. **Enables cross-product identity** — multiple products resolve the same contact within their respective contexts. +3. **Preserves user control** — the host gates access and filters responses to the requesting product's scope. +4. **Supports contextual accounts** — a contact has different aliases and accounts per DotNS context, preserving unlinkability. + +## Detailed Design + +### Data Model + +```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 same tuple used for Ring VRF alias derivation. The host derives the `[u8; 32]` Ring VRF context by hashing this identifier internally. + +`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. This is safe because the product could already derive this information through its own alias system. + +#### 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, + Rejected, + Unknown(GenericErr) +} + +fn host_contacts_get() -> 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. + +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: + +```rust +enum DevicePermission { + // ... existing variants ... + 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 + +Loses alignment with Ring VRF contexts and makes scoping ambiguous. + +### B: Per-contact lookup by alias + +Requires knowing the alias upfront; does not support browsing. + +### C: No context scoping + +Breaks unlinkability — any product could correlate aliases across all contexts. + +## Unresolved Questions + +1. **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. +2. **Common triage contexts.** Should well-known contexts (profile, honour) have a lighter permission model? +3. **Contact mutation.** Write access deferred to a follow-up RFC. +4. **Filtered subscriptions.** Should tier 2 `host_contacts_subscribe` accept a context filter? +5. **Overlay specification.** The exact overlay mechanism needs its own spec. +6. **Pagination.** May be needed for large contact lists. 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) | From 69ac4ca4c23105f4148ade3651c98728c3fe3e51 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 30 Apr 2026 09:12:33 +0200 Subject: [PATCH 2/4] Address review feedback on RFC-0014 Contacts API - Rewrite motivation: frame around privacy model (alias-per-context), contact list as private mapping notebook, social circle analogy - Replace generic bullet points with concrete value propositions - Note that hosts already have internal contact schemas - Clarify ContactContext/Ring VRF type relationship and DerivationIndex need - Fix tier 1 safety explanation - Add context: Option parameter to host_contacts_get - Flesh out alternatives with clear rationale - Add unresolved question about contact ingestion - Expand pagination concern --- docs/rfcs/0014-contacts-api.md | 50 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/rfcs/0014-contacts-api.md b/docs/rfcs/0014-contacts-api.md index 148b97c1..9a6e2f44 100644 --- a/docs/rfcs/0014-contacts-api.md +++ b/docs/rfcs/0014-contacts-api.md @@ -3,7 +3,7 @@ | | | | --------------- | --------------------------------------------------------------- | | **Start Date** | 2026-04-17 | -| **Description** | Expose the user's contact list to products via Host API | +| **Description** | Expose the user's contact list to products via TrUAPI | | **Authors** | Filippo Vecchiato | ## Summary @@ -12,19 +12,22 @@ Products can read the user's host-managed address book. Each contact pairs local ## Motivation -Products need to resolve human-readable identities to accounts. The host manages an address book but does not expose it. Without this API, users must paste raw keys or scan QR codes for every interaction. +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. **Removes friction** — products show names instead of raw addresses. -2. **Enables cross-product identity** — multiple products resolve the same contact within their respective contexts. -3. **Preserves user control** — the host gates access and filters responses to the requesting product's scope. -4. **Supports contextual accounts** — a contact has different aliases and accounts per DotNS context, preserving unlinkability. +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) @@ -43,7 +46,7 @@ struct Contact { } ``` -`ContactContext` is a `ProductAccountId` (`DotNsIdentifier` + `DerivationIndex`) — the same tuple used for Ring VRF alias derivation. The host derives the `[u8; 32]` Ring VRF context by hashing this identifier internally. +`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. @@ -51,7 +54,7 @@ struct Contact { #### Tier 1: Own-context (default) -The host filters `entries` to only the requesting product's `ProductAccountId`. `LocalContactInfo` is always included. This is safe because the product could already derive this information through its own alias system. +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. #### Tier 2: Cross-context (privileged) @@ -66,7 +69,9 @@ enum ContactsErr { Unknown(GenericErr) } -fn host_contacts_get() -> Result, ContactsErr>; +fn host_contacts_get( + context: Option +) -> Result, ContactsErr>; fn host_contacts_subscribe( callback: fn(Vec) @@ -75,6 +80,8 @@ fn host_contacts_subscribe( 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 @@ -128,23 +135,24 @@ The host can render a contact picker in a privileged overlay using full contact ## Alternatives -### A: Freeform context keys +### A: Freeform context keys instead of ProductAccountId -Loses alignment with Ring VRF contexts and makes scoping ambiguous. +Using arbitrary strings as context keys would lose alignment with Ring VRF contexts and make scoping ambiguous — there would be no canonical key for "this product's view of a contact." -### B: Per-contact lookup by alias +### B: Per-contact lookup by alias instead of full list -Requires knowing the alias upfront; does not support browsing. +An API that takes an alias and returns the matching contact would require the product to already know the alias, which defeats the discovery use case. Products need to browse the contact list, not just resolve known identifiers. -### C: No context scoping +### C: No context scoping — return all entries to all products -Breaks unlinkability — any product could correlate aliases across all contexts. +Simpler, but breaks unlinkability. Any product could correlate aliases across all contexts, learning which contacts the user interacts with in other products. ## Unresolved Questions -1. **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. -2. **Common triage contexts.** Should well-known contexts (profile, honour) have a lighter permission model? -3. **Contact mutation.** Write access deferred to a follow-up RFC. -4. **Filtered subscriptions.** Should tier 2 `host_contacts_subscribe` accept a context filter? -5. **Overlay specification.** The exact overlay mechanism needs its own spec. -6. **Pagination.** May be needed for large contact lists. +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. From e1424db1c98316840ec96e6488437726c73fb6f8 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 7 May 2026 15:54:26 +0200 Subject: [PATCH 3/4] Address review feedback from triangle-js-sdks#137 - Clarify that new DevicePermission variants are defined in this RFC, not as an amendment to RFC-0002 (@Imod7) - Document when each ContactsErr variant is returned - Explain what tier 1 reveals vs what a product already knows: the product knows its own user's alias but not other users' aliases in the same context (@BigTava) - Flesh out alternatives with concrete examples and threat model so trade-offs are easier to evaluate (@BigTava) --- docs/rfcs/0014-contacts-api.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/0014-contacts-api.md b/docs/rfcs/0014-contacts-api.md index 9a6e2f44..788a9d94 100644 --- a/docs/rfcs/0014-contacts-api.md +++ b/docs/rfcs/0014-contacts-api.md @@ -56,6 +56,8 @@ struct Contact { 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). @@ -64,8 +66,8 @@ Returns the full `entries` map. Required for host-privileged products that aggre ```rust enum ContactsErr { - NotConnected, - Rejected, + NotConnected, // user has no active session + Rejected, // user denied the permission prompt Unknown(GenericErr) } @@ -86,11 +88,11 @@ This API returns only contacts the user has explicitly saved in their address bo ### Permission Model -Extends `DevicePermission` from RFC-0002 with two new variants: +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 ... + // ... existing variants from RFC-0002 ... Contacts, ContactsCrossContext } @@ -137,15 +139,15 @@ The host can render a contact picker in a privileged overlay using full contact ### A: Freeform context keys instead of ProductAccountId -Using arbitrary strings as context keys would lose alignment with Ring VRF contexts and make scoping ambiguous — there would be no canonical key for "this product's view of a contact." +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 that takes an alias and returns the matching contact would require the product to already know the alias, which defeats the discovery use case. Products need to browse the contact list, not just resolve known identifiers. +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 -Simpler, but breaks unlinkability. Any product could correlate aliases across all contexts, learning which contacts the user interacts with in other 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 From aea42f52060f03aae67e0d475e2ffc0d23287c96 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 9 Jun 2026 16:59:59 +0200 Subject: [PATCH 4/4] feat: implement Contacts API trait and wire types (RFC 0014) Adds the `Contacts` trait to the TrUAPI contract with two methods: - `get` (wire 162): retrieve the user's filtered contact list - `subscribe` (wire 164): fallible subscription to contact list updates Introduces v01 wire types (`Contact`, `ContactEntry`, `ContextContactInfo`, `LocalContactInfo`, `HostContactsGetRequest/Response`, `HostContactsError`) and versioned envelopes. Extends `HostDevicePermissionRequest` with `Contacts` (tier 1) and `ContactsCrossContext` (tier 2) variants. --- rust/crates/truapi/src/api/contacts.rs | 59 ++++++++++ rust/crates/truapi/src/api/mod.rs | 80 +++++++++++++ rust/crates/truapi/src/v01/contacts.rs | 80 +++++++++++++ rust/crates/truapi/src/v01/mod.rs | 39 ++++++ rust/crates/truapi/src/v01/permissions.rs | 84 +++++++++++++ rust/crates/truapi/src/versioned/contacts.rs | 12 ++ rust/crates/truapi/src/versioned/mod.rs | 118 +++++++++++++++++++ 7 files changed, 472 insertions(+) create mode 100644 rust/crates/truapi/src/api/contacts.rs create mode 100644 rust/crates/truapi/src/api/mod.rs create mode 100644 rust/crates/truapi/src/v01/contacts.rs create mode 100644 rust/crates/truapi/src/v01/mod.rs create mode 100644 rust/crates/truapi/src/v01/permissions.rs create mode 100644 rust/crates/truapi/src/versioned/contacts.rs create mode 100644 rust/crates/truapi/src/versioned/mod.rs 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); + } +}