Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dcb1c4b
feat(wire): split the frame discriminant into trait and method bytes
decrypto21 Aug 7, 2026
4458b32
fix(wire): lift trait ids clear of the codec 1 method range
decrypto21 Aug 7, 2026
23ed9a0
fix(wire): carry the trait byte through every post-rebase call site a…
decrypto21 Aug 10, 2026
0c6fb85
fix(wire): regenerate the account_get golden payload against Derivati…
decrypto21 Aug 10, 2026
61633a6
fix(wire): carry the trait byte through main's new call sites
decrypto21 Aug 21, 2026
c5a19ae
fix(wire): rebase onto main, allocate trait-relative ids for host_inf…
decrypto21 Aug 28, 2026
165c8c1
fix(wire): move the trait-id floor past main's true codec 1 ceiling
decrypto21 Aug 28, 2026
52739bb
fix(wire): correct the trait-id appendix to the 193 floor the code ac…
decrypto21 Aug 28, 2026
eaf757d
fix(wire): keep #547's answer-don't-drop semantics when re-keying to …
decrypto21 Aug 28, 2026
82e66ec
fix(wire): allocate trait id 208 for the locale trait main added
decrypto21 Aug 31, 2026
f190cdb
feat(wire): fold direction into the versioned payload (RFC 0028)
decrypto21 Sep 1, 2026
fed09fb
fix(wire): catch truapi-server up to the nested envelope (RFC 0028)
decrypto21 Sep 1, 2026
3da9798
feat(wire): bring the TS client onto the nested envelope (RFC 0028)
decrypto21 Sep 1, 2026
9e19716
fix(ios): recompute wire byte layouts for the nested envelope (RFC 0028)
decrypto21 Sep 1, 2026
9e6db28
docs: catch up the protocol design doc and READMEs to RFC 0028
decrypto21 Sep 1, 2026
6ebb401
fix(wire): decode interrupt errors, encode declines as failures, drop…
decrypto21 Sep 2, 2026
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
14 changes: 14 additions & 0 deletions .changeset/wire-trait-method-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@parity/truapi": minor
"@parity/truapi-host": minor
---

Address every frame with a two-byte `(trait, method)` wire discriminant. The
trait byte names the API trait and the method byte addresses a method within
it, so each trait owns a full 256-slot method space and method ids restart at
0 in every trait.

This is wire codec version 2. A codec version 1 peer cannot exchange frames
with a codec version 2 peer in either direction: the handshake itself rides
the changed envelope, so the mismatch cannot be negotiated in band. Hosts and
products must move together.
2 changes: 1 addition & 1 deletion .claude/skills/e2e-dotli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ uses the iframe `postMessage` provider.
- Connection chip stays on **Handshaking** → handshake is failing.
Check:
- The dotli console for `Unknown wire tag` /
`Unknown wire discriminant` — wire-table mismatch between dotli's
`unknown wire discriminant pair` — wire-table mismatch between dotli's
vendored `@parity/truapi` and the just-built one.
- The playground console for `decodeWireMessage` errors — the
inbound frame's discriminant is unknown (the playground's
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot
rust/crates/
truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical)
truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher
truapi-macros/ #[wire(id = N)] proc-macro
truapi-macros/ #[wire_trait(id = N)] + #[wire(...)] proc-macros
truapi-platform/ Host syscall traits (storage, navigation, consent, ...)
truapi-provider/ network provider backends (WebSocket RPC or smoldot light-client)
truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node)
Expand Down Expand Up @@ -306,7 +306,7 @@ __truapi.setLogLevel("debug");
sessionStorage.setItem("dotli:truapi-debug", "1");
```

Reload after setting the debug-panel flag. Watch for `Unknown wire discriminant`, missing
Reload after setting the debug-panel flag. Watch for `unknown wire discriminant pair`, missing
`@parity/truapi-host` imports, worker WASM instantiation failures, and
debug-panel traffic disappearing when the login popup opens.

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Canonical design documentation lives in `docs/design/`. To propose updates or ad
rust/crates/
truapi/ Rust trait + type definitions (source of truth)
truapi-codegen/ rustdoc JSON → TypeScript client generator
truapi-macros/ #[wire(id = N)] proc-macro
truapi-macros/ #[wire_trait(id = N)] + #[wire(...)] proc-macros
js/packages/
truapi/ @parity/truapi TypeScript package (generated TS is auto-generated and git-ignored)
playground/ Next.js interactive playground
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full
rust/crates/
truapi/ Rust traits, versioned envelopes, and latest payload re-exports
truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher
truapi-macros/ #[wire(id = N)] proc-macro
truapi-macros/ #[wire_trait(id = N)] + #[wire(...)] proc-macros
truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...)
truapi-provider/ Network provider backends (WebSocket RPC or smoldot light-client)
truapi-server/ Host runtime: dispatcher, typed SCALE logic, chain signing, WASM surface
Expand Down Expand Up @@ -138,12 +138,12 @@ dependency on the crate:

## How it works

1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one.
1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each trait tagged `#[wire_trait(id = N)]` and each method tagged `#[wire(id = N)]` for a stable byte-level `(trait, method)` dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one.
2. `truapi-codegen` reads rustdoc JSON for that crate and generates the TypeScript client under git-ignored paths in `js/packages/truapi/`.
3. Higher-level SDKs wrap the typed client; the transport encodes SCALE frames and ships them over `MessagePort` (or `postMessage` in iframe mode) to the host.
4. The host decodes the frame, dispatches to the matching trait method, encodes the response, and ships it back.

