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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const transport = createTransport(createMessagePortProvider(port));
const truapi = createClient(transport);

const result = await truapi.accountManagement.accountGet({
productAccountId: { dotNsIdentifier: "my-product.dot", derivationIndex: 0 },
productAccountId: { derivationIndex: 0 },
});
```

Expand Down
204 changes: 204 additions & 0 deletions docs/rfcs/0022-remove-dotns-from-default-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# RFC-0022: Optional `dotNsIdentifier` and permissioned external-account access

| | |
| --------------- | --------------------------------------------------------------------- |
| **RFC Number** | 22 |
| **Start Date** | 2026-06-30 |
| **Description** | Account, signing, and statement-store calls address the caller's own accounts by default, and a foreign dotNS identifier opts into permissioned cross-product access. |
| **Authors** | Valentin Fernandez |

## Summary

The account, signing, and statement-store methods that operate on a product account take a
`ProductAccountId { dotNsIdentifier, derivationIndex }`. `dotNsIdentifier` is now **optional**. Omit it
and the call resolves against the caller's own dotNS domain, so the common case only needs a
`derivationIndex`.

```typescript
// Own account, the common case
await truapi.account.getAccount({ productAccountId: { derivationIndex: 0 } });

// Another product's account, requires the ExternalAccount permission
await truapi.account.getAccount({
productAccountId: { dotNsIdentifier: "another-product.dot", derivationIndex: 0 },
});
```

Supplying a `dotNsIdentifier` that names a different product is cross-product access, gated behind a
new `ExternalAccount` remote permission. When the field is omitted the host resolves the account's
domain from the authenticated caller. When it names a foreign domain the host accepts or rejects the
call based on whether the user granted `ExternalAccount`.

This RFC covers seven call sites:

- [`getAccount()`](https://paritytech.github.io/truapi/v/main/method/Account/get_account)
- [`getAccountAlias()`](https://paritytech.github.io/truapi/v/main/method/Account/get_account_alias)
- [`createAccountProof()`](https://paritytech.github.io/truapi/v/main/method/Account/create_account_proof)
- [`createTransaction()`](https://paritytech.github.io/truapi/v/main/method/Signing/create_transaction)
- [`signRaw()`](https://paritytech.github.io/truapi/v/main/method/Signing/sign_raw)
- [`signPayload()`](https://paritytech.github.io/truapi/v/main/method/Signing/sign_payload)
- [`statementStore.createProof()`](https://paritytech.github.io/truapi/v/main/method/StatementStore/create_proof)

## Motivation

For the common case, a product addressing its own accounts, `dotNsIdentifier` is redundant. The host
already knows the calling product's dotNS domain and can resolve it from the authenticated caller.
Making the field optional lets that case carry only a `derivationIndex`.

The field still serves a real purpose, though. Some products have a legitimate reason to read, and
possibly sign with, *another* product's account. That capability should be explicit and consented, not
an unguarded consequence of an always-present parameter. So instead of dropping `dotNsIdentifier`
entirely, which would remove the capability, or leaving it mandatory, which offers no isolation, it
becomes an optional opt-in: absent for own-product access, present and permissioned for cross-product
access.

Split out from [#222](https://github.com/paritytech/truapi/issues/222), tracked in
[#243](https://github.com/paritytech/truapi/issues/243).

## Stakeholders

- **Product developers** omit the domain for their own accounts. To reach another product's account
they supply a `dotNsIdentifier` and hold the `ExternalAccount` grant.
- **Host developers** resolve the domain from the authenticated caller when it is omitted, and enforce
the `ExternalAccount` permission when a foreign domain is supplied.

## Explanation

`ProductAccountId` carries an optional domain:

```rust
struct ProductAccountId {
dot_ns_identifier: Option<String>,
derivation_index: u32,
}
```

Resolution rules:

- `dot_ns_identifier == None` resolves to the caller's own product.
- `dot_ns_identifier == Some(domain)` where `domain` is the caller's own also resolves to the caller's
own product.
- `dot_ns_identifier == Some(domain)` where `domain` is a different product is cross-product access. It
is permitted only when the user has granted the caller the `ExternalAccount` permission, otherwise
the host rejects the call.

Each of the seven request types carries a `ProductAccountId`:

```rust
struct HostAccountGetRequest { product_account_id: ProductAccountId }
struct HostAccountGetAliasRequest { product_account_id: ProductAccountId }
struct HostAccountCreateProofRequest {
product_account_id: ProductAccountId,
ring_location: RingLocation,
context: Vec<u8>,
}
struct ProductAccountTxPayload {
signer: ProductAccountId,
genesis_hash: GenesisHash,
call_data: Vec<u8>,
extensions: Vec<TxPayloadExtension>,
tx_ext_version: u8,
}
struct HostSignRawRequest { account: ProductAccountId, payload: RawPayload }
struct HostSignPayloadRequest { account: ProductAccountId, payload: HostSignPayloadData }
struct RemoteStatementStoreCreateProofRequest {
product_account_id: ProductAccountId,
statement: Statement,
}
```

In TypeScript `dotNsIdentifier` is optional on the nested identifier, so own-account calls pass only
the index:

```typescript
await truapi.account.getAccount({ productAccountId: { derivationIndex: 0 } });
await truapi.account.getAccountAlias({ productAccountId: { derivationIndex: 0 } });
await truapi.account.createAccountProof({ productAccountId: { derivationIndex: 0 }, ringLocation, context: "0x" });
await truapi.signing.createTransaction({ signer: { derivationIndex: 0 }, genesisHash, callData, extensions: [], txExtVersion: 0 });
await truapi.signing.signRaw({ account: { derivationIndex: 0 }, payload });
await truapi.signing.signPayload({ account: { derivationIndex: 0 }, payload });
await truapi.statementStore.createProof({ productAccountId: { derivationIndex: 0 }, statement });
```

### `ExternalAccount` permission

A new `ExternalAccount` variant on `RemotePermission` governs cross-product access. Like `ChainSubmit`
and `StatementSubmit`, it is triggered implicitly by the business call. The first time a product issues
one of these calls with a foreign `dotNsIdentifier`, the host prompts for the grant, and the user's
decision persists. A product that only ever addresses its own accounts never sees the prompt.

The change is made in place on `v01`, with no `v02` of these messages. Their wire IDs are unchanged.
Only the request body shape changes, and `dotNsIdentifier` moves from a required `String` to an
optional one. The `*WithLegacyAccount` signing variants are unaffected, since they identify a legacy
account by raw `AccountId`, never by `dotNsIdentifier`.

`statementStore.createProofAuthorized()` already takes only the statement (it uses a pre-allocated
allowance account) and so needs no change. The deprecated `statementStore.createProof()` is the
statement-store call still carrying `ProductAccountId`, and is covered here for consistency.

## Drawbacks

Cross-product access adds a permission surface the host must enforce and the user must reason about.
The capability is opt-in on both sides, though: a product reaches another product's account only by
supplying a foreign domain, and only after the user grants `ExternalAccount`. Products that never opt
in are unaffected, and the default call surface stays minimal.

## Testing, Security, and Privacy

Cross-product access is only possible through an explicit foreign `dotNsIdentifier` combined with a
user-granted `ExternalAccount` permission. Own-account calls omit the domain entirely, so the host
resolves it from the authenticated caller and there is no caller-supplied domain to validate in the
common case. The host stays the sole authority on which domain a call resolves against, and the
permission prompt keeps the user in the loop before any product reads or signs with another product's
account.

## Performance, Ergonomics, and Compatibility

### Ergonomics

The common case, a product's own account, needs only an index, and there is no domain string to supply
or get wrong. Cross-product access is expressed by the presence of a domain plus a permission, so the
capability is discoverable instead of hidden behind an always-present field.

### Compatibility

This is a breaking wire change to seven `v01` request bodies, and the protocol gives it **no version
signal**. Each message is wrapped in a `versioned_type!` envelope whose SCALE discriminant byte is the
version (`V1` is codec index `0`), but every one of the protocol's envelopes is currently
single-variant `V1`. There is no live multi-version support and no cross-version conversion anywhere in
the tree. The handshake negotiates only the SCALE *codec* version, not the API version. So editing the
body of a `V1` message keeps the version byte at `0` while changing the bytes that follow it. Because
SCALE is positional and not self-describing, an un-upgraded host reads "V1" and silently misdecodes the
new layout. There is no clean failure.

The change is therefore safe only while the host and product SDK ship together, that is, while the
v0.1 wire is not frozen against independently-deployed hosts. That is the repository's current posture.
`v01` is the single, still-evolving wire, and breaking changes land in it in place (see
[RFC-0020](0020-create-transaction.md)). The `versioned_type!` machinery, though fully scaffolded, has
never been exercised with a second variant. This RFC follows that posture. Some of these methods do
have a live consumer (the playground calls `signRaw` and `createProof`), and those call sites are
updated in the same change. The PR stays a draft until the protocol-wide versioning strategy is
settled.

Once the v0.1 wire is frozen and hosts deploy independently of the SDK, a breaking change like this one
must instead add a `V2` variant (`{ V1 => v01::X, V2 => v02::X }`) with hand-written
`FromLatest`/`IntoLatest`, so the version byte distinguishes the shapes (an old host rejects `V2`
cleanly instead of corrupting) and a dual-version host can bridge both. Adopting that for these seven
messages alone, while the rest of the protocol stays single-variant `V1`, would buy no real safety. It
is a protocol-wide discipline to take on at the freeze, tracked separately from this RFC.

## Prior Art and References

- [RFC-0002](0002-permission-model.md), the permission model that `ExternalAccount` extends.
- [RFC-0020](0020-create-transaction.md), which removed a field from a signing request body in place on
`v01` and establishes the in-place-change precedent followed here.
- [#222](https://github.com/paritytech/truapi/issues/222), the parent issue this is split out from.
- [#243](https://github.com/paritytech/truapi/issues/243), the tracking issue for this RFC.

## Unresolved Questions

- **In-place vs versioned migration.** This RFC changes the seven `v01` bodies in place, which is sound
only while hosts and the SDK ship together (see [Compatibility](#compatibility)). If v0.1 is frozen
against independently-deployed hosts before this lands, it must move to a `V2` envelope instead, and
that decision is really protocol-wide (today all envelopes are single-variant `V1`), not specific to
these seven messages.
1 change: 1 addition & 0 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ 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 | [Optional `dotNsIdentifier` and permissioned external-account access](0022-remove-dotns-from-default-access.md) | draft | Valentin Fernandez | [#245](https://github.com/paritytech/truapi/pull/245) |
26 changes: 10 additions & 16 deletions rust/crates/truapi/src/api/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ pub trait Account: Send + Sync {
/// Retrieve a product-scoped account.
///
/// ```ts
/// const result = await truapi.account.getAccount({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// });
/// const result = await truapi.account.getAccount({ productAccountId: { derivationIndex: 0 } });
/// assert(result.isOk(), "getAccount failed:", result);
/// console.log("account retrieved:", result.value);
///
/// // To read another product's account, pass its dotNS domain. This is cross-product
/// // access and requires the `ExternalAccount` permission — host-side enforcement is
/// // not yet implemented.
/// // await truapi.account.getAccount({
/// // productAccountId: { dotNsIdentifier: "another-product.dot", derivationIndex: 0 },
/// // });
/// ```
#[wire(request_id = 22)]
async fn get_account(
Expand All @@ -56,12 +58,7 @@ pub trait Account: Send + Sync {
/// Retrieve a contextual alias for a product account.
///
/// ```ts
/// const result = await truapi.account.getAccountAlias({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// });
/// const result = await truapi.account.getAccountAlias({ productAccountId: { derivationIndex: 0 } });
/// assert(result.isOk(), "getAccountAlias failed:", result);
/// console.log("account alias:", result.value);
/// ```
Expand All @@ -80,10 +77,7 @@ pub trait Account: Send + Sync {
/// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi";
///
/// const result = await truapi.account.createAccountProof({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// productAccountId: { derivationIndex: 0 },
/// ringLocation: {
/// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// ringRootHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2",
Expand Down
28 changes: 22 additions & 6 deletions rust/crates/truapi/src/api/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,25 @@ pub trait Signing: Send + Sync {
/// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi";
///
/// const result = await truapi.signing.createTransaction({
/// signer: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// signer: { derivationIndex: 0 },
/// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// callData: "0x0000",
/// extensions: [],
/// txExtVersion: 0,
/// });
/// assert(result.isOk(), "createTransaction failed:", result);
/// console.log("transaction created:", result.value);
///
/// // To sign with another product's account, set `signer.dotNsIdentifier`. This is
/// // cross-product access and requires the `ExternalAccount` permission — host-side
/// // enforcement is not yet implemented.
/// // await truapi.signing.createTransaction({
/// // signer: { dotNsIdentifier: "another-product.dot", derivationIndex: 0 },
/// // genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis,
/// // callData: "0x0000",
/// // extensions: [],
/// // txExtVersion: 0,
/// // });
/// ```
#[wire(request_id = 30)]
async fn create_transaction(
Expand Down Expand Up @@ -134,7 +142,7 @@ pub trait Signing: Send + Sync {
///
/// ```ts
/// const result = await truapi.signing.signRaw({
/// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: 0 },
/// account: { derivationIndex: 0 },
/// payload: {
/// tag: "Bytes",
/// value: {
Expand All @@ -144,6 +152,14 @@ pub trait Signing: Send + Sync {
/// });
/// assert(result.isOk(), "signRaw failed:", result);
/// console.log("raw bytes signed:", result.value);
///
/// // To sign with another product's account, set `account.dotNsIdentifier`. This is
/// // cross-product access and requires the `ExternalAccount` permission — host-side
/// // enforcement is not yet implemented.
/// // await truapi.signing.signRaw({
/// // account: { dotNsIdentifier: "another-product.dot", derivationIndex: 0 },
/// // payload: { tag: "Bytes", value: { bytes: "0x48656c6c6f2c20776f726c6421" } },
/// // });
/// ```
#[wire(request_id = 114)]
async fn sign_raw(
Expand All @@ -160,7 +176,7 @@ pub trait Signing: Send + Sync {
/// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi";
///
/// const result = await truapi.signing.signPayload({
/// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: 0 },
/// account: { derivationIndex: 0 },
/// payload: {
/// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2",
/// blockNumber: "0x00000000",
Expand Down
15 changes: 3 additions & 12 deletions rust/crates/truapi/src/api/statement_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ pub trait StatementStore: Send + Sync {
/// const statement: Statement = { expiry, topics: [topic] };
///
/// const proofResult = await truapi.statementStore.createProof({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// productAccountId: { derivationIndex: 0 },
/// statement,
/// });
/// assert(proofResult.isOk(), "createProof failed:", proofResult);
Expand Down Expand Up @@ -74,10 +71,7 @@ pub trait StatementStore: Send + Sync {
/// const topic: `0x${string}` = `0x${bytes.toHex()}`;
/// const statement = { expiry, topics: [topic] };
/// const result = await truapi.statementStore.createProof({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// productAccountId: { derivationIndex: 0 },
/// statement,
/// });
/// assert(result.isOk(), "createProof failed:", result);
Expand Down Expand Up @@ -133,10 +127,7 @@ pub trait StatementStore: Send + Sync {
/// const statement = { expiry, topics: [topic] };
///
/// const proofResult = await truapi.statementStore.createProof({
/// productAccountId: {
/// dotNsIdentifier: "truapi-playground.dot",
/// derivationIndex: 0,
/// },
/// productAccountId: { derivationIndex: 0 },
/// statement,
/// });
/// assert(proofResult.isOk(), "createProof failed:", proofResult);
Expand Down
Loading