From fb91bebc21ab71f210c01db64b07e0818286de2a Mon Sep 17 00:00:00 2001 From: valentunn Date: Tue, 21 Jul 2026 17:25:01 +0700 Subject: [PATCH 1/4] RFC 0023 --- docs/rfcs/0023-account-sign-vrf.md | 311 +++++++++++++++++++++++++++++ docs/rfcs/_index.md | 2 + 2 files changed, 313 insertions(+) create mode 100644 docs/rfcs/0023-account-sign-vrf.md diff --git a/docs/rfcs/0023-account-sign-vrf.md b/docs/rfcs/0023-account-sign-vrf.md new file mode 100644 index 00000000..101dea83 --- /dev/null +++ b/docs/rfcs/0023-account-sign-vrf.md @@ -0,0 +1,311 @@ +--- +title: "sr25519 VRF signing for product accounts" +owner: "@valentunn" +--- + +# RFC 0023 — sr25519 VRF signing for product accounts + +| | | +| --------------- | -------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 23 | +| **Start Date** | 2026-07-21 | +| **Description** | Add a general-purpose `account_sign_vrf` method producing an sr25519 VRF signature from a product account. | +| **Authors** | Valentin Sergeev | + +## Summary + +Add one method to the `Account` trait, `account_sign_vrf`, producing an +**sr25519 (schnorrkel) VRF signature** from a product account. The caller +supplies the signing transcript as a structured recipe — a root label plus an +ordered list of `(label, value)` items — which the host replays into a Merlin +transcript and signs; the response is the schnorrkel `VRFPreOut` and `VRFProof`. + +Authorization matches ordinary product-account signing (`sign_raw`): a per-call +confirmation, unless `AutoSigning` (RFC-0010) covers the account, in which case +the host signs locally. The motivating consumer is the People Chain lottery +ticket a participant submits while **not yet a people-set member**; members use +the bandersnatch ring-VRF path instead (`create_account_proof`, RFC-0004). + +## Definitions + +- **sr25519 VRF** — schnorrkel's VRF over Ristretto25519. Signing consumes a + Merlin transcript; the output splits into a `VRFPreOut` (32-byte output point) + and a `VRFProof` (64-byte DLEQ proof), both needed on-chain. +- **Merlin transcript** — the STROBE-128 transcript schnorrkel signs over, built + from a root domain-separation label and a sequence of labeled message appends. +- **Product account** — an sr25519 account at `//product//{productId}/{suffix}` + (RFC-0022). +- **`AutoSigning`** — the RFC-0010 capability handing the host a product's + subtree secret key so it can sign locally without a round trip. +- **Host** / **Account Holder** — the runtime executing products, and the device + holding the user's root secret (RFC-0010). +- `Bytes` — a variable-length byte array (SCALE `Vec` on the wire). +- `ProductAccountId` — `{ dot_ns_identifier: String, derivation_suffix: Bytes }` + (RFC-0022). + +## Motivation + +The People Chain airdrop flow requires a participant to submit an sr25519 VRF +signature as a lottery ticket, over a transcript the runtime builds like this +(`indiv_pallet_airdrop::vrf::transcript_for_event`): + +```rust +fn transcript_for_event(event_id: &[u8], public_key: &[u8]) -> Transcript { + let mut domain = Vec::with_capacity(VRF_TRANSCRIPT_LABEL.len() + event_id.len()); + domain.extend_from_slice(VRF_TRANSCRIPT_LABEL); + domain.extend_from_slice(event_id); + + let mut transcript = Transcript::new(b"pop:airdrop"); + transcript.append_message(b"domain", &domain); + transcript.append_message(b"signer", public_key); + transcript +} +``` + +Products hold no private keys, so the signature must come from the host, bound +to the product's account. No existing method fits: `create_account_proof` +(RFC-0004) is a ring-VRF for people-set members; `sign_raw` / `sign_payload` +produce ordinary signatures, not VRF output. + +This transcript is one consumer; asset-hub smart contracts can define others. +So the API is **general-purpose** — it replays an arbitrary Merlin transcript +rather than hard-coding one pallet's shape, so new consumers need no host +change. + +**Members vs. non-members.** This sr25519 path is for participants **not yet** +people-set members (still verifying); members use the anonymous bandersnatch +ring-VRF (`create_account_proof`, RFC-0004). The two are complementary. + +## Detailed Design + +### Method + +Added to the `Account` trait: + +```rust +/// Produce an sr25519 (schnorrkel) VRF signature from a product account. +/// +/// The host builds a Merlin transcript from `transcript_label` and `items` and +/// signs it with `account`'s key, returning the VRF pre-output and proof. +/// Authorized like `sign_raw`: local when `AutoSigning` covers the account, +/// otherwise a per-call user confirmation. +#[wire(request_id = 164)] +async fn account_sign_vrf( + &self, + _cx: &CallContext, + _request: HostAccountSignVrfRequest, +) -> Result> { + Err(CallError::unavailable()) +} +``` + +### Request, response, and error + +```rust +struct HostAccountSignVrfRequest { + /// Account whose key signs the VRF. + account: ProductAccountId, + /// Root domain-separation label: `Transcript::new(transcript_label)`. + transcript_label: Bytes, + /// Replayed in order as `transcript.append_message(item.label, item.value)`. + items: Vec, +} + +/// One `append_message` call against the transcript. +struct VrfTranscriptItem { + label: Bytes, + value: Bytes, +} + +/// `HostAccountSignVrfResponse = VrfSignature`. +struct VrfSignature { + /// schnorrkel `VRFPreOut` — the VRF output point. + pre_output: [u8; 32], + /// schnorrkel `VRFProof` — the DLEQ proof. + proof: [u8; 64], +} + +enum HostAccountSignVrfError { + /// No authenticated session (RFC-0009). The host must not auto-prompt login. + NotConnected, + /// The user declined the signing confirmation. + Rejected, + Unknown { reason: String }, +} +``` + +### Transcript replay + +The host reconstructs the transcript deterministically and signs it, performing +**no** interpretation of labels or values — a pure replayer, which is what lets +one method serve any consumer's transcript: + +1. `let mut t = Transcript::new(transcript_label);` +2. for each `item` in order: `t.append_message(item.label, item.value);` +3. `let (io, proof, _) = keypair.vrf_sign(t);` +4. return `VrfSignature { pre_output: io.to_preout().to_bytes(), proof: proof.to_bytes() }`. + +Reproducing the People Chain transcript above: + +```rust +HostAccountSignVrfRequest { + account, + transcript_label: b"pop:airdrop".to_vec(), + items: vec![ + VrfTranscriptItem { label: b"domain".to_vec(), value: [VRF_TRANSCRIPT_LABEL, event_id].concat() }, + VrfTranscriptItem { label: b"signer".to_vec(), value: account_public_key.to_vec() }, + ], +} +``` + +The caller supplies its `account_public_key` for the `signer` item (from +`host_account_get_account`); the host does not inject it. The root label is a +distinct field, not the first `items` entry, because `Transcript::new`'s framing +is not equivalent to a later `append_message` (see +[Implementation notes](#implementation-notes)). + +### Authorization + +Same rules as ordinary product-account signing (`sign_raw`): + +1. **No session** → `NotConnected` (RFC-0009); the host does not auto-prompt + login. +2. **`AutoSigning` covers the account** (granted for the account's product, and + the account is in that product's own subtree) → the host signs locally, no + round trip. +3. **Otherwise** → a per-call confirmation (`UserConfirmationReview::SignVrf`, a + new review variant); signs on approval, `Rejected` on decline. + +Cross-product signing is allowed: an account outside the caller's own product +simply always takes path 3 (a prompt), never a silent local-sign, and there is +no account-ownership error. The prompt-free path is therefore confined to a +product's own subtree, so a product can never *silently* obtain a VRF bound to +another identity — only with the user's explicit per-call consent, exactly as +with `sign_raw`. That, plus per-product hard derivation (RFC-0022) and the +consuming-runtime contract below, keeps the free-form transcript safe. + +### Consuming-runtime contract + +A runtime that verifies these VRFs MUST: + +> Verify the proof against the public key of the account it authorizes, and +> derive any in-transcript `signer` field from that same key — never from +> caller-supplied data. + +Then arbitrary transcript content is safe: schnorrkel binds the proof to the +signing key, so a product that writes a foreign `signer` value produces a proof +that verifies only under its own key and a transcript no correct verifier +reconstructs — it simply fails. The People Chain transcript already binds +`signer = public_key`. + +Runtimes should also keep the VRF **input narrow** (per schnorrkel's guidance): +the narrower the bound transcript, the less a participant can manipulate the +draw. That is the runtime's responsibility; this method signs whatever it is +given. + +### Accounts Protocol companion + +For the non-`AutoSigning` path, the Host ↔ Account Holder boundary gains the +mirror request, shaped like RFC-0004's `create_account_proof` companion: + +```rust +/// Host → Account Holder. +fn sign_account_vrf( + calling_product_id: ProductId, + account: ProductAccountId, + transcript_label: Bytes, + items: Vec, +) -> Result; +``` + +The Account Holder derives the account, presents the confirmation, and signs. +`SignVrfErr` carries the `Rejected` / `Unknown` cases. + +## Implementation notes + +Hosts build the transcript with Merlin (schnorrkel's transcript type), whose API +requires every **label** to be `&'static [u8]` (`Transcript::new`, +`append_message`, …); only message *values* accept runtime lifetimes. A +general-purpose host receives its labels (`transcript_label`, each `item.label`) +as **runtime bytes off the wire**, so it cannot pass them to stock Merlin +directly. + +This is an implementation obstacle, **not** a security constraint. The +`&'static` bound is misuse-resistance — it nudges a protocol author to hard-code +their labels — while the binding guarantee comes from Merlin's tag-length-value +framing, which encodes runtime-length labels just as unambiguously. `Box::leak` +and `unsafe` casts to `'static` are sanctioned workarounds, so the bytes' +provenance is irrelevant to correctness. + +**Merlin author consultation.** Jeff Burdges (Merlin / schnorrkel / +ark-transcript author) was consulted: he called the `&'static` labels an +annoyance and suggested forking Merlin to relax them ("we could fork merlin and +take that out"), said abstracting the transcript this way has been done before +and is reasonable, and noted the structured `(label, value)` shape is harder to +abuse than a raw pre-accumulated transcript. The acceptability of an `unsafe` +cast to `'static` comes from his earlier public Merlin threads, not this +consultation. The one hazard raised in discussion — domain collision from +arbitrary labels — does not apply here, since signatures are bound to per-account +derived keys. + +**Recommended host implementation.** + +- A **patched/vendored Merlin** relaxing `&'static [u8]` → `&[u8]` on label + params: same STROBE-128 body, so byte-identical to stock Merlin with no + `unsafe` in host code. (Stock-Merlin equivalent: an + `unsafe transmute::<&[u8], &'static [u8]>` per label call — sound because + Merlin absorbs labels synchronously and never retains the reference.) +- A **conformance test** asserting byte-equality vs stock `merlin::Transcript`, + plus an end-to-end "verifies under `sp_core` sr25519" test (the analogue of + the pallet's `transcript_matches_sp_core`). +- **Bound** `items.len()` and total transcript size against a hostile caller. + +**Dead ends.** `Transcript::new(b"")` + append is *not* framing-equivalent to +`Transcript::new(label)`, so the root label must go into the constructor. And +`ark-transcript` — though it allows dynamic labels — wraps SHAKE128 with postfix +framing (not STROBE-128) and feeds the arkworks/bandersnatch stack; schnorrkel +cannot consume it and its output would never verify under sr25519. + +## Non-goals + +For **infrequent, identity-bound** VRFs (lottery ticket / airdrop claim), where +binding the draw to the product account is the point and a per-call confirmation +or one-time `AutoSigning` grant is acceptable. + +**Not** for fast-moving in-game randomness: that should use a **device-local +game key** — held on the player's device, able to act only in the game and never +over money — instead of round-tripping every draw through host signing or +widening `AutoSigning` to a game loop. + +## Drawbacks + +- **Free-form transcript surface.** Hosts must bound transcript size and cannot + validate transcript *semantics*; correctness of the bound draw rests with the + consuming runtime. +- **The caller must supply the `signer` public key** (via + `host_account_get_account`) rather than the host inserting it. + +## Alternatives + +- **Opaque single blob** (`signing_context(context).bytes(input)`) — only a + single-append transcript; can't reproduce the People Chain's `Transcript::new` + plus two appends, so it fails on-chain. +- **Enumerated per-pallet shapes** — a wire enum per consumer; every new + consumer would need a host release, defeating the general-purpose goal. +- **Host-injected `signer` placeholder** — unnecessary: the product can fetch + its own pubkey, and a spoofed `signer` can't compromise another identity, so + injection buys only ergonomics. +- **`ark-transcript`** — incompatible with schnorrkel/Merlin (see + [Implementation notes](#implementation-notes)). + +## Prior Art and References + +- **RFC-0004** — `create_account_proof`; the ring-VRF path for people-set + members, complementary to this sr25519 path for non-members. +- **RFC-0009** — the `NotConnected` gate and no-auto-login rule. +- **RFC-0010** — allowance and `AutoSigning`. +- **RFC-0022** — account key derivations; `ProductAccountId` and the + `//product//{productId}/{suffix}` scheme this method signs with. +- **Merlin / schnorrkel** — the transcript and VRF primitives; the static-label + discussion and Jeff Burdges' input are in + [Implementation notes](#implementation-notes). diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index b9d617c7..15b95f4e 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -23,3 +23,5 @@ created: 2026-03-13 | 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 | — | +| 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | +| 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | From dc54b2b1b8775242f47ed513023acdf14541cd5d Mon Sep 17 00:00:00 2001 From: valentunn Date: Wed, 22 Jul 2026 14:26:25 +0700 Subject: [PATCH 2/4] Types --- docs/rfcs/0023-account-sign-vrf.md | 6 +-- .../truapi-codegen/tests/golden/dispatcher.rs | 28 +++++++++++++ .../truapi-codegen/tests/golden/wire_table.rs | 10 +++++ .../truapi-server/src/generated/dispatcher.rs | 36 ++++++++++++++++ .../truapi-server/src/generated/wire_table.rs | 10 +++++ rust/crates/truapi/src/api/account.rs | 34 ++++++++++++++- rust/crates/truapi/src/v01/account.rs | 41 +++++++++++++++++++ rust/crates/truapi/src/versioned/account.rs | 3 ++ 8 files changed, 164 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/0023-account-sign-vrf.md b/docs/rfcs/0023-account-sign-vrf.md index 101dea83..3d049434 100644 --- a/docs/rfcs/0023-account-sign-vrf.md +++ b/docs/rfcs/0023-account-sign-vrf.md @@ -9,12 +9,12 @@ owner: "@valentunn" | --------------- | -------------------------------------------------------------------------------------------------------- | | **RFC Number** | 23 | | **Start Date** | 2026-07-21 | -| **Description** | Add a general-purpose `account_sign_vrf` method producing an sr25519 VRF signature from a product account. | +| **Description** | Add a general-purpose `sign_vrf` method producing an sr25519 VRF signature from a product account. | | **Authors** | Valentin Sergeev | ## Summary -Add one method to the `Account` trait, `account_sign_vrf`, producing an +Add one method to the `Account` trait, `sign_vrf`, producing an **sr25519 (schnorrkel) VRF signature** from a product account. The caller supplies the signing transcript as a structured recipe — a root label plus an ordered list of `(label, value)` items — which the host replays into a Merlin @@ -90,7 +90,7 @@ Added to the `Account` trait: /// Authorized like `sign_raw`: local when `AutoSigning` covers the account, /// otherwise a per-call user confirmation. #[wire(request_id = 164)] -async fn account_sign_vrf( +async fn sign_vrf( &self, _cx: &CallContext, _request: HostAccountSignVrfRequest, diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 25f2fb13..b11224ef 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -156,6 +156,34 @@ where }) }); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_SIGN_VRF, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountSignVrfRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountSignVrfResponse = match host.sign_vrf(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } { let host = host.clone(); dispatcher.on_request(wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, move |request_id: String, bytes: Vec| { diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 12a72a94..7360d042 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -460,6 +460,12 @@ pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFr receive_id: 163, }; +/// Wire discriminants for `account_sign_vrf`. +pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { + request_id: 164, + response_id: 165, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -719,4 +725,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), }, + WireEntry { + method: "account_sign_vrf", + kind: WireKind::Request(ACCOUNT_SIGN_VRF), + }, ]; diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 231dd362..1cefce40 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -173,6 +173,42 @@ where }, ); } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_SIGN_VRF, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountSignVrfRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountSignVrfError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountSignVrfResponse = + match host.sign_vrf(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } { let host = host.clone(); dispatcher.on_request( diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 12a72a94..7360d042 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -460,6 +460,12 @@ pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFr receive_id: 163, }; +/// Wire discriminants for `account_sign_vrf`. +pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { + request_id: 164, + response_id: 165, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -719,4 +725,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), }, + WireEntry { + method: "account_sign_vrf", + kind: WireKind::Request(ACCOUNT_SIGN_VRF), + }, ]; diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index aa3f3799..31337365 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -4,7 +4,8 @@ use crate::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, - HostAccountGetRequest, HostAccountGetResponse, HostGetLegacyAccountsError, + HostAccountGetRequest, HostAccountGetResponse, HostAccountSignVrfError, + HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, HostRequestLoginResponse, @@ -123,6 +124,37 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Produce an sr25519 (schnorrkel) VRF signature from a product account. + /// + /// The host builds a Merlin transcript from `transcriptLabel` and `items` + /// and signs it with the account's key, returning the VRF pre-output and + /// proof. Authorized like signing: local when `AutoSigning` covers the + /// account, otherwise a per-call user confirmation. + /// + /// ```ts + /// const result = await truapi.account.signVrf({ + /// account: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: 0, + /// }, + /// transcriptLabel: "0x706f703a61697264726f70", + /// items: [ + /// { label: "0x646f6d61696e", value: "0x706f703a61697264726f70" }, + /// { label: "0x7369676e6572", value: "0x00" }, + /// ], + /// }); + /// assert(result.isOk(), "signVrf failed:", result); + /// console.log("vrf signature:", result.value); + /// ``` + #[wire(request_id = 164)] + async fn sign_vrf( + &self, + _cx: &CallContext, + _request: HostAccountSignVrfRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + /// List non-product accounts the user owns. /// /// ```ts diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 2cfc1198..afe32792 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -215,3 +215,44 @@ pub struct HostGetLegacyAccountsResponse { /// Legacy accounts. pub accounts: Vec, } + +/// One `append_message` call replayed against the signing transcript. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct VrfTranscriptItem { + /// Merlin `append_message` label. + pub label: Vec, + /// Merlin `append_message` value. + pub value: Vec, +} + +/// Request to produce an sr25519 VRF signature from a product account over a +/// caller-supplied Merlin transcript. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountSignVrfRequest { + /// Account whose key signs the VRF. + pub account: ProductAccountId, + /// Root domain-separation label: `Transcript::new(transcript_label)`. + pub transcript_label: Vec, + /// Transcript items replayed in order as `append_message(label, value)`. + pub items: Vec, +} + +/// An sr25519 (schnorrkel) VRF signature: the VRF pre-output and its proof. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct VrfSignature { + /// schnorrkel `VRFPreOut` — the 32-byte VRF output point. + pub pre_output: [u8; 32], + /// schnorrkel `VRFProof` — the 64-byte DLEQ proof. + pub proof: [u8; 64], +} + +/// Error returned when VRF signing fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountSignVrfError { + /// User is not logged in. + NotConnected, + /// User or host rejected the signing confirmation. + Rejected, + /// Catch-all. + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 8ce339a9..d2579f74 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -12,6 +12,9 @@ truapi_macros::versioned_type! { pub enum HostAccountCreateProofRequest { V1 => v01::HostAccountCreateProofRequest } pub enum HostAccountCreateProofResponse { V1 => v01::HostAccountCreateProofResponse } pub enum HostAccountCreateProofError { V1 => v01::HostAccountCreateProofError } + pub enum HostAccountSignVrfRequest { V1 => v01::HostAccountSignVrfRequest } + pub enum HostAccountSignVrfResponse { V1 => v01::VrfSignature } + pub enum HostAccountSignVrfError { V1 => v01::HostAccountSignVrfError } pub enum HostGetLegacyAccountsRequest { V1 } pub enum HostGetLegacyAccountsResponse { V1 => v01::HostGetLegacyAccountsResponse } pub enum HostGetLegacyAccountsError { V1 => v01::HostAccountGetError } From cdf668959bcdbf819042751e6cc3801ef4d9a665 Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 23 Jul 2026 17:21:10 +0700 Subject: [PATCH 3/4] Doc --- rust/crates/truapi/src/v01/account.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index afe32792..8b454d9c 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -254,5 +254,8 @@ pub enum HostAccountSignVrfError { /// User or host rejected the signing confirmation. Rejected, /// Catch-all. - Unknown { reason: String }, + Unknown { + /// Human-readable failure reason. + reason: String, + }, } From 50fad8dd89104a1e8a7ca53fead410ce540fdd3d Mon Sep 17 00:00:00 2001 From: valentunn Date: Fri, 24 Jul 2026 13:41:16 +0700 Subject: [PATCH 4/4] Clarifications --- docs/rfcs/0023-account-sign-vrf.md | 36 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/rfcs/0023-account-sign-vrf.md b/docs/rfcs/0023-account-sign-vrf.md index 3d049434..bcf9623d 100644 --- a/docs/rfcs/0023-account-sign-vrf.md +++ b/docs/rfcs/0023-account-sign-vrf.md @@ -33,15 +33,15 @@ the bandersnatch ring-VRF path instead (`create_account_proof`, RFC-0004). and a `VRFProof` (64-byte DLEQ proof), both needed on-chain. - **Merlin transcript** — the STROBE-128 transcript schnorrkel signs over, built from a root domain-separation label and a sequence of labeled message appends. -- **Product account** — an sr25519 account at `//product//{productId}/{suffix}` - (RFC-0022). +- **Product account** — an sr25519 account the host derives per-product from the + user's root secret (RFC-0022). - **`AutoSigning`** — the RFC-0010 capability handing the host a product's subtree secret key so it can sign locally without a round trip. - **Host** / **Account Holder** — the runtime executing products, and the device holding the user's root secret (RFC-0010). - `Bytes` — a variable-length byte array (SCALE `Vec` on the wire). -- `ProductAccountId` — `{ dot_ns_identifier: String, derivation_suffix: Bytes }` - (RFC-0022). +- `ProductAccountId` — the existing identifier for a product account; this RFC + passes it through unchanged and does not depend on its shape. ## Motivation @@ -181,7 +181,7 @@ simply always takes path 3 (a prompt), never a silent local-sign, and there is no account-ownership error. The prompt-free path is therefore confined to a product's own subtree, so a product can never *silently* obtain a VRF bound to another identity — only with the user's explicit per-call consent, exactly as -with `sign_raw`. That, plus per-product hard derivation (RFC-0022) and the +with `sign_raw`. That, plus per-product key separation (RFC-0022) and the consuming-runtime contract below, keeps the free-form transcript safe. ### Consuming-runtime contract @@ -215,11 +215,29 @@ fn sign_account_vrf( account: ProductAccountId, transcript_label: Bytes, items: Vec, -) -> Result; +) -> Result; + +/// SCALE-encoded across the Host ↔ Account Holder boundary; this variant order +/// is the cross-host wire contract and MUST NOT be reordered. New variants +/// append only. +enum SignVrfError { + /// User or Account Holder declined the signing confirmation. (index 0) + Rejected, + /// Catch-all failure, carrying a diagnostic reason. (index 1) + Unknown { reason: String }, +} ``` The Account Holder derives the account, presents the confirmation, and signs. -`SignVrfErr` carries the `Rejected` / `Unknown` cases. + +`SignVrfError` deliberately omits `NotConnected`: this leg only exists once the +Host and Account Holder are paired, so the RFC-0009 session gate is enforced +before the request is ever sent. Its indices therefore do **not** line up with +`HostAccountSignVrfError` (whose index 0 is `NotConnected`); the host maps +`Rejected → Rejected` and `Unknown → Unknown` when returning to the product, and +raises `NotConnected` itself without consulting the Account Holder. This mirrors +`RingVrfError` — the existing RFC-0004 companion enum, which likewise pins its +order as the wire contract and carries only the failures reachable on that leg. ## Implementation notes @@ -304,8 +322,8 @@ widening `AutoSigning` to a game loop. members, complementary to this sr25519 path for non-members. - **RFC-0009** — the `NotConnected` gate and no-auto-login rule. - **RFC-0010** — allowance and `AutoSigning`. -- **RFC-0022** — account key derivations; `ProductAccountId` and the - `//product//{productId}/{suffix}` scheme this method signs with. +- **RFC-0022** — account key derivations; `ProductAccountId` and the per-product + derivation scheme this method signs with. - **Merlin / schnorrkel** — the transcript and VRF primitives; the static-label discussion and Jeff Burdges' input are in [Implementation notes](#implementation-notes).