Wire ids are append-only: existing ids never change, so deployed products stay compatible across protocol revisions. Discriminant 255 is permanently reserved for a correlated protocol error, allowing either peer to reject API messages introduced after it was released instead of leaving the caller pending.
Wire ids are append-only per trait: a trait id is never reassigned and a method id is never renumbered or reused within its trait, so deployed products stay compatible across protocol revisions. New methods take the next free method ids in their own trait and leave every other trait untouched. Trait 255 is permanently reserved for a correlated protocol error, allowing either peer to reject API messages introduced after it was released instead of leaving the caller pending.

## Develop

Expand Down
96 changes: 42 additions & 54 deletions docs/design/truapi-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,72 +49,59 @@ struct Message {
}
```

`requestId` ties related messages together (see [Rules](#rules)); `payload` carries the action itself. `Payload` is an enum whose variants are the **actions** — the individual things a Host and Product can say to each other.
`requestId` ties related messages together (see [Rules](#rules)); `payload` carries the action itself. On the wire the envelope is laid out as:

Actions are not written by hand. They are derived mechanically from the TrUAPI methods, so the high-level method signature and the wire format can never drift apart. One method expands into several actions depending on its shape: a plain call becomes a request/response pair, while a subscription becomes a small lifecycle of start, stop, interrupt, and receive messages.
```text
[requestId: SCALE str][trait: u8][method: u8][payload bytes...]
```

The two bytes after the `requestId` are the **`(trait, method)` discriminant pair**. The first byte identifies the API trait (`System`, `Account`, `Chain`, ...); the second identifies a method within it: exactly one id per method, regardless of that method's shape. The payload bytes are the SCALE-encoded value for that method's own nested envelope (below), inlined without a length prefix; the receiver reads to the end of the transport frame.

Each action variant carries an explicit wire-protocol discriminant, its `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, or `receive_id`. These ids are assigned per method in the `truapi` crate via the `#[wire(...)]` annotation. They are **append-only and never reused**: once an id ships it keeps its meaning forever, which is what lets a newer Host and an older Product still understand each other. The crate is the source of truth for their values. Discriminant 255 is permanently reserved for protocol errors and cannot be assigned to an API method.
Trait discriminants are assigned per trait in the `truapi` crate via the trait-level `#[wire_trait(id = N)]` annotation, with the `System` trait fixed at `1`, so a handshake request frame always starts `[requestId][0x01][0x00]`. Each method carries an explicit discriminant within its trait, assigned via the `#[wire(id = N)]` annotation and numbered from `0` independently inside every trait. Ids are **append-only per trait and never reused**: once a `(trait, method)` pair ships it keeps its meaning forever, which is what lets a newer Host and an older Product still understand each other, and adding methods to one trait never disturbs the ids of any other trait. The crate is the source of truth for all values. Trait discriminant `255` is permanently reserved for protocol errors and cannot be assigned to an API trait, so no method can ever be addressed there; a protocol error travels on the pair `(255, 255)`.

