Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions docs/rfcs/0014-contacts-api.md
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>>,
account_id: Option<AccountId>
}

struct LocalContactInfo {
display_name: Option<str>
}

struct Contact {
local: LocalContactInfo,
entries: Map<ContactContext, ContextContactInfo>
}
```

`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<DotNsIdentifier>
) -> Result<Vec<Contact>, ContactsErr>;

fn host_contacts_subscribe(
callback: fn(Vec<Contact>)
) -> Result<Subscriber, ContactsErr>;
```

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<u8>) -> Option<Contact>` 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.
1 change: 1 addition & 0 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
59 changes: 59 additions & 0 deletions rust/crates/truapi/src/api/contacts.rs
Original file line number Diff line number Diff line change
@@ -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<HostContactsGetResponse, CallError<HostContactsGetError>> {
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<Subscription<HostContactsSubscribeItem>, CallError<HostContactsSubscribeError>>
{
Err(CallError::unavailable())
}
}
80 changes: 80 additions & 0 deletions rust/crates/truapi/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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<T> TrUApi for T where
T: Account
+ Chain
+ Chat
+ CoinPayment
+ Contacts
+ Entropy
+ LocalStorage
+ Notifications
+ Payment
+ Permissions
+ Preimage
+ ResourceAllocation
+ Signing
+ StatementStore
+ System
+ Theme
+ Send
+ Sync
{
}
80 changes: 80 additions & 0 deletions rust/crates/truapi/src/v01/contacts.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>>,
/// Account public key in this context, if known.
pub account_id: Option<Vec<u8>>,
}

/// 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<String>,
}

/// 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<ContactEntry>,
}

/// 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<String>,
}

/// 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<Contact>,
}

/// 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<Contact>,
}

/// 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 },
}
Loading