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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ scripts/truapi-host-installer.sh
rather than importing a concrete protocol version. Runtime crates may use
`truapi::versioned::*` for wire envelopes, but should unwrap them into latest
payloads immediately.
- Inter-host SSO uses `SsoWire` and `#[sso_service]` as described
in the [macro guide](rust/crates/truapi-macros/README.md). Keep per-variant pairing,
dispatch, and correlation in those macros; do not add manual per-variant catalogs.
Requests with a caller share `ProductRequest<P>` around canonical payloads;
handler method names select request variants. Responses share `Response<P>`;
handler signatures name their result payload
and response variant. Transcript classification belongs in shared reply handling
or the handler; request context carries only the call and signing session.
- Native bindings expose canonical Rust domain and protocol types directly.
Add feature-gated UniFFI derives to those types and custom conversions for
unsupported leaf values instead of defining parallel `Native*` mirrors.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ returning a typed outcome: response bytes to post back, a disconnect marker, or
ignored) and `prepareDisconnectRequest` (builds the SCALE-encoded wire message
for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and
session-record cleanup remain on the wallet side.
See the core's [inter-host SSO design](rust/crates/truapi-server/README.md#inter-host-sso)
for typed handlers, canonical resource types, and consent bound to the signing session.
Product and SSO signing share canonical payloads and the one-byte `OptionBool`
encoding for `with_signed_transaction`.

### JS Host SDKs

Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/0024-personhood-as-product.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ struct ListRingVrfKeysResponse {
}
```

A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and a `RingVrfSignRequest` / `Response` pair mirrors `account_ring_vrf_sign` with the same two fields plus `message`. `RingVrfError` gains `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`.
A Host holding a current registry snapshot answers `list` locally. `CreateAccountProofRequest` and `GetAccountAliasRequest` carry `key_handle: ProductAccountId` and `calling_product_id`. The `RingVrfSignRequest` / `RingVrfSignResponse` pair mirrors `account_ring_vrf_sign` with the same two request fields plus `message`. `RingVrfError` includes `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`.

**Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host holding the product's domain entropy answers immediately and mirrors the registration fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without the entropy the Host issues the request and waits.

Expand Down
38 changes: 38 additions & 0 deletions js/packages/truapi/src/wire-equality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { describe, expect, it } from "bun:test";
import { str } from "./scale.js";
import { decodeWireMessage, encodeWireMessage } from "./transport.js";
import * as W from "./generated/wire-table.js";
import { HostSignPayloadRequest } from "./generated/types.js";

function toHex(u: Uint8Array): string {
return Array.from(u)
Expand Down Expand Up @@ -38,6 +39,43 @@ function unwrap<T>(result: Result<T, { message: string }>, message: string): T {
}

describe("encodeWireMessage / decodeWireMessage wire equality", () => {
it("matches Rust signing bytes for absent, true, and false transaction flags", () => {
for (const [flag, byte] of [
[undefined, "00"],
[true, "01"],
[false, "02"],
] as const) {
const request: HostSignPayloadRequest = {
account: {
dotNsIdentifier: "myapp.dot",
derivationIndex: { tag: "Index", value: 7 },
},
payload: {
blockHash: "0x",
blockNumber: "0x",
era: "0x",
genesisHash: "0x",
method: "0x",
nonce: "0x",
specVersion: "0x",
tip: "0x",
transactionVersion: "0x",
signedExtensions: [],
version: 4,
assetId: undefined,
metadataHash: undefined,
mode: undefined,
withSignedTransaction: flag,
},
};
const encoded = HostSignPayloadRequest.enc(request);
expect(toHex(encoded)).toBe(
`246d796170702e646f7400070000000000000000000000000004000000000000${byte}`,
);
expect(HostSignPayloadRequest.dec(encoded)).toEqual(request);
}
});

it("encodes handshake_request (discriminant 0) to match the Rust reference", () => {
const inner = new Uint8Array([0x00, 0x01]); // V1 variant + codec_version=1
const encoded = unwrap(
Expand Down
17 changes: 16 additions & 1 deletion rust/crates/truapi-codegen/src/ts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2883,6 +2883,7 @@ fn ts_inner_option(ty: &TypeRef) -> Result<String> {
fn ts_inner_option_with_named(ty: &TypeRef, qualified: bool, mode: NameMode<'_>) -> Result<String> {
match ty {
TypeRef::Option(inner) => ts_type_with_named(inner, qualified, mode),
TypeRef::Primitive(name) if name == "optionBool" => Ok("boolean".to_string()),
other => ts_type_with_named(other, qualified, mode),
}
}
Expand All @@ -2897,7 +2898,8 @@ fn ts_type_qualified_preserve(ty: &TypeRef) -> Result<String> {

fn ts_field_name(name: &str, ty: &TypeRef) -> (String, bool) {
let camel = to_camel_case(name);
let optional = matches!(ty, TypeRef::Option(_));
let optional = matches!(ty, TypeRef::Option(_))
|| matches!(ty, TypeRef::Primitive(name) if name == "optionBool");
(camel, optional)
}

Expand Down Expand Up @@ -3054,6 +3056,19 @@ mod tests {
}
}

#[test]
fn option_bool_fields_are_optional_and_keep_their_compact_codec() {
let api = api_with_payload_fields(vec![(
"with_signed_transaction",
TypeRef::Primitive("optionBool".to_string()),
)]);

let source = generate_types(&api, 1).expect("generate types");

assert!(source.contains("withSignedTransaction?: boolean;"));
assert!(source.contains("withSignedTransaction: S.OptionBool"));
}

#[test]
fn schema_hash_moves_when_a_payload_field_type_changes() {
// The drift class this fingerprint exists to catch: same frame ids, same
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-codegen/tests/golden/wire_table.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/crates/truapi-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand", "dep:base64"]
[dependencies]
truapi = { path = "../truapi" }
truapi-platform = { path = "../truapi-platform" }
truapi-macros = { path = "../truapi-macros" }
async-trait = "0.1"
derive_more = { version = "2", features = ["debug", "display", "error", "from"] }
futures = "0.3"
Expand Down
26 changes: 26 additions & 0 deletions rust/crates/truapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,32 @@ role-specific lifecycle, so no method exists on a role that can't mean it:
session/SSO crypto, key derivation, and permission policy, while all I/O
(statement-store RPC, storage, prompts, chain RPC) stays in the layers above.

### Inter-host SSO

`PairingHost::call(request)` sends typed requests to
[`SigningHostSsoService`](src/runtime/signing_host/sso_service.rs). Handlers own
consent and business logic; `sso_responder.rs` owns the transport loop and shared
allowance helpers. Resource consent is bound to the request's signing session:
account changes, disconnects, and reactivation invalidate pending approval before
allocation or key return. Allocation failure details stay in local transcripts.
Allocation requests use the canonical `truapi::latest::AllocatableResource` type.
Signing uses canonical request and result types. Product-scoped VRF requests use
`ProductRequest<P>` to attach the caller to a canonical payload. Both product and
SSO signing encode `with_signed_transaction` with the one-byte `OptionBool` codec.

The `host_logic::sso::messages::v1::RemoteMessage` enum owns the SCALE wire
contract. Its response variants wrap named result payloads in `Response<P>`,
which carries `responding_to` once. Macros generate request/response pairing
and dispatch; see the
[macro guide](../truapi-macros/README.md) for handler signatures and reply handling.
A new operation needs payload definitions, wire variants, a handler, and a typed
client call.

Rust consumers must update renamed SSO types and helpers even when SCALE encoding
is unchanged. Use `RemoteMessage::request(message_id, request)` to construct
requests. Decoded `SsoSessionStatement::RemoteMessages` preserves message order;
match variants directly or use the request's `SsoRequest::response_from_message`.

## Wire envelope

Every frame on the wire is encoded as:
Expand Down
36 changes: 13 additions & 23 deletions rust/crates/truapi-server/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ use truapi_platform::{

use crate::core::TrUApiCore;
use crate::frame::ProtocolMessage;
use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequestOutcome, v1};
use crate::host_logic::sso::messages::{RemoteMessage, SsoRequestOutcome};
use crate::runtime::sso_service::Dispatch;
use crate::runtime::{
ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer,
PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices,
SigningHostRole, answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing,
SigningHostRole, SigningHostSsoService, establish_pairing, respond_to_pairing, resume_pairing,
};
use crate::subscription::{HostInitiatedSubscriptionManager, Spawner};
use crate::transport::Transport;
Expand Down Expand Up @@ -780,20 +781,11 @@ impl SigningHostRuntime {
&self,
message: RemoteMessage,
) -> SsoRequestOutcome<RemoteMessage> {
let RemoteMessageData::V1(request) = message.data;
if matches!(request, v1::RemoteMessage::Disconnected) {
return SsoRequestOutcome::Disconnected;
}
match answer_remote_message(
&self.services,
&self.signing_host,
message.message_id,
request,
)
.await
{
Some(answer) => SsoRequestOutcome::Response(answer.response),
None => SsoRequestOutcome::Ignored,
let service = SigningHostSsoService::new(self.signing_host.clone());
match service.dispatch(service.current_session(), message).await {
Dispatch::Response(answer) => SsoRequestOutcome::Response(answer.message),
Dispatch::Disconnected => SsoRequestOutcome::Disconnected,
Dispatch::NotARequest(_) => SsoRequestOutcome::Ignored,
}
}
}
Expand Down Expand Up @@ -2245,9 +2237,7 @@ mod tests {

#[test]
fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() {
use crate::host_logic::sso::messages::{
RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1,
};
use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1};
use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig};

const ENTROPY: [u8; 32] = [0xab; 32];
Expand Down Expand Up @@ -2279,10 +2269,10 @@ mod tests {

let response_variant = RemoteMessage {
message_id: "m2".to_string(),
data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse(
SignRawLegacyResponse {
data: RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountResponse(
Response {
responding_to: "m2".to_string(),
signature: Ok(vec![]),
payload: Ok(vec![]),
},
)),
};
Expand Down Expand Up @@ -2336,6 +2326,6 @@ mod tests {
panic!("expected a product subtree response payload");
};
assert_eq!(payload.responding_to, "m3");
assert!(payload.product_public_key.is_ok());
assert!(payload.payload.is_ok());
}
}
3 changes: 2 additions & 1 deletion rust/crates/truapi-server/src/host_logic/sso.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Inter-host SSO with the paired wallet: `pairing` bootstraps the
//! QR/deeplink handshake, `messages` carries the session-channel payloads
//! exchanged afterwards.
//! exchanged afterwards, `wire` types the request/response pairing.

pub mod messages;
pub mod pairing;
pub mod wire;
Loading
Loading