Skip to content
200 changes: 200 additions & 0 deletions docs/rfcs/0004-ringlocation-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# RFC 0004 — Redesign `host_account_create_proof`

| | |
| --------------- |-----------------------------------------------------------------------------------------------------------|
| **Start Date** | 2026-03-16 |
| **Description** | Junction-based RingLocation, context-scoped proofs, and a specified host member-key selection contract |
| **Authors** | Valentin Sergeev |

## Summary

Redesign `host_account_create_proof` and `host_account_get_alias`:

1. **Junction-based ring addressing** — replace the `ring_root_hash`-based `RingLocation` with a struct carrying a required `chain_id` and a `Vec<RingLocationJunction>` path of stable, immutable identifiers.
2. **Member-key-based, context-scoped proofs** — replace `domain: ProductAccountId` with `ProductProofContext = (ProductId, ProductProofContextSuffix)`. The proof is created with a member key the host holds (selected for the requested ring); the context scopes the derived alias for unlinkability.
3. **Richer output and errors** — return `contextual_alias`, `ring_index`, and `ring_revision`; specify host member-key selection; add a `NotMember` error.

No protocol version bump is required: the current shape of these methods is unusable (the `ring_root_hash` race makes it broken by construction) and is not implemented or consumed anywhere yet, so it can be replaced in place.

## Motivation

- **Request invalidation.** `ring_root_hash` changes whenever ring membership changes, invalidating any in-flight proof request built against the previous root.
- **No revision in the response.** Downstream consumers (coinage's recycler transaction extension, the `personhoodInfoByProof` precompile) need the ring revision and index, which the current `Vec<u8>` return cannot carry.
- **Hints can't address multi-ring pallets.** With the membership pallet, one pallet instance hosts rings from multiple collections, each identified by `(collection_id, ring_index)`. `RingLocationHint`'s optional `pallet_instance` cannot disambiguate them.
- **`domain: ProductAccountId` is the wrong input.** Proof generation depends only on which member key proves membership in the requested ring — the host holds one or more member keys (possibly different keys for different rings) and selects the right one. A derived product account and its derivation index have nothing to do with that. The old signature conflated product-account derivation with proof generation; unlinkability instead comes from the `context` (the same member key under different contexts yields different, unlinkable aliases), so the request needs an explicit, product-scoped context rather than a derivation index.
- **Member-key selection is unspecified.** A host may hold several member keys but the API hides them (exposing them leaks identity). Without a defined selection contract, two hosts can derive different aliases for the same request.
- **No "not a member" error.** A user who has not reached full personhood is not in the ring. `CreateProofErr` cannot distinguish this from "ring does not exist", so products can't route the user to onboarding.

## Status Quo

```rust
struct RingLocationHint { pallet_instance: Option<u32> }
struct RingLocation { genesis_hash: GenesisHash, ring_root_hash: Vec<u8>, hints: Option<RingLocationHint> }
type RingVrfProof = Vec<u8>;

fn host_account_create_proof(domain: ProductAccountId, ring: RingLocation, message: Vec<u8>)
-> Result<RingVrfProof, CreateProofErr>;
fn host_account_get_alias(domain: ProductAccountId)
-> Result<ContextualAlias, RequestCredentialsErr>;
```

## Design

### Ring addressing

`chain_id` is a required field (not a junction) so a location can never omit its chain; the junctions address the ring within it. All identifiers are stable for the ring's lifetime, so the host can resolve the current root and the caller's index internally without a membership-change race. New `RingLocationJunction` variants can be added without breaking consumers. (The junction pattern is borrowed from XCM's `MultiLocation`.)

```rust
enum RingLocationJunction {
PalletInstance(u8),
CollectionId(Vec<u8>),
}

struct RingLocation {
chain_id: GenesisHash,
junctions: Vec<RingLocationJunction>,
}
```

### Product-scoped proof context

`ProductId` is the existing dotNS product identifier (named here as a reminder of what scopes the context). `domain: ProductAccountId` is replaced by:

```rust
type ProductProofContextSuffix = Vec<u8>; // arbitrary bytes
type ProductProofContext = (ProductId, ProductProofContextSuffix);

// 32-byte context bound into the proof.
fn product_context_bytes(context: ProductProofContext) -> [u8; 32] {
blake2b256(utf8("product/") ++ utf8(context.0) ++ utf8("/") ++ context.1)
}
```

- **Product-scoped.** The `product/<product_id>/` prefix stops a malicious product from choosing a suffix that collides with another product's context and thereby links its aliases. This is a privacy boundary.
- **Arbitrary-byte suffix.** Some contexts need more than one index — e.g. a pgas claim derives its context from two `u32`s (period and sequence). A single-index suffix would make them unrepresentable.

### `create_proof` and `get_alias`

The proof is created with a member key the host holds; the host selects which key based on the requested ring (see below). Both methods take the same `(context, ring)` so they derive the same alias.

```rust
struct RingVrfProof {
proof: Vec<u8>,
contextual_alias: ContextualAlias,
ring_index: u32,
ring_revision: u32,
}

fn host_account_create_proof(context: ProductProofContext, ring: RingLocation, message: Vec<u8>)
-> Result<RingVrfProof, CreateProofErr>;

fn host_account_get_alias(context: ProductProofContext, ring: RingLocation)
-> Result<ContextualAlias, RequestCredentialsErr>;
```

`ring_index` / `ring_revision` let products call downstream precompiles without a separate lookup. `contextual_alias` is an ergonomics optimization — the same value `get_alias` returns for the same `(context, ring)` — saving a round trip when a caller needs both proof and alias (e.g. a voting contract keying votes by alias). The host MUST select the member key identically in both methods so the two aliases match.

### Host member-key selection

The host may hold multiple member keys; the API exposes neither the keys nor their ids. The host MUST:

1. Define the **"PoP" ring collection** as the collection corresponding to full-personhood rings.
2. Choose a member key that is present in / logically corresponds to the requested `RingLocation`.
3. If correspondence is not determinable, fall back to a key corresponding to the "PoP" ring.
4. If multiple keys correspond to the same ring, consistently pick any one — the choice MUST be stable across calls for the same inputs so the alias is stable.

**Out of scope:** explicit member-key management (letting the caller reference a specific key rather than having the host infer one) is left to a future RFC — exposing keys or their ids is a separate, larger design with its own privacy considerations.

### Errors

```rust
enum CreateProofErr { RingNotFound, NotMember, Rejected, Unknown }
```

`NotMember` is returned when the selected member key is not a member of the requested ring — most importantly when the user has not yet reached full personhood — letting products distinguish it from `RingNotFound` and route to onboarding.

### Usage

`ring_root_hash`, `hints`, and the `domain` parameter are gone — products never fetch or hash ring roots or manage derivation indices.

```rust
let location = RingLocation {
chain_id: chain_genesis,
junctions: vec![
RingLocationJunction::PalletInstance(42),
RingLocationJunction::CollectionId(collection),
],
};
let result = host_account_create_proof(
(product_id, suffix),
location,
message,
)?;
// result.proof / contextual_alias / ring_index / ring_revision
```

### Accounts Protocol companion

The methods above are the **TrUAPI** boundary (product ↔ Host). The same operations also cross the **Accounts Protocol** boundary (Host ↔ Account Holder, which custodies the member keys and does the selection and proof generation). The companion methods reuse the same types, with `calling_product_id: ProductId` prepended — at the TrUAPI boundary the Host already knows the calling product, but here it is the caller acting on a product's behalf, so the Account Holder needs it named to scope context derivation and permissioning:

```rust
fn create_account_proof(
calling_product_id: ProductId,
context: ProductProofContext,
ring: RingLocation,
message: Vec<u8>,
) -> Result<RingVrfProof, CreateProofErr>;

fn get_account_alias(
calling_product_id: ProductId,
context: ProductProofContext,
ring: RingLocation,
) -> Result<ContextualAlias, RequestCredentialsErr>;
```

The two boundaries are kept as distinct method sets so they can evolve independently, even though they currently share request/response shapes.

## Out of Scope: Product-SDK Helpers (Non-Normative)

These live at the product-sdk level, not in truAPI; the host implements none of them. Documented only because they shape how products build a `ProductProofContext`.

**Default context.** For contexts that need no suffix, the sdk can use a canonical default:

```rust
const SINGLETON_PROOF_SUFFIX: [u8; 1] = [0];
fn singleton_proof_context(product_id: ProductId) -> ProductProofContext {
(product_id, SINGLETON_PROOF_SUFFIX.to_vec())
}
```

**Context ↔ accountId linkability.** To set an account as the alias for a context, the sdk needs a canonical suffix → `DerivationIndex` mapping (`host_account_get_account` takes `ProductAccountId = (ProductId, DerivationIndex)`):

```rust
fn product_account_id_for_proof_context(product_id: ProductId, suffix: [u8; 4]) -> ProductAccountId {
ProductAccountId { product_id, derivation_index: u32_from_be_bytes(suffix) }
}
fn u32_from_be_bytes(bytes: [u8; 4]) -> u32; // big-endian
```

Defined only for 4-byte suffixes to keep a bijection with `u32`. Hashing arbitrary bytes down to 4 was rejected — the space is too small (high collision risk). This is a helper-level limit only: truAPI still accepts arbitrary-byte suffixes, so products not needing a 1:1 context→account mapping are unaffected.

## Drawbacks

- **Host complexity** — the host must resolve the root from the junction path and implement member-key selection (PoP fallback + stable tiebreak).
- **No type-level junction validation** — `chain_id` is mandatory, but the `junctions` vector has no enforced ordering; malformed paths are handled at runtime.

## Alternatives

- Keep `ring_root_hash` with product-side retry — doesn't solve revision visibility; adds complexity to every product.
- Keep `domain: ProductAccountId` plus a separate context — keeps proof generation tied to a derived product account instead of the host's member key for the ring.
- Single-`u32` suffix — too narrow; real contexts (pgas claims) need more.
- XCM `MultiLocation` directly — overly general; only the junction pattern is borrowed.

## References

- [Host API Design Document v0.5](https://docs.google.com/document/d/1AxKjF15y7gmdl-a6twc5wd8R5xcxKxMO8Ahp2l20v0g/edit?usp=sharing)
- Technical Design: Sybil-Resistant Voting with Personhood — driving product for the member-key-based proof model, the `contextual_alias` response, and `NotMember`.
- [Polkadot People Registry / Ring VRF](https://forum.polkadot.network/t/the-people-registry/12749)
- [individuality#878](https://github.com/paritytech/individuality/pull/878) — alias-account assignment for derived product addresses
- [individuality#891](https://github.com/paritytech/individuality/pull/891) — `personhoodInfoByProof` precompile (motivates the richer response)
- [triangle-js-sdks#81 comment](https://github.com/paritytech/triangle-js-sdks/pull/81) — feedback on moving `ring_index` to output and abstraction concerns
28 changes: 14 additions & 14 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ created: 2026-03-13

# RFCs

| Number | Title | Status | Author | PR |
| ------ | ------------------------------------------------------------------------ | ------------------ | ------------- | --------------------------------------------------------------- |
| 0001 | [RFC Title](0001-template.md) | accepted | @ownerhandle | — |
| 0002 | [Permission Model for Host API](0002-permission-model.md) | accepted | @johnthecat | [#66](https://github.com/paritytech/triangle-js-sdks/pull/66) |
| 0006 | [Payment Host API](0006-payments.md) | accepted | Valentin Sergeev | [#94](https://github.com/paritytech/triangle-js-sdks/pull/94) |
| 0007 | [Deterministic Entropy Derivation for Products](0007-derive-entropy.md) | accepted | Valentin Sergeev | [#95](https://github.com/paritytech/triangle-js-sdks/pull/95) |
| 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | draft | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) |
| 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | Filippo Vecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) |
| 0010 | [W3S Allowance Management in TrUAPI](0010-allowance.md) | accepted | Valentin Sergeev | — |
| 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | accepted | Valentin Sergeev | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) |
| 0017 | [Coinage Payment User Agent API](0017-coinage-payment.md) | accepted | @replghost | — |
| 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | accepted | @johnthecat | — |
| 0020 | [Remove `context` from `create_transaction` and mirror in Accounts Protocol](0020-create-transaction.md) | accepted | Valentin Sergeev | — |
| 0021 | [Add Coins variant to PaymentTopUpSource](0021-payment-topup-coins.md) | accepted | @filippovecchiato | — |
| Number | Title | Status | Author | PR |
| ------ | -------------------------------------------------------------------------------------------------------- | -------- | ----------------- | --------------------------------------------------------------- |
| 0001 | [RFC Title](0001-template.md) | accepted | @ownerhandle | — |
| 0002 | [Permission Model for Host API](0002-permission-model.md) | accepted | @johnthecat | [#66](https://github.com/paritytech/triangle-js-sdks/pull/66) |
| 0006 | [Payment Host API](0006-payments.md) | accepted | Valentin Sergeev | [#94](https://github.com/paritytech/triangle-js-sdks/pull/94) |
| 0007 | [Deterministic Entropy Derivation for Products](0007-derive-entropy.md) | accepted | Valentin Sergeev | [#95](https://github.com/paritytech/triangle-js-sdks/pull/95) |
| 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | draft | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) |
| 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | Filippo Vecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) |
| 0010 | [W3S Allowance Management in TrUAPI](0010-allowance.md) | accepted | Valentin Sergeev | — |
| 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | accepted | Valentin Sergeev | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) |
| 0017 | [Coinage Payment User Agent API](0017-coinage-payment.md) | accepted | @replghost | — |
| 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | accepted | @johnthecat | — |
| 0020 | [Remove `context` from `create_transaction` and mirror in Accounts Protocol](0020-create-transaction.md) | accepted | Valentin Sergeev | — |
| 0021 | [Add Coins variant to PaymentTopUpSource](0021-payment-topup-coins.md) | accepted | @filippovecchiato | — |
25 changes: 12 additions & 13 deletions rust/crates/truapi/src/api/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,16 @@ pub trait Account: Send + Sync {
Err(CallError::unavailable())
}

/// Retrieve a contextual alias for a product account.
/// Retrieve the contextual alias for a context and ring.
///
/// ```ts
/// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi";
///
/// const result = await truapi.account.getAccountAlias({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// context: ["truapi-playground.dot", "0x00"],
/// ringLocation: {
/// chainId: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// junctions: [{ tag: "PalletInstance", value: 42 }],
/// },
/// });
/// assert(result.isOk(), "getAccountAlias failed:", result);
Expand All @@ -74,22 +77,18 @@ pub trait Account: Send + Sync {
Err(CallError::unavailable())
}

/// Generate a ring VRF proof for a product account.
/// Generate a ring VRF proof; the host selects the member key for the ring.
///
/// ```ts
/// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi";
///
/// const result = await truapi.account.createAccountProof({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// context: ["truapi-playground.dot", "0x00"],
/// ringLocation: {
/// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// ringRootHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2",
/// hints: { palletInstance: 42 },
/// chainId: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// junctions: [{ tag: "PalletInstance", value: 42 }],
/// },
/// context: "0x",
/// message: "0x",
/// });
/// assert(result.isOk(), "createAccountProof failed:", result);
/// console.log("account proof created:", result.value);
Expand Down
Loading