diff --git a/docs/rfcs/0004-ringlocation-redesign.md b/docs/rfcs/0004-ringlocation-redesign.md new file mode 100644 index 00000000..36b714fb --- /dev/null +++ b/docs/rfcs/0004-ringlocation-redesign.md @@ -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` 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` 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 } +struct RingLocation { genesis_hash: GenesisHash, ring_root_hash: Vec, hints: Option } +type RingVrfProof = Vec; + +fn host_account_create_proof(domain: ProductAccountId, ring: RingLocation, message: Vec) + -> Result; +fn host_account_get_alias(domain: ProductAccountId) + -> Result; +``` + +## 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), +} + +struct RingLocation { + chain_id: GenesisHash, + junctions: Vec, +} +``` + +### 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; // 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//` 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, + contextual_alias: ContextualAlias, + ring_index: u32, + ring_revision: u32, +} + +fn host_account_create_proof(context: ProductProofContext, ring: RingLocation, message: Vec) + -> Result; + +fn host_account_get_alias(context: ProductProofContext, ring: RingLocation) + -> Result; +``` + +`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, +) -> Result; + +fn get_account_alias( + calling_product_id: ProductId, + context: ProductProofContext, + ring: RingLocation, +) -> Result; +``` + +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 diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 3bac6f9a..f7df88c3 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -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 | — | diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 7c4e065f..8b45fd9a 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -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); @@ -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); diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index f3b928ec..b283c309 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -1,3 +1,4 @@ +use crate::v01::transaction::GenesisHash; use parity_scale_codec::{Decode, Encode}; /// Identifies a product-specific account by combining a dotNS domain name with a @@ -32,40 +33,55 @@ pub struct ProductAccount { /// A privacy-preserving alias derived via ring VRF, bound to a specific context. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostAccountGetAliasResponse { - /// 32-byte context identifier. +pub struct ContextualAlias { + /// 32-byte context identifier the alias is bound to. pub context: [u8; 32], /// Ring VRF alias (variable length). pub alias: Vec, } -/// Hints for locating a ring on-chain. +/// A single step in a [`RingLocation`] path, addressing a ring within a chain. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingLocationHint { - /// Optional pallet instance index. - pub pallet_instance: Option, +pub enum RingLocationJunction { + /// Pallet instance hosting the ring collection. + PalletInstance(u8), + /// Ring collection identifier within the pallet. + CollectionId(Vec), } -/// Locates a specific ring on a specific chain for ring VRF operations. +/// Locates a ring for ring VRF operations using only identifiers that are +/// stable across membership changes. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingLocation { - /// Chain genesis hash. - pub genesis_hash: Vec, - /// Root hash of the ring. - pub ring_root_hash: Vec, - /// Optional location hints. - pub hints: Option, + /// Genesis hash of the chain hosting the ring. + pub chain_id: GenesisHash, + /// Path addressing the ring within the chain. + pub junctions: Vec, } -/// Request to create a ring VRF proof for a product account. +/// dotNS product identifier (e.g. `"my-product.dot"`). +pub type ProductId = String; + +/// Arbitrary-byte suffix distinguishing contexts within a single product. +pub type ProductProofContextSuffix = Vec; + +/// A product-scoped proof context: a product and a context within it. +/// +/// Hashed (with a `product//` prefix) into the 32-byte context bound +/// to a ring VRF proof, so contexts cannot collide across products and the same +/// member key under different contexts yields unlinkable aliases. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductProofContext(pub ProductId, pub ProductProofContextSuffix); + +/// Request to create a ring VRF proof. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountCreateProofRequest { - /// Product account that should create the proof. - pub product_account_id: ProductAccountId, - /// Ring location to use for proof generation. + /// Product-scoped context the derived alias is bound to. + pub context: ProductProofContext, + /// Ring to generate the proof against; the host selects the member key. pub ring_location: RingLocation, - /// Context bytes bound to the proof. - pub context: Vec, + /// Opaque message bound into the proof. + pub message: Vec, } /// User's authentication state. @@ -118,6 +134,8 @@ pub enum HostAccountGetError { pub enum HostAccountCreateProofError { /// Ring not available at the specified location. RingNotFound, + /// The selected member key is not a member of the requested ring. + NotMember, /// User or host rejected. Rejected, /// Catch-all. @@ -156,18 +174,27 @@ pub enum HostGetUserIdError { Unknown { reason: String }, } -/// Request to retrieve a contextual alias for a product account. +/// Request to retrieve the contextual alias for a context and ring. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountGetAliasRequest { - /// Product account to derive the alias for. - pub product_account_id: ProductAccountId, + /// Product-scoped context to derive the alias for. + pub context: ProductProofContext, + /// Ring whose member key the host should use; matches `create_proof`. + pub ring_location: RingLocation, } -/// Response containing a ring VRF proof. +/// Response containing a ring VRF proof and the values needed to verify it +/// against a downstream precompile. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountCreateProofResponse { /// Variable-length ring VRF proof bytes. pub proof: Vec, + /// Alias derived for the request's context. + pub contextual_alias: ContextualAlias, + /// Index of the selected member key within the ring. + pub ring_index: u32, + /// Ring revision the proof was generated against. + pub ring_revision: u32, } /// Response containing all legacy (user-imported) accounts owned by the user. diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 1d82fc62..750af434 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -7,7 +7,7 @@ truapi_macros::versioned_type! { pub enum HostAccountGetResponse { V1 => v01::HostAccountGetResponse } pub enum HostAccountGetError { V1 => v01::HostAccountGetError } pub enum HostAccountGetAliasRequest { V1 => v01::HostAccountGetAliasRequest } - pub enum HostAccountGetAliasResponse { V1 => v01::HostAccountGetAliasResponse } + pub enum HostAccountGetAliasResponse { V1 => v01::ContextualAlias } pub enum HostAccountGetAliasError { V1 => v01::HostAccountGetError } pub enum HostAccountCreateProofRequest { V1 => v01::HostAccountCreateProofRequest } pub enum HostAccountCreateProofResponse { V1 => v01::HostAccountCreateProofResponse }