Payloads are versioned independently of the action id, so a single message can evolve without renumbering anything around it. The current version `V1` encodes as discriminant `0`:
#### The nested envelope

A `(trait, method)` pair names a method, not a direction: request and response share it, and so do a subscription's four phases. Direction, and the payload version, both live inside the payload bytes as a small nested envelope instead:

```rust
enum Versioned<T> {
V1(T),
enum Versioned<Shape> {
V1(Shape),
// ...
}
```

Actions are derived from the TrUAPI methods using the following algorithm:

- For request functions, actions are derived as follows:
- Request
- Name: `method_name + '_request'`
- Argument: `Versioned<(arg1, arg2, ...)>`
- Discriminant: `request_id`
- Response
- Name: `method_name + '_response'`
- Argument: `Versioned<Result<ReturnValue, ReturnError>>`
- Discriminant: `response_id`
- For subscriptions, there are four messages:
- Subscribe
- Name: `method_name + '_start'`
- Argument: tuple of all arguments except the callback `Versioned<(arg1, arg2, ...)>`
- Discriminant: `start_id`
- Unsubscribe
- Name: `method_name + '_stop'`
- Argument: none
- Discriminant: `stop_id`
- Interrupt
- Name: `method_name + '_interrupt'`
- Argument: none
- Discriminant: `interrupt_id`
- Receive
- Name: `method_name + '_receive'`
- Argument: the versioned callback argument `Versioned<CallbackArg>`
- Discriminant: `receive_id`

Put together, a slice of `Payload` looks like this (the payload types are illustrative; see the `truapi` crate for the real ones):
enum Request<Req, Res> {
Request(Req),
Response(Res),
}

```rust
enum Payload {
host_handshake_request(Versioned::V1(HandshakeVersion)),
host_handshake_response(Versioned::V1(Result<(), GenericErr>)),
enum Subscription<Start, Item, Err> {
Start(Start),
Stop,
Interrupt(Option<Err>), // None = clean completion, Some(err) = failure
Receive(Item),
}
```

// ...
// imaginary subscription method
`Shape` is `Request<Req, Result<Ok, Err>>` for a plain call, or `Subscription<Start, Item, Err>` for a subscription, whichever the method's own return type calls for. The version tag therefore selects a method's *shape*: today every method has exactly one version and one shape, but a later version of the same method could switch a call to a subscription (or vice versa) without needing a new `(trait, method)` pair. On the wire, `Versioned<Shape>`'s tag and `Request`/`Subscription`'s own direction tag are two consecutive SCALE enum discriminant bytes: `[version][direction][...direction's own payload]`.

message_send_request(Versioned::V1((ChainId, str))),
message_send_response(Versioned::V1(Result<(), GenericErr>)),
For example, a `system_feature_supported` request/response pair (trait `1`, method `1`) is carried entirely by the payload bytes at that one address:

message_subscribe_start(Versioned::V1(ChainId)),
message_subscribe_stop,
message_subscribe_interrupt,
message_subscribe_receive(Versioned::V1(str)),
```text
outbound: [0x01][0x01][0x00 V1][0x00 Request][...request fields]
inbound: [0x01][0x01][0x00 V1][0x01 Response][0x00 Ok][...response fields]
```

// ...
}
and a subscription's four phases (start, stop, interrupt, receive) all address the same `(trait, method)` pair, distinguished only by which `Subscription` variant tag follows the version byte:

```text
start: [trait][method][0x00 V1][0x00 Start][...start fields]
stop: [trait][method][0x00 V1][0x01 Stop]
interrupt: [trait][method][0x00 V1][0x02 Interrupt][...Option<Err>]
receive: [trait][method][0x00 V1][0x03 Receive][...item fields]
```

Request/response and subscription methods are both derived mechanically from the TrUAPI trait methods, so the high-level method signature and the wire format can never drift apart; nothing is written by hand.

### Rules

A single byte channel carries every call in both directions at once, so the two sides need a way to tell which message belongs to which exchange. That is what `requestId` is for.
Expand All @@ -123,15 +110,15 @@ A single byte channel carries every call in both directions at once, so the two

Every request expects exactly one response. Each Host or Product MUST send a response message for every request it receives, and the request and its response MUST share the same `requestId` — so the caller can match a reply to the call it made even with many calls in flight.

If a receiver has no handler for an incoming discriminant, it MUST send a protocol-error frame with discriminant 255 and the same `requestId`. The codec-version-1 payload is `V1(UnsupportedMessage { discriminant })`, encoded as the three bytes `[0, 0, unsupported_discriminant]`. The sender maps this method-independent response to its own pending request or subscription and reports a generic unsupported error.
If a receiver has no handler for an incoming `(trait, method)` pair, it MUST send a protocol-error frame addressed to `(255, 255)` with the same `requestId`. The codec-version-2 payload is `V1(UnsupportedMessage { trait_id, method_id })`, encoded as the four bytes `[0, 0, unsupported_trait, unsupported_method]` — one byte cannot name a pair, so the error that describes the envelope grew with it. The sender maps this method-independent response to its own pending request or subscription and reports a generic unsupported error. A receiver MUST NOT answer a protocol-error frame with another protocol error.

A protocol-error frame MUST NOT receive another protocol-error response. An unmatched error is ignored, while a malformed protocol-error payload is rejected as a wire violation. These rules prevent error loops without hiding malformed control messages.

Hosts and Products released before this control frame was introduced still silently drop unknown discriminants. They must be upgraded once before they can safely reject APIs introduced by later peers. Existing API frames and codec version 1 remain unchanged.

#### Subscription

A subscription is not a one-shot call but an ongoing stream: the consumer asks once and then receives updates until it stops listening. Its four messages `start`, `stop`, `interrupt`, and `receive` MUST all share the same `requestId`, so a subscription handler can route every update and teardown signal to the right place.
A subscription is not a one-shot call but an ongoing stream: the consumer asks once and then receives updates until it stops listening. Its four messages (`start`, `stop`, `interrupt`, and `receive`) all address the same `(trait, method)` pair (distinguished by the `Subscription` direction tag inside the payload) and MUST all share the same `requestId`, so a subscription handler can route every update and teardown signal to the right place.

Each message has a defined role:

Expand Down Expand Up @@ -159,6 +146,7 @@ Before either side trusts a single byte of payload, they have to agree on how th

Handshake calls are bidirectional: both Host and Product can send a handshake request, and both MUST respond to one. An implementation CAN apply a timeout of 10 seconds, after which the connection is marked failed and the call returns a timeout error. The handshake result can be cached.

The handshake request carries the protocol (codec) version as a `u8`. On receiving it, the peer switches its encoding/decoding mode to match; for SCALE codec, the version is `1`. A successful handshake MUST be the first request TrUAPI processes — any other request sent before a successful handshake response MUST fail.
The handshake request carries the protocol (codec) version as a `u8`. On receiving it, the peer switches its encoding/decoding mode to match; for the SCALE codec with the two-byte `(trait, method)` envelope, the version is `2`. (Codec version `1` designates the retired single-byte-discriminant envelope; a peer speaking it fails the handshake.) A successful handshake MUST be the first request TrUAPI processes — any other request sent before a successful handshake response MUST fail.

The concrete handshake request, response, and error types are defined in the `truapi` crate.

2 changes: 1 addition & 1 deletion docs/local-e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ iframe via `window.parent` and uses the iframe `postMessage` provider.
If the connection chip stays on _Handshaking_, the handshake is
failing. Check:

- The dotli console for `Unknown wire tag` / `Unknown wire discriminant`
- The dotli console for `Unknown wire tag` / `unknown wire discriminant pair`
errors — wire-table mismatch between the dotli vendored copy of
`@parity/truapi` and the just-built one.
- The playground console for `decodeWireMessage` errors — the inbound
Expand Down
29 changes: 17 additions & 12 deletions ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ struct TrUAPIWsBridgeTests {
Issue.record("expected binary frame, got \(message)")
return
}
// Frame tail is the SCALE Result payload: Ok(0x00), V1(0x00), supported(0x01).
#expect(response.suffix(3) == Data([0x00, 0x00, 0x01]))
// Frame tail is the merged wire envelope: version(0x00), direction=
// Response(0x01), Result::Ok(0x00), supported(0x01).
#expect(response.suffix(4) == Data([0x00, 0x01, 0x00, 0x01]))
}

/// An iOS host must classify itself as `Ios` without the embedding app
Expand Down Expand Up @@ -82,25 +83,28 @@ private extension TrUAPIWsBridgeTests {
)
}

// wire_table.rs: SYSTEM_FEATURE_SUPPORTED.request_id = 2
static let featureSupportedRequestDiscriminant = Data([0x02])
// wire_table.rs: SYSTEM_FEATURE_SUPPORTED { trait_id: 1, method_id: 1 }.
// Both bytes are load-bearing: a lone method byte is read as the trait and
// routes into a different trait's method 0 rather than failing.
static let featureSupportedDiscriminant = Data([0x01, 0x01])

// wire_table.rs: SYSTEM_HOST_INFO.request_id = 192
static let hostInfoRequestDiscriminant = Data([0xC0])
// wire_table.rs: SYSTEM_HOST_INFO { trait_id: 1, method_id: 3 }.
static let hostInfoDiscriminant = Data([0x01, 0x03])

static func hostInfoRequestFrame() -> Data {
var frame = Data()
frame.append(contentsOf: [0x0C]) // compact length 3
frame.append("p:1".data(using: .utf8)!)
frame.append(hostInfoRequestDiscriminant) // from wire_table.rs
frame.append(contentsOf: [0x00]) // V1
frame.append(hostInfoDiscriminant) // from wire_table.rs
frame.append(contentsOf: [0x00, 0x00]) // version=V1, direction=Request
return frame
}

// SCALE Result payload: Ok(0x00), V1(0x00), then HostInfo as
// The merged wire envelope's response tail: version(0x00),
// direction=Response(0x01), Result::Ok(0x00), then HostInfo as
// platform(Ios = 0x02), name, version (empty, hostVersion is unset).
static var hostInfoResponseTail: Data {
var tail = Data([0x00, 0x00, 0x02, 0x44]) // 0x44 is compact length 17
var tail = Data([0x00, 0x01, 0x00, 0x02, 0x44]) // 0x44 is compact length 17
tail.append("truapi-host-tests".data(using: .utf8)!)
tail.append(contentsOf: [0x00])
return tail
Expand All @@ -110,8 +114,9 @@ private extension TrUAPIWsBridgeTests {
var frame = Data()
frame.append(contentsOf: [0x0C]) // compact length 3
frame.append("p:1".data(using: .utf8)!)
frame.append(featureSupportedRequestDiscriminant) // from wire_table.rs
frame.append(contentsOf: [0x00, 0x00, 0x80]) // V1, Chain, compact(32)
frame.append(featureSupportedDiscriminant) // from wire_table.rs
// version=V1, direction=Request, Chain, compact(32)
frame.append(contentsOf: [0x00, 0x00, 0x00, 0x80])
frame.append(Data(repeating: 0, count: 32))
return frame
}
Expand Down
Loading