From ef9feffaf94963e879d406237b94eb12528bfed5 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 15:31:47 -0400 Subject: [PATCH 01/67] feat: add Chat v2 account authority operations --- CHANGELOG.md | 2 + README.md | 6 + rust/crates/truapi-client/src/generated.rs | 34 +- .../truapi-codegen/tests/golden/dispatcher.rs | 39 ++ .../truapi-codegen/tests/golden/wire_table.rs | 12 +- rust/crates/truapi-server/Cargo.toml | 2 +- .../src/host_logic/permissions.rs | 47 +- .../src/host_logic/sso/messages.rs | 122 +++++- .../src/host_logic/sso/messages/v1.rs | 11 +- .../src/host_logic/sso/pairing.rs | 4 +- .../src/host_logic/sso/pairing/v2.rs | 8 + rust/crates/truapi-server/src/runtime.rs | 82 ++-- .../truapi-server/src/runtime/authority.rs | 401 +++++++++++++++++- .../src/runtime/capabilities/account.rs | 105 ++++- .../truapi-server/src/runtime/pairing_host.rs | 25 +- .../src/runtime/pairing_host/sso_channel.rs | 100 ++++- .../truapi-server/src/runtime/signing_host.rs | 38 +- .../src/runtime/signing_host/sso_responder.rs | 325 ++++++++++---- .../truapi-server/src/runtime/sso_pairing.rs | 26 +- .../truapi-server/tests/wire_result_shape.rs | 26 ++ rust/crates/truapi/src/api/account.rs | 37 +- rust/crates/truapi/src/lib.rs | 6 + rust/crates/truapi/src/v01/account.rs | 82 ++++ .../truapi/src/v01/resource_allocation.rs | 3 + rust/crates/truapi/src/versioned/account.rs | 3 + 25 files changed, 1394 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 664fd8773..68790019f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Added +- Add `account.productDeviceChat` for host-private Chat v2 identity binding and + identity-route sealing/opening through local or paired account authorities. - Generate a transport-neutral `no_std` Rust client with typed request, subscription, result-subscription, and host-initiated Worker subscription codecs. diff --git a/README.md b/README.md index 6ecc3bf15..38dfe50f0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ const result = await truapi.accountManagement.accountGet({ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full client reference. +`account.productDeviceChat` binds a product-derived account to the connected +wallet's Chat v2 identity and seals or opens identity-route payloads without +exposing the wallet's X25519 private key. Browser pairing hosts forward the +operation over encrypted SSO; signing hosts require the calling product's +identity-disclosure authorization before using local wallet material. + ## Repository layout ``` diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 5aca53c9d..d2aa0a997 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -239,6 +239,35 @@ impl RequestMethod for AccountRingVrfSign { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `account_product_device_chat` method marker. +pub struct AccountProductDeviceChat; +impl AccountProductDeviceChat { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "product_device_chat", + wire_name: "account_product_device_chat", + request_type: "truapi::versioned::account::HostProductDeviceChatRequest", + response_type: "truapi::versioned::account::HostProductDeviceChatResponse", + error_type: Some("truapi::versioned::account::HostProductDeviceChatError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + sensitive: true, + wire: MethodWire::Request(RequestFrameIds { + request_id: 174, + response_id: 175, + }), + }; +} +impl RequestMethod for AccountProductDeviceChat { + type Request = truapi::versioned::account::HostProductDeviceChatRequest; + type Response = truapi::versioned::account::HostProductDeviceChatResponse; + type Error = truapi::versioned::account::HostProductDeviceChatError; + const RESPONSE_VERSIONED: bool = true; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `account_get_legacy_accounts` method marker. pub struct AccountGetLegacyAccounts; impl AccountGetLegacyAccounts { @@ -2121,6 +2150,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2191,6 +2221,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2261,6 +2292,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 7b059b295..58c85c5c0 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -370,6 +370,45 @@ where }) }); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_PRODUCT_DEVICE_CHAT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostProductDeviceChatRequest = 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::HostProductDeviceChatResponse = match host.product_device_chat(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload( + downgrade_call_error(err, target_version), + target_version, + )); + } + }; + // Downgraded to the caller's version: a handler answers in + // latest terms, and a peer that asked in an older version + // cannot decode a newer variant. + Ok(encode_versioned_ok_payload( + ::from_latest( + truapi::versioned::IntoLatest::into_latest(response), + target_version, + ), + )) + }) + }); + } { 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 5d59d7f93..e96191a49 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { @@ -496,6 +496,12 @@ pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { response_id: 173, }; +/// Wire discriminants for `account_product_device_chat`. +pub const ACCOUNT_PRODUCT_DEVICE_CHAT: RequestFrameIds = RequestFrameIds { + request_id: 174, + response_id: 175, +}; + /// Wire discriminants for `system_get_product_context`. pub const SYSTEM_GET_PRODUCT_CONTEXT: RequestFrameIds = RequestFrameIds { request_id: 190, @@ -795,6 +801,10 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_ring_vrf_sign", kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), }, + WireEntry { + method: "account_product_device_chat", + kind: WireKind::Request(ACCOUNT_PRODUCT_DEVICE_CHAT), + }, WireEntry { method: "system_get_product_context", kind: WireKind::Request(SYSTEM_GET_PRODUCT_CONTEXT), diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 390556255..4c0d14293 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -55,7 +55,7 @@ getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } -x25519-dalek = { version = "2", default-features = false, features = ["static_secrets"] } +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] } sha2 = "0.10" merlin = "3" parking_lot = "0.12" diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index f8cd35c62..640a6b9de 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,9 +7,9 @@ //! The cache layer is shared but keys are typed so a device grant cannot //! authorize a remote operation by accident. Keys are also scoped by product id //! so one product's authorization never grants another product's request. -//! Identity disclosure is also represented as a product-scoped authorization, -//! but the prompt itself is handled by the account runtime because it uses the -//! richer user-confirmation surface rather than the device/remote callbacks. +//! Identity disclosure is also represented as a product-scoped authorization; +//! its richer user-confirmation prompt is coordinated here so local and remote +//! account-authority paths share one decision state machine. //! //! Domain grants (`RemotePermission::Remote`) are the one request that does not //! occupy a single slot. A product may ask for several domains at once, while @@ -42,9 +42,10 @@ use truapi::latest::{ RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ - CoreStorage, CoreStorageKey, DevicePermissionStatus, PermissionAuthorizationRequest, - PermissionAuthorizationStatus, PermissionStatusHost, Permissions, - has_trusted_remote_permissions, remote_domain_candidates, + CoreStorage, CoreStorageKey, DevicePermissionStatus, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionStatusHost, + Permissions, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, + remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -358,6 +359,40 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a set_authorization_status(self.storage, key, status).await } + /// Resolve the product's identity-disclosure grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_identity_disclosure( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::IdentityDisclosure( + IdentityDisclosureReview { + product_id: self.product_id.to_string(), + }, + )) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, prompting the platform's `device_permission` callback /// and persisting the answer when the question is still open. diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 076703c62..fbdc30aab 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -29,7 +29,7 @@ use truapi::latest::{ HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RawPayload, RingLocation, }; -use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; +use truapi::v01::{self, HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -497,6 +497,8 @@ pub enum SsoAllocatableResource { SmartContractAllowance(DerivationIndex), /// Transfer of the product subtree key so the host can sign locally. AutoSigning, + /// Current UTC-day Statement Store allowance targeting the selected product account. + ProductStatementStoreAllowance(DerivationIndex), } impl From for SsoAllocatableResource { @@ -508,6 +510,9 @@ impl From for SsoAllocatableResource { Self::SmartContractAllowance(index) } AllocatableResource::AutoSigning => Self::AutoSigning, + AllocatableResource::ProductStatementStoreAllowance(index) => { + Self::ProductStatementStoreAllowance(index) + } } } } @@ -563,6 +568,8 @@ pub enum SsoAllocatedResource { /// Entropy of the product's ring-VRF domain. ring_vrf_domain_entropy: [u8; 32], }, + /// Product account was registered as the current Statement Store slot target. + ProductStatementStoreAllowance, } impl SsoAllocatedResource { @@ -573,6 +580,7 @@ impl SsoAllocatedResource { Self::BulletinAllowance { .. } => "bulletin-allowance", Self::SmartContractAllowance => "smart-contract-allowance", Self::AutoSigning { .. } => "auto-signing", + Self::ProductStatementStoreAllowance => "product-statement-store-allowance", } } } @@ -622,6 +630,53 @@ pub struct CreateTransactionLegacyRequest { pub payload: CreateTransactionLegacyPayload, } +/// Product-device Chat v2 request forwarded to the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductDeviceChatRequest { + /// Product originating the operation. + pub calling_product_id: String, + /// Resolved authority operation; no wallet private material is included. + pub operation: SsoProductDeviceChatOperation, +} + +/// Product-device Chat v2 operation carried over encrypted SSO. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoProductDeviceChatOperation { + /// Bind the product device to the wallet identity and derive peer routes. + Bind { + /// Product account index; the Account Holder re-derives the device + /// account instead of trusting a pairing-host supplied public key. + derivation_index: v01::DerivationIndex, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal an identity-route payload for the peer. + Seal { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an authenticated identity-route payload from the peer. + Open { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, +} + +/// Product-device Chat v2 response returned by the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductDeviceChatResponse { + /// Remote request identifier this response answers. + pub responding_to: String, + /// Authority operation result. + pub payload: Result, +} + /// Versioned legacy transaction-creation payload. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CreateTransactionLegacyPayload { @@ -676,6 +731,8 @@ pub enum SsoRemoteResponse { ListRingVrfKeys(ListRingVrfKeysResponse), /// Direct ring-VRF signing response. RingVrfSign(RingVrfSignResponse), + /// Product-device Chat v2 response. + ProductDeviceChat(Box), } impl SsoRemoteResponse { @@ -693,6 +750,7 @@ impl SsoRemoteResponse { Self::RegisterRingVrfKey(_) => "register-ring-vrf-key", Self::ListRingVrfKeys(_) => "list-ring-vrf-keys", Self::RingVrfSign(_) => "ring-vrf-sign", + Self::ProductDeviceChat(_) => "product-device-chat", } } } @@ -830,10 +888,32 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::RingVrfSign(response)) } + v1::RemoteMessage::ProductDeviceChatResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ProductDeviceChat(Box::new(response))) + } _ => None, } } +/// Build a product-device Chat v2 request for the Account Holder. +pub fn product_device_chat_message( + message_id: String, + calling_product_id: String, + operation: SsoProductDeviceChatOperation, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatRequest( + ProductDeviceChatRequest { + calling_product_id, + operation, + }, + )), + } +} + /// Build an RFC-0024 ring-VRF key registration request for the Account Holder. pub fn register_ring_vrf_key_message( message_id: String, @@ -1563,6 +1643,46 @@ mod tests { ); } + #[test] + fn product_device_chat_messages_pin_mobile_wire_indices() { + let request = product_device_chat_message( + "request".to_string(), + "egui-chat.paseo".to_string(), + SsoProductDeviceChatOperation::Bind { + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [0x66; 32], + }, + ); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 24); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let product_response = ProductDeviceChatResponse { + responding_to: "request".to_string(), + payload: Ok(v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext: vec![0x77; 28], + }), + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatResponse( + product_response.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 25); + assert_eq!( + remote_response_for_message(response, "request"), + Some(SsoRemoteResponse::ProductDeviceChat(Box::new( + product_response + ))) + ); + } + #[test] fn sign_vrf_messages_match_mobile_wire_contract() { let payload = HostAccountSignVrfRequest { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 68f9c09dc..dacde0004 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -9,7 +9,8 @@ use parity_scale_codec::{Decode, Encode}; use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, - ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductDeviceChatRequest, + ProductDeviceChatResponse, ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, RingVrfSignRequest, RingVrfSignResponse, SignRawLegacyRequest, @@ -104,4 +105,12 @@ pub enum RemoteMessage { #[codec(index = 23)] #[display("ring_vrf_sign_response")] RingVrfSignResponse(RingVrfSignResponse), + /// Forward a product-device Chat v2 operation to the Account Holder. + #[codec(index = 24)] + #[display("product_device_chat")] + ProductDeviceChatRequest(ProductDeviceChatRequest), + /// Account Holder's product-device Chat v2 response. + #[codec(index = 25)] + #[display("product_device_chat_response")] + ProductDeviceChatResponse(ProductDeviceChatResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 037095471..d8a49f86f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -254,13 +254,15 @@ pub fn establish_sso_session_info( /// The statement keypair signs every session statement (its public key is the /// `identityAccountId` the pairing host binds the session to), and the X25519 /// secret is the persistent `sso` key both sides feed into the session ECDH. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct ResponderIdentity { /// Expanded Ed25519 secret used to sign session statements. + #[debug("\"\"")] pub statement_secret: [u8; 64], /// Ed25519 public key advertised as the session identity account. pub statement_public_key: [u8; 32], /// X25519 secret key used to derive the shared session channels. + #[debug("\"\"")] pub encryption_secret_key: [u8; 32], /// Raw X25519 public key advertised during pairing. pub encryption_public_key: [u8; 32], diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index e4ffebfec..aca105596 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -5,6 +5,7 @@ //! use parity_scale_codec::{Decode, Encode}; +use zeroize::Zeroize; /// Handshake proposal sent by the host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -79,3 +80,10 @@ pub struct Success { /// Wallet-derived source for deterministic product entropy, never the raw root secret. pub root_entropy_source: [u8; 32], } + +impl Drop for Success { + fn drop(&mut self) { + self.identity_chat_private_key.zeroize(); + self.root_entropy_source.zeroize(); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..19b26c23c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -40,7 +40,7 @@ use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; -use authority::{AuthorityCancelError, AuthoritySession}; +use authority::{AuthorityCancelError, AuthoritySession, ProductDeviceChatAuthorityError}; pub(crate) use authority::{AuthorityError, BulletinAllowanceKey, ProductAuthority}; pub(crate) use chat::{ChatConnection, chat_platform_for}; use futures::{FutureExt, StreamExt, pin_mut}; @@ -57,7 +57,9 @@ pub(crate) use signing_host::{ pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; use truapi::api::Chat; -use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; +use truapi::versioned::account::{ + HostAccountGetError, HostAccountSignVrfError, HostProductDeviceChatError, +}; use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, @@ -67,7 +69,7 @@ use truapi::versioned::chat::{ use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::{CallContext, CallError, CancellationReason, Subscription, v01}; use truapi_platform::{ - AccountAccessReview, ChatFieldError, IdentityDisclosureReview, PermissionAuthorizationRequest, + AccountAccessReview, ChatFieldError, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, ProductStorageKey, SessionUiInfo, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, validate_chat_icon, validate_chat_message_content, validate_chat_name, @@ -575,41 +577,10 @@ impl ProductRuntimeHost { &self, ) -> Result { let product_id = self.product_id(); - let request = PermissionAuthorizationRequest::IdentityDisclosure; - let service = self.permissions_service(&product_id); - let cached = service - .authorization_status(&request) - .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - if cached != PermissionAuthorizationStatus::NotDetermined { - return Ok(cached); - } - - // A dismissed/unavailable confirmation has no durable user decision. - // Fail the current disclosure request closed but keep authorization in - // the ask/default state so the next request can prompt again. - let confirmed = match self - .platform - .confirm_user_action(UserConfirmationReview::IdentityDisclosure( - IdentityDisclosureReview { - product_id: product_id.clone(), - }, - )) - .await - { - Ok(confirmed) => confirmed, - Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), - }; - let status = if confirmed { - PermissionAuthorizationStatus::Authorized - } else { - PermissionAuthorizationStatus::Denied - }; - service - .set_authorization_status(&request, status) + self.permissions_service(&product_id) + .check_or_prompt_identity_disclosure() .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - Ok(status) + .map_err(|err| format!("permission storage failed: {err:?}")) } async fn classify_legacy_address_signer( @@ -777,6 +748,43 @@ fn account_get_authority_error(err: AuthorityError) -> CallError CallError { + let error = match error { + AuthorityError::Disconnected => v01::HostProductDeviceChatError::NotConnected, + AuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + AuthorityError::Cancelled(error) => v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + }, + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostProductDeviceChatError::Unknown { reason }, + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + +fn product_device_chat_authority_error( + error: ProductDeviceChatAuthorityError, +) -> CallError { + let error = match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { match err { RingVrfError::RingNotFound => v01::HostAccountGetAliasError::RingNotFound, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 302473ea3..0dd8a55e3 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ - AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, + AccountId, DerivationIndex, HostAccountCreateProofResponse, HostAccountGetAliasResponse, HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignResponse, HostCreateTransactionResponse, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, @@ -15,7 +15,7 @@ use truapi::latest::{ HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; -use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; +use truapi::v01::{HostAccountSignVrfRequest, HostProductDeviceChatResponse, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -285,10 +285,52 @@ pub(crate) struct RingVrfSignAuthorityRequest { pub message: Vec, } +/// Host-private Chat identity operation after product authorization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityRequest { + Bind { + calling_product_id: String, + device_account_id: [u8; 32], + derivation_index: DerivationIndex, + peer_identity_account_id: [u8; 32], + peer_chat_public_key: [u8; 32], + }, + Seal { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + plaintext: Vec, + }, + Open { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + combined_ciphertext: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityError { + Disconnected, + Rejected, + InvalidPeerKey, + InvalidCiphertext, + Unavailable(String), +} + +impl From for ProductDeviceChatAuthorityError { + fn from(error: AuthorityError) -> Self { + match error { + AuthorityError::Disconnected => Self::Disconnected, + AuthorityError::Rejected => Self::Rejected, + other => Self::Unavailable(other.to_string()), + } + } +} + /// Statement-store allowance signing material held by the authority layer. -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub(crate) struct StatementStoreAllowanceKey { /// sr25519 secret used to sign allowance statements. + #[debug("\"\"")] pub(crate) secret: [u8; 64], /// Public key derived from `secret`. pub(crate) public_key: [u8; 32], @@ -456,6 +498,14 @@ pub(crate) trait ProductAuthority: Send + Sync { request: RingVrfSignAuthorityRequest, ) -> Result; + /// Bind/seal/open using the active wallet's host-private Chat identity key. + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result; + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, @@ -511,6 +561,204 @@ pub(crate) trait ProductAuthority: Send + Sync { ) -> Result<[u8; 32], AuthorityError>; } +pub(super) fn execute_product_device_chat( + identity_chat_private_key: &[u8; 32], + identity_account_id: [u8; 32], + request: ProductDeviceChatAuthorityRequest, +) -> Result { + use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::{ChaCha20Poly1305, Nonce}; + use hkdf::Hkdf; + use sha2::Sha256; + use x25519_dalek::{PublicKey, StaticSecret}; + use zeroize::Zeroizing; + + let peer_public_key = match &request { + ProductDeviceChatAuthorityRequest::Bind { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Seal { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Open { + peer_chat_public_key, + .. + } => *peer_chat_public_key, + }; + if !is_canonical_x25519_public_key(&peer_public_key) { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + let shared_secret = Zeroizing::new( + StaticSecret::from(*identity_chat_private_key) + .diffie_hellman(&PublicKey::from(peer_public_key)) + .to_bytes(), + ); + if *shared_secret == [0; 32] { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + + return match request { + ProductDeviceChatAuthorityRequest::Bind { + device_account_id, + peer_identity_account_id, + .. + } => { + let context = b"mds-chat-request"; + let mut payload = Vec::with_capacity(65 + context.len()); + payload.extend_from_slice(&identity_account_id); + payload.extend_from_slice(&device_account_id); + payload.push((context.len() as u8) << 2); + payload.extend_from_slice(context); + let proof = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret.as_ref()) + .hash(&payload); + let mut proof_bytes = [0; 32]; + proof_bytes.copy_from_slice(proof.as_bytes()); + let wallet_own_session_id = chat_identity_session_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let peer_own_session_id = chat_identity_session_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + let wallet_outgoing_channel_id = chat_request_channel_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let wallet_incoming_channel_id = chat_request_channel_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + Ok(HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof: proof_bytes, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + }) + } + ProductDeviceChatAuthorityRequest::Seal { plaintext, .. } => { + let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(format!( + "failed to generate Chat identity-route nonce: {error}" + )) + })?; + let encrypted = ChaCha20Poly1305::new((&*key).into()) + .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route encryption failed".to_string(), + ) + })?; + let mut combined_ciphertext = Vec::with_capacity(12 + encrypted.len()); + combined_ciphertext.extend_from_slice(&nonce); + combined_ciphertext.extend_from_slice(&encrypted); + Ok(HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + }) + } + ProductDeviceChatAuthorityRequest::Open { + combined_ciphertext, + .. + } => { + if combined_ciphertext.len() < 28 { + return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); + } + let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let plaintext = ChaCha20Poly1305::new((&*key).into()) + .decrypt( + Nonce::from_slice(&combined_ciphertext[..12]), + &combined_ciphertext[12..], + ) + .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; + Ok(HostProductDeviceChatResponse::Opened { plaintext }) + } + }; + + fn product_device_chat_aead_key( + shared_secret: &[u8; 32], + ) -> Result<[u8; 32], ProductDeviceChatAuthorityError> { + let mut key = [0; 32]; + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route HKDF failed".to_string(), + ) + })?; + Ok(key) + } + + fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, + ]; + if key[31] & 0x80 != 0 { + return false; + } + for index in (0..32).rev() { + if key[index] < FIELD_MODULUS[index] { + return true; + } + if key[index] > FIELD_MODULUS[index] { + return false; + } + } + false + } + + fn chat_identity_session_id( + shared_secret: &[u8; 32], + first_account_id: &[u8; 32], + second_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(7 + 32 + 32 + 2); + input.extend_from_slice(b"session"); + input.extend_from_slice(first_account_id); + input.extend_from_slice(second_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } + + fn chat_request_channel_id( + shared_secret: &[u8; 32], + requester_account_id: &[u8; 32], + acceptor_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(12 + 32 + 32 + 2); + input.extend_from_slice(b"chat-request"); + input.extend_from_slice(requester_account_id); + input.extend_from_slice(acceptor_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } +} + /// Build the neutral authority-session snapshot for `session`. pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { AuthoritySession::from_session_info(session, authority_session_validation_id(session)) @@ -549,3 +797,150 @@ pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec } id } + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(value: &str) -> [u8; 32] { + hex::decode(value).unwrap().try_into().unwrap() + } + + #[test] + fn product_device_bind_matches_ios_chat_v2_derivations() { + let peer_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + assert_eq!( + peer_public_key, + hex32("0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20") + ); + + let response = execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: "egui-chat.paseo".to_string(), + device_account_id: [0x44; 32], + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: peer_public_key, + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + } = response + else { + panic!("Bind must return an identity binding"); + }; + assert_eq!(identity_account_id, [0x33; 32]); + assert_eq!( + proof, + hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333") + ); + assert_eq!( + wallet_own_session_id, + hex32("460db8611d842e65414f9eea4aa74d3fe1ac2e31468d4fbebededd914be28422") + ); + assert_eq!( + peer_own_session_id, + hex32("bfb5eb8c0b959f95b3ab09bd0f8001ab80f100cf5bb617640534372ab777c5c3") + ); + assert_eq!( + wallet_outgoing_channel_id, + hex32("576f71aa7f51aa340f411c20779c35f476361d8008247db367a8ce4d7e087d70") + ); + assert_eq!( + wallet_incoming_channel_id, + hex32("19de8cf16554a8463d0f8af7ad23717f4106463af331ee33f297b7367c8fe9fa") + ); + } + + #[test] + fn product_device_seal_open_round_trip_and_authenticate() { + let identity_chat_private_key = [0x11; 32]; + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + let plaintext = b"private first-contact payload".to_vec(); + let sealed = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + mut combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let opened = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + .unwrap(); + assert_eq!(opened, HostProductDeviceChatResponse::Opened { plaintext }); + + let last = combined_ciphertext.len() - 1; + combined_ciphertext[last] ^= 1; + assert_eq!( + execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + combined_ciphertext, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + + #[test] + fn product_device_rejects_invalid_peer_keys() { + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: [0; 32], + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + + let mut noncanonical_peer_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + noncanonical_peer_key[31] |= 0x80; + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: noncanonical_peer_key, + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 51c12c999..e2509d57f 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -16,8 +16,9 @@ use truapi::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use truapi::{CallContext, CallError, Subscription, latest, v01}; use truapi_platform::{ @@ -27,10 +28,12 @@ use truapi_platform::{ use crate::runtime::authority::{ AccountAliasAuthorityRequest, CreateProofAuthorityRequest, ListRingVrfKeysAuthorityRequest, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, }; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, + product_device_chat_account_authority_error, product_device_chat_authority_error, remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, validate_vrf_transcript, vrf_call_error, @@ -347,6 +350,102 @@ impl Account for ProductRuntimeHost { .map_err(|err| CallError::Domain(HostAccountRingVrfSignError::V1(ring_vrf_sign_error(err)))) } + #[instrument(skip_all, fields(runtime.method = "account.product_device_chat"))] + async fn product_device_chat( + &self, + cx: &CallContext, + request: HostProductDeviceChatRequest, + ) -> Result> { + let HostProductDeviceChatRequest::V1(request) = request; + let product_account_id = match &request { + v01::HostProductDeviceChatRequest::Bind { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Seal { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Open { + product_account_id, .. + } => product_account_id.clone(), + }; + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "Invalid product account".to_string(), + }, + )) + })?; + if product_account_id.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "product account does not belong to the calling product".to_string(), + }, + ))); + } + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::NotConnected, + ))); + }; + if self + .identity_disclosure_authorization() + .await + .map_err(|reason| CallError::HostFailure { reason })? + != PermissionAuthorizationStatus::Authorized + { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + let authority_request = match request { + v01::HostProductDeviceChatRequest::Bind { + peer_identity_account_id, + peer_chat_public_key, + .. + } => { + let device_account_id = self + .product_account_public_key(&cx, &session, &product_account_id) + .await + .map_err(product_device_chat_account_authority_error)?; + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: self.product_id(), + device_account_id, + derivation_index: product_account_id.derivation_index.clone(), + peer_identity_account_id, + peer_chat_public_key, + } + } + v01::HostProductDeviceChatRequest::Seal { + peer_chat_public_key, + plaintext, + .. + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: self.product_id(), + peer_chat_public_key, + plaintext, + }, + v01::HostProductDeviceChatRequest::Open { + peer_chat_public_key, + combined_ciphertext, + .. + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id: self.product_id(), + peer_chat_public_key, + combined_ciphertext, + }, + }; + remote_authority_call( + &cx, + self.authority + .product_device_chat(&cx, &session, authority_request), + ) + .await + .map(HostProductDeviceChatResponse::V1) + .map_err(product_device_chat_authority_error) + } + #[instrument(skip_all, fields(runtime.method = "account.sign_vrf"))] async fn sign_vrf( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index df9453c1a..bfaaca11b 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -22,7 +22,8 @@ use super::auth_state::AuthStateMachine; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; @@ -2309,6 +2310,19 @@ impl PairingHost { .await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let private_session = self + .current_private_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + self.remote_product_device_chat(cx, &private_session, request) + .await + } + async fn allocate_resources( &self, cx: &CallContext, @@ -2557,6 +2571,15 @@ impl ProductAuthority for PairingHost { PairingHost::ring_vrf_sign(self, cx, session, request).await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + PairingHost::product_device_chat(self, cx, session, request).await + } + async fn allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index a19a7084b..d825279f0 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -3,7 +3,8 @@ use super::super::authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, }; @@ -18,12 +19,13 @@ use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RingVrfError, - SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, - alias_request_message, build_outgoing_request_statement, create_transaction_legacy_message, - create_transaction_message, decode_sso_session_statement, list_ring_vrf_keys_message, - product_subtree_request_message, proof_request_message, register_ring_vrf_key_message, - resource_allocation_message, ring_vrf_sign_message, sign_payload_message, - sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, + SsoAllocatedResource, SsoAllocationOutcome, SsoProductDeviceChatOperation, SsoRemoteResponse, + SsoSessionStatement, alias_request_message, build_outgoing_request_statement, + create_transaction_legacy_message, create_transaction_message, decode_sso_session_statement, + list_ring_vrf_keys_message, product_device_chat_message, product_subtree_request_message, + proof_request_message, register_ring_vrf_key_message, resource_allocation_message, + ring_vrf_sign_message, sign_payload_message, sign_raw_legacy_message, sign_raw_message, + sign_vrf_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -67,6 +69,8 @@ enum RemoteAction { ResourceAllocation, #[display("product-subtree")] ProductSubtree, + #[display("product-device-chat")] + ProductDeviceChat, } /// Active peer-disconnect watcher for one SSO session; aborts on drop. @@ -631,6 +635,87 @@ impl PairingHost { response.payload } + /// Forward a product-device Chat v2 operation without exposing wallet key material. + pub(super) async fn remote_product_device_chat( + &self, + cx: &CallContext, + session: &SessionInfo, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let (calling_product_id, operation) = match request { + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + .. + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + }, + ), + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + plaintext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + plaintext, + }, + ), + ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + combined_ciphertext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + combined_ciphertext, + }, + ), + }; + let message_id = sso_message_id(); + let message = product_device_chat_message(message_id, calling_product_id, operation); + let response = self + .submit_remote_message(cx, session, RemoteAction::ProductDeviceChat, message) + .await + .map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable( + remote_authority_error(error).to_string(), + ) + })?; + let response_kind = response.kind(); + let SsoRemoteResponse::ProductDeviceChat(response) = response else { + return Err(ProductDeviceChatAuthorityError::Unavailable( + unexpected_response_reason( + "Unexpected SSO response for product-device Chat request", + response_kind, + ), + )); + }; + response.payload.map_err(|error| match error { + v01::HostProductDeviceChatError::NotConnected => { + ProductDeviceChatAuthorityError::Disconnected + } + v01::HostProductDeviceChatError::Rejected => ProductDeviceChatAuthorityError::Rejected, + v01::HostProductDeviceChatError::InvalidPeerKey => { + ProductDeviceChatAuthorityError::InvalidPeerKey + } + v01::HostProductDeviceChatError::InvalidCiphertext => { + ProductDeviceChatAuthorityError::InvalidCiphertext + } + v01::HostProductDeviceChatError::Unknown { reason } => { + ProductDeviceChatAuthorityError::Unavailable(reason) + } + }) + } + /// Ask the paired signing host to allocate product resources, caching any /// returned allowance keys. pub(super) async fn remote_allocate_resources( @@ -902,6 +987,7 @@ impl PairingHost { .await?; } SsoAllocatedResource::SmartContractAllowance => {} + SsoAllocatedResource::ProductStatementStoreAllowance => {} SsoAllocatedResource::AutoSigning { product_root_private_key, ring_vrf_domain_entropy, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c61543307..dac7f1f7d 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -36,9 +36,10 @@ pub(crate) use sso_responder::{ use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, authority_session_validation_id, + StatementStoreAllowanceKey, authority_session_validation_id, execute_product_device_chat, }; use super::ring_vrf_registry::RingVrfRegistryStore; use super::{RuntimeServices, connected_session_ui_info, validate_vrf_transcript}; @@ -991,6 +992,28 @@ impl ProductAuthority for SigningHost { sign_from_entropy(&entropy, &request.message) } + async fn product_device_chat( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + self.require_current_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + let entropy = self + .root_entropy() + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let (identity, identity_chat_private_key) = + sso_responder::derive_responder_identity(&entropy, self.network_suffix()) + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let identity_chat_private_key = Zeroizing::new(identity_chat_private_key); + execute_product_device_chat( + &identity_chat_private_key, + identity.statement_public_key, + request, + ) + } + async fn allocate_resources( &self, _cx: &CallContext, @@ -1037,6 +1060,17 @@ impl ProductAuthority for SigningHost { .grant_auto_signing(session, &product_id) .map(|_| v01::AllocationOutcome::Allocated) .map_err(sso_responder::AllowanceAllocationError::Authority), + v01::AllocatableResource::ProductStatementStoreAllowance(index) => { + sso_responder::allocate_product_statement_store_allowance( + &self.services, + self, + &product_id, + &index, + OnExistingAllowancePolicy::Increase, + ) + .await + .map(|()| v01::AllocationOutcome::Allocated) + } }; match outcome { Ok(outcome) => outcomes.push(outcome), diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 393232f7a..266e3ec7f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -19,15 +19,18 @@ use parity_scale_codec::Encode; use tracing::{debug, instrument, warn}; use truapi::{CallContext, latest as api, v01}; use truapi_platform::{ - CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, + CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, + SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, }; use super::SigningHost; +#[cfg(not(target_arch = "wasm32"))] +use super::allowance_renewal::StatementRenewalTarget; use super::sso_replay::{ReplayExecution, SsoReplayScope, execute_once}; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; +use crate::host_logic::permissions::PermissionsService; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::derive_sr25519_hard_path; use crate::host_logic::product_account::{ @@ -55,6 +58,7 @@ use crate::host_logic::statement_store::{ use crate::runtime::authority::{ AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, ListRingVrfKeysAuthorityRequest, ProductAuthority, + ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; @@ -76,7 +80,7 @@ const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::fr /// Upper bound on undecodable request ids acknowledged within one serve loop. const MAX_DECODE_FAILURE_REQUEST_IDS: usize = 1024; -fn derive_responder_identity( +pub(super) fn derive_responder_identity( entropy: &[u8], network_suffix: &str, ) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { @@ -838,6 +842,14 @@ pub(crate) async fn answer_remote_message( payload: answer.payload, }) } + v1::RemoteMessage::ProductDeviceChatRequest(request) => { + let cx = CallContext::with_request_id(message_id.clone()); + let payload = product_device_chat_response(services, signing_host, &cx, request).await; + v1::RemoteMessage::ProductDeviceChatResponse(messages::ProductDeviceChatResponse { + responding_to: message_id, + payload, + }) + } v1::RemoteMessage::CreateTransactionRequest(request) => { let CreateTransactionPayload::V1(payload) = request.payload; let signed_transaction = create_transaction_response( @@ -908,7 +920,8 @@ pub(crate) async fn answer_remote_message( | v1::RemoteMessage::CreateTransactionResponse(_) | v1::RemoteMessage::SignRawLegacyResponse(_) | v1::RemoteMessage::ProductSubtreeResponse(_) - | v1::RemoteMessage::SignVrfResponse(_) => return None, + | v1::RemoteMessage::SignVrfResponse(_) + | v1::RemoteMessage::ProductDeviceChatResponse(_) => return None, }; Some(AnsweredRemoteMessage { response: RemoteMessage { @@ -918,6 +931,103 @@ pub(crate) async fn answer_remote_message( response_result, }) } +async fn authorize_identity_disclosure( + services: &Arc, + product_id: &str, +) -> Result { + let service = PermissionsService::new( + services.platform.as_ref(), + services.platform.as_ref(), + product_id, + ); + let status = service + .check_or_prompt_identity_disclosure() + .await + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.reason, + })?; + Ok(status == PermissionAuthorizationStatus::Authorized) +} + +async fn product_device_chat_response( + services: &Arc, + signing_host: &Arc, + cx: &CallContext, + request: messages::ProductDeviceChatRequest, +) -> Result { + let calling_product_id = + normalize_product_identifier(&request.calling_product_id).map_err(|_| { + v01::HostProductDeviceChatError::Unknown { + reason: "invalid calling product identifier".to_string(), + } + })?; + if !authorize_identity_disclosure(services, &calling_product_id).await? { + return Err(v01::HostProductDeviceChatError::Rejected); + } + let authority_request = match request.operation { + messages::SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } => { + let product_account = v01::ProductAccountId { + dot_ns_identifier: calling_product_id.clone(), + derivation_index: derivation_index.clone(), + }; + let device_account_id = signing_host + .product_keypair(&product_account) + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + })? + .public + .to_bytes(); + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + device_account_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } + } + messages::SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + plaintext, + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + plaintext, + }, + messages::SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + combined_ciphertext, + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + combined_ciphertext, + }, + }; + let session = signing_host + .current_session() + .ok_or(v01::HostProductDeviceChatError::NotConnected)?; + signing_host + .product_device_chat(cx, &session, authority_request) + .await + .map_err(|error| match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }) +} async fn resource_allocation_response( services: &Arc, @@ -1008,6 +1118,21 @@ async fn resource_allocation_response( }, )) })(), + SsoAllocatableResource::ProductStatementStoreAllowance(index) => { + allocate_product_statement_store_allowance( + services, + signing_host, + &request.calling_product_id, + &index, + request.on_existing, + ) + .await + .map(|()| { + SsoAllocationOutcome::Allocated( + SsoAllocatedResource::ProductStatementStoreAllowance, + ) + }) + } }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -1035,6 +1160,9 @@ fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::Alloca api::AllocatableResource::SmartContractAllowance(index.clone()) } SsoAllocatableResource::AutoSigning => api::AllocatableResource::AutoSigning, + SsoAllocatableResource::ProductStatementStoreAllowance(index) => { + api::AllocatableResource::ProductStatementStoreAllowance(index.clone()) + } } } @@ -1045,16 +1173,67 @@ pub(super) async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { - use super::allowance_renewal::{self, StatementRenewalTarget}; + let entropy = signing_host.root_entropy()?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; + register_statement_store_target( + services, + signing_host, + product_id, + allowance.public.to_bytes(), + policy, + StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + }, + ) + .await?; + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_product_statement_store_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + derivation_index: &v01::DerivationIndex, + policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { + let target = signing_host + .product_keypair(&v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: derivation_index.clone(), + })? + .public + .to_bytes(); + register_statement_store_target( + services, + signing_host, + product_id, + target, + policy, + StatementRenewalTarget::Account { + account_id: target, + label: format!("product-account:{product_id}"), + }, + ) + .await +} + +#[cfg(not(target_arch = "wasm32"))] +async fn register_statement_store_target( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + target: [u8; 32], + policy: OnExistingAllowancePolicy, + renewal_target: StatementRenewalTarget, +) -> Result<(), AllowanceAllocationError> { + use super::allowance_renewal; use crate::runtime::statement_allowance::{ self, PooledRegistrationParams, allocated_in, find_including_rings, register_statement_account_pooled, scan_collections, }; - let entropy = signing_host.root_entropy()?; - let allowance = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; - let target = allowance.public.to_bytes(); let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; @@ -1069,15 +1248,7 @@ pub(super) async fn allocate_statement_store_allowance( let period = statement_allowance::slot::current_period(current_unix_secs()?); let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); - // Held from the scan through the submission, not just around the submission: - // the scan is what picks the free slot, so a renewal pass scanning in the gap - // would choose the same one. Released on the early return below, which - // submits nothing. let _registration = signing_host.renewal.registration_lock().lock().await; - - // One read of the period's slot tables, reused below rather than rescanned: - // when an allowance is already recorded on chain neither a proof nor a - // submission is needed, and a ring snapshot pages in every member key. let scans = scan_collections( rpc, &chain.metadata, @@ -1096,72 +1267,59 @@ pub(super) async fn allocate_statement_store_allowance( %collection, "statement-store allowance already allocated" ); - return Ok(allowance.secret.to_bytes().to_vec()); - } - - // Every ring back to index 0, because a membership that stopped being - // re-included still proves against the ring that holds it. - let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; - if memberships.is_empty() { - return Err(AllowanceAllocationError::MissingPersonhoodMembership { - resource: "statement-store", - }); - } - let outcome = register_statement_account_pooled( - rpc, - &chain.metadata, - &chain.state, - &scans, - &memberships, - PooledRegistrationParams { - target: &target, - period, - network_suffix: &network_suffix, - reuse_existing, - // Connecting a product must not revoke another product's allowance. - // A full period is reported as exhaustion; reclaiming space is the - // renewal pass's job, which only ever replaces for its own ledger. - allow_eviction: false, - protected: &[], - }, - ) - .await?; - match outcome { - statement_allowance::RegistrationOutcome::Registered { - block_hash, - seq, - ring_index, - collection, - } => { - debug!( - %product_id, - %block_hash, - seq, - ring_index, - %collection, - "registered statement-store allowance" - ); + } else { + let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; + if memberships.is_empty() { + return Err(AllowanceAllocationError::MissingPersonhoodMembership { + resource: "statement-store", + }); } - statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { - debug!( - %product_id, + let outcome = register_statement_account_pooled( + rpc, + &chain.metadata, + &chain.state, + &scans, + &memberships, + PooledRegistrationParams { + target: &target, + period, + network_suffix: &network_suffix, + reuse_existing, + allow_eviction: false, + protected: &[], + }, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, seq, - %collection, - "statement-store allowance already allocated" - ); + ring_index, + collection, + } => { + debug!( + %product_id, + %block_hash, + seq, + ring_index, + %collection, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { + debug!( + %product_id, + seq, + %collection, + "statement-store allowance already allocated" + ); + } } } - if let Err(reason) = allowance_renewal::track( - signing_host, - vec![StatementRenewalTarget::ProductStatementAllowance { - product_id: product_id.to_string(), - }], - ) - .await - { + if let Err(reason) = allowance_renewal::track(signing_host, vec![renewal_target]).await { warn!(%product_id, %reason, "failed to record statement-store renewal target"); } - Ok(allowance.secret.to_bytes().to_vec()) + Ok(()) } #[cfg(not(target_arch = "wasm32"))] @@ -1284,6 +1442,19 @@ pub(super) async fn allocate_statement_store_allowance( }) } +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_product_statement_store_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _derivation_index: &v01::DerivationIndex, + _policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { + Err(AllowanceAllocationError::NativeOnly { + resource: "statement-store", + }) +} + /// Claim an Asset Hub PGAS allowance for the product account `derivation_index` /// selects. /// diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index d5cb2eb64..b2a564ccf 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -107,10 +107,9 @@ impl<'a> SsoPairingFlow<'a> { read_last_processed_pairing_statement(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - // Pairing success statements are retained by statement-store. Reusing a - // previous pairing identity means reusing its topic, where the only - // retained response may be the last processed success. Rotate before - // presenting QR so every explicit login waits on a fresh wallet scan. + // Pairing success statements are retained by statement-store. Persist + // only a one-way fingerprint: the statement ciphertext and the + // session's X25519 secret must never coexist at rest. if reused_identity { debug!("regenerating stored pairing device identity"); pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) @@ -332,7 +331,7 @@ async fn write_last_processed_pairing_statement( if let Err(err) = storage .write_core_storage( CoreStorageKey::LastProcessedPairingStatement, - statement.to_vec(), + pairing_statement_fingerprint(statement), ) .await { @@ -340,6 +339,14 @@ async fn write_last_processed_pairing_statement( } } +fn pairing_statement_fingerprint(statement: &[u8]) -> Vec { + blake2b_simd::Params::new() + .hash_length(32) + .hash(statement) + .as_bytes() + .to_vec() +} + #[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { if let Err(err) = storage @@ -389,7 +396,7 @@ impl PairingProgress { Ok(Self::Success(Box::new(PairingSuccess { statement: statement.to_vec(), peer_statement_account_id: verified.signer, - success: *success, + success: (*success).clone(), }))) } } @@ -526,7 +533,9 @@ fn handle_v2_pairing_result( parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; let mut pending = false; for statement in page.statements { - if last_processed_statement == Some(statement.as_slice()) { + if last_processed_statement + .is_some_and(|fingerprint| fingerprint == pairing_statement_fingerprint(&statement)) + { continue; } match PairingProgress::from_v2_statement(&statement, core_encryption_secret_key)? { @@ -1115,10 +1124,11 @@ mod tests { }, }); + let fingerprint = pairing_statement_fingerprint(&statement); let ignored = handle_v2_pairing_result( &page, bootstrap.encryption_secret_key, - Some(statement.as_slice()), + Some(fingerprint.as_slice()), ) .unwrap(); assert!(ignored.is_none()); diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 5cfb62cbe..fc8636e4f 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -401,6 +401,32 @@ fn malformed_result_subscription_start_interrupts_with_malformed_frame() { } } +#[test] +fn product_device_chat_reaches_the_account_authority() { + let core = make_core(); + let request = + account::HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "dotli.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [ + 0x0f, 0xaa, 0x68, 0x4e, 0xd2, 0x88, 0x67, 0xb9, 0x7f, 0x4a, 0x6a, 0x2d, 0xee, 0x5d, + 0xf8, 0xce, 0x97, 0x4e, 0x76, 0xb7, 0x01, 0x8e, 0x3f, 0x22, 0xa1, 0xc4, 0xcf, 0x26, + 0x78, 0x57, 0x0f, 0x20, + ], + }); + + assert_request_returns_domain_error( + &core, + "p:product-device-chat", + "account_product_device_chat", + request.encode(), + account::HostProductDeviceChatError::V1(v01::HostProductDeviceChatError::NotConnected), + ); +} + fn make_core() -> TrUApiCore { let (host_config, product) = test_runtime_config(); TrUApiCore::from_platform_with_config( diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index f3e18bcde..132668de0 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -11,8 +11,9 @@ use crate::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use crate::wire; use crate::{CallContext, CallError, Subscription}; @@ -272,6 +273,38 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Bind a product account as a Chat v2 device, or seal/open identity-route + /// payloads without exposing the wallet Chat identity secret. + /// + /// ```ts + /// const productContext = await truapi.system.getProductContext(); + /// assert(productContext.isOk(), "getProductContext failed:", productContext); + /// + /// const result = await truapi.account.productDeviceChat({ + /// tag: "Bind", + /// value: { + /// productAccountId: { + /// dotNsIdentifier: productContext.value.productId, + /// derivationIndex: { tag: "Index", value: 0 }, + /// }, + /// peerIdentityAccountId: + /// "0x5555555555555555555555555555555555555555555555555555555555555555", + /// peerChatPublicKey: + /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", + /// }, + /// }); + /// assert(result.isOk(), "productDeviceChat failed:", result); + /// console.log("Chat identity binding:", result.value); + /// ``` + #[wire(request_id = 174, sensitive)] + async fn product_device_chat( + &self, + _cx: &CallContext, + _request: HostProductDeviceChatRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + /// List non-product accounts the user owns. /// /// Current hosts do not expose non-product accounts, so the list is empty. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 6b1f43d93..62f776e38 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -168,6 +168,12 @@ pub mod latest { /// Per-resource allocation outcomes. pub type HostRequestResourceAllocationResponse = LatestOf; + /// Product-device Chat v2 identity request. + pub type HostProductDeviceChatRequest = + LatestOf; + /// Product-device Chat v2 identity result. + pub type HostProductDeviceChatResponse = + LatestOf; /// Extrinsic payload signing request for a product account. pub type HostSignPayloadRequest = LatestOf; /// Signing operation result. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index b1eabcf05..91eed9d8a 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -412,3 +412,85 @@ pub enum HostAccountSignVrfError { reason: String, }, } + +/// Product-device Chat v2 identity operation. +/// +/// The wallet Chat identity secret and derived shared key remain host-private. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatRequest { + /// Resolve the product account as a Chat device and bind it to the wallet identity. + Bind { + /// Product account becoming a Chat device. + product_account_id: ProductAccountId, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal identity-route plaintext for the peer with a host-generated nonce. + Seal { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an identity-route combined nonce/ciphertext/tag value. + Open { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, +} + +/// Result of a product-device Chat v2 identity operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatResponse { + /// Wallet identity binding and deterministic peer routes. + IdentityBinding { + /// Wallet's canonical identity account. + identity_account_id: [u8; 32], + /// Keyed proof binding the wallet identity to the product device. + proof: [u8; 32], + /// Wallet-to-peer session identifier. + wallet_own_session_id: [u8; 32], + /// Peer-to-wallet session identifier. + peer_own_session_id: [u8; 32], + /// Wallet-to-peer contact-request channel. + wallet_outgoing_channel_id: [u8; 32], + /// Peer-to-wallet contact-request channel. + wallet_incoming_channel_id: [u8; 32], + }, + /// Sealed identity-route payload. + Sealed { + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, + /// Opened identity-route payload. + Opened { + /// Authenticated plaintext. + plaintext: Vec, + }, +} + +/// Product-device Chat v2 identity failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatError { + /// No account-authority session is connected. + NotConnected, + /// The user or Host rejected the operation. + Rejected, + /// The peer X25519 public key is invalid. + InvalidPeerKey, + /// The ciphertext failed structural or authentication checks. + InvalidCiphertext, + /// The Host could not complete the operation. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/v01/resource_allocation.rs b/rust/crates/truapi/src/v01/resource_allocation.rs index d9a1ef59a..85fc3f654 100644 --- a/rust/crates/truapi/src/v01/resource_allocation.rs +++ b/rust/crates/truapi/src/v01/resource_allocation.rs @@ -22,6 +22,9 @@ pub enum AllocatableResource { SmartContractAllowance(DerivationIndex), /// Permission to sign on the product's behalf without per-call user prompts. AutoSigning, + /// Current UTC-day Statement Store allowance whose target is the product + /// account selected by this derivation index. + ProductStatementStoreAllowance(DerivationIndex), } /// Outcome of allocating a single resource (RFC 0010). diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 2c7c2a1c0..1abbfb524 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -35,4 +35,7 @@ truapi_macros::versioned_type! { pub enum HostGetUserIdRequest { V1 } pub enum HostGetUserIdResponse { V1 => v01::HostGetUserIdResponse } pub enum HostGetUserIdError { V1 => v01::HostGetUserIdError } + pub enum HostProductDeviceChatRequest { V1 => v01::HostProductDeviceChatRequest } + pub enum HostProductDeviceChatResponse { V1 => v01::HostProductDeviceChatResponse } + pub enum HostProductDeviceChatError { V1 => v01::HostProductDeviceChatError } } From da72d306aa1df9f4fa6e9528062dda27a6a3cf30 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 16:31:43 -0400 Subject: [PATCH 02/67] fix: align Chat client examples and catalog test --- rust/crates/truapi-client/src/lib.rs | 19 +++++++++++++++---- rust/crates/truapi/src/api/account.rs | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs index f40b6e57a..af46a5e67 100644 --- a/rust/crates/truapi-client/src/lib.rs +++ b/rust/crates/truapi-client/src/lib.rs @@ -457,10 +457,21 @@ mod tests { #[test] fn catalogs_partition_worker_only_chat() { - assert_eq!(APP_METHODS.len(), 66); - assert_eq!(WIDGET_METHODS.len(), 66); - assert_eq!(WORKER_METHODS.len(), 72); - assert_eq!(WORKER_ONLY_METHODS.len(), 6); + assert_eq!(APP_METHODS, WIDGET_METHODS); + assert_eq!( + WORKER_METHODS.len(), + APP_METHODS.len() + WORKER_ONLY_METHODS.len() + ); + assert!( + APP_METHODS + .iter() + .all(|method| WORKER_METHODS.contains(method)) + ); + assert!( + WORKER_ONLY_METHODS + .iter() + .all(|method| WORKER_METHODS.contains(method) && !APP_METHODS.contains(method)) + ); assert!(WORKER_ONLY_METHODS.iter().all(|method| { method.service == "Chat" && method.required_execution == Some(ExecutionKind::Worker) })); diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 132668de0..61f892ec4 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -280,7 +280,7 @@ pub trait Account: Send + Sync { /// const productContext = await truapi.system.getProductContext(); /// assert(productContext.isOk(), "getProductContext failed:", productContext); /// - /// const result = await truapi.account.productDeviceChat({ + /// const result = await truapi.account.deviceChat({ /// tag: "Bind", /// value: { /// productAccountId: { @@ -293,7 +293,7 @@ pub trait Account: Send + Sync { /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", /// }, /// }); - /// assert(result.isOk(), "productDeviceChat failed:", result); + /// assert(result.isOk(), "deviceChat failed:", result); /// console.log("Chat identity binding:", result.value); /// ``` #[wire(request_id = 174, sensitive)] From 36b0dbbd40ff6d30ac1ac36c5e7c02de27d392e6 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 19:43:51 -0400 Subject: [PATCH 03/67] feat(chat): add explicit context-bound cipher suite --- rust/crates/truapi-client/src/generated.rs | 2 +- .../src/host_logic/sso/messages.rs | 4 + .../truapi-server/src/runtime/authority.rs | 192 ++++++++++++++++-- .../src/runtime/capabilities/account.rs | 4 + .../src/runtime/pairing_host/sso_channel.rs | 4 + .../src/runtime/signing_host/sso_responder.rs | 4 + rust/crates/truapi/src/v01/account.rs | 21 ++ 7 files changed, 217 insertions(+), 14 deletions(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index d2aa0a997..4cc6d384c 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index fbdc30aab..b1c5ec6c7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -656,6 +656,8 @@ pub enum SsoProductDeviceChatOperation { Seal { /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, /// Identity-route plaintext. plaintext: Vec, }, @@ -663,6 +665,8 @@ pub enum SsoProductDeviceChatOperation { Open { /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 0dd8a55e3..ee6e5d4d8 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -15,7 +15,10 @@ use truapi::latest::{ HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; -use truapi::v01::{HostAccountSignVrfRequest, HostProductDeviceChatResponse, VrfSignature}; +use truapi::v01::{ + HostAccountSignVrfRequest, HostProductDeviceChatCipherSuite, HostProductDeviceChatResponse, + VrfSignature, +}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -298,11 +301,13 @@ pub(crate) enum ProductDeviceChatAuthorityRequest { Seal { calling_product_id: String, peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, plaintext: Vec, }, Open { calling_product_id: String, peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, combined_ciphertext: Vec, }, } @@ -566,7 +571,7 @@ pub(super) fn execute_product_device_chat( identity_account_id: [u8; 32], request: ProductDeviceChatAuthorityRequest, ) -> Result { - use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use hkdf::Hkdf; use sha2::Sha256; @@ -646,8 +651,20 @@ pub(super) fn execute_product_device_chat( wallet_incoming_channel_id, }) } - ProductDeviceChatAuthorityRequest::Seal { plaintext, .. } => { - let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + cipher_suite, + plaintext, + .. + } => { + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + true, + )?; + let key = Zeroizing::new(key); let mut nonce = [0; 12]; getrandom::getrandom(&mut nonce).map_err(|error| { ProductDeviceChatAuthorityError::Unavailable(format!( @@ -655,7 +672,13 @@ pub(super) fn execute_product_device_chat( )) })?; let encrypted = ChaCha20Poly1305::new((&*key).into()) - .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) .map_err(|_| { ProductDeviceChatAuthorityError::Unavailable( "Chat identity-route encryption failed".to_string(), @@ -669,35 +692,85 @@ pub(super) fn execute_product_device_chat( }) } ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + cipher_suite, combined_ciphertext, .. } => { if combined_ciphertext.len() < 28 { return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); } - let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + false, + )?; + let key = Zeroizing::new(key); let plaintext = ChaCha20Poly1305::new((&*key).into()) .decrypt( Nonce::from_slice(&combined_ciphertext[..12]), - &combined_ciphertext[12..], + Payload { + msg: &combined_ciphertext[12..], + aad: &aad, + }, ) .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; Ok(HostProductDeviceChatResponse::Opened { plaintext }) } }; - fn product_device_chat_aead_key( + fn product_device_chat_aead_material( shared_secret: &[u8; 32], - ) -> Result<[u8; 32], ProductDeviceChatAuthorityError> { + calling_product_id: &str, + identity_account_id: &[u8; 32], + cipher_suite: &HostProductDeviceChatCipherSuite, + sealing: bool, + ) -> Result<([u8; 32], Vec), ProductDeviceChatAuthorityError> { let mut key = [0; 32]; - Hkdf::::new(Some(&[]), shared_secret) - .expand(&[], &mut key) + let HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id, + channel_id, + } = cipher_suite + else { + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route HKDF failed".to_string(), + ) + })?; + return Ok((key, Vec::new())); + }; + let product_id_len = u32::try_from(calling_product_id.len()).map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat product identifier is too long".to_string(), + ) + })?; + let (sender_account_id, recipient_account_id) = if sealing { + (identity_account_id, peer_account_id) + } else { + (peer_account_id, identity_account_id) + }; + let domain = b"dotli-chat/context-bound/v1"; + let mut aad = Vec::with_capacity( + domain.len() + 4 + calling_product_id.len() + 32 + 32 + channel_id.len(), + ); + aad.extend_from_slice(domain); + aad.extend_from_slice(&product_id_len.to_le_bytes()); + aad.extend_from_slice(calling_product_id.as_bytes()); + aad.extend_from_slice(sender_account_id); + aad.extend_from_slice(recipient_account_id); + aad.extend_from_slice(channel_id); + Hkdf::::new(Some(domain), shared_secret) + .expand(&aad, &mut key) .map_err(|_| { ProductDeviceChatAuthorityError::Unavailable( - "Chat identity-route HKDF failed".to_string(), + "context-bound Chat identity-route HKDF failed".to_string(), ) })?; - Ok(key) + Ok((key, aad)) } fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { @@ -873,6 +946,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: plaintext.clone(), }, ) @@ -890,6 +964,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Open { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, combined_ciphertext: combined_ciphertext.clone(), }, ) @@ -905,6 +980,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Open { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, combined_ciphertext, }, ), @@ -912,6 +988,94 @@ mod tests { ); } + #[test] + fn context_bound_product_device_chat_rejects_downgrade_and_wrong_context() { + let sender_private_key = [0x11; 32]; + let recipient_private_key = [0x22; 32]; + let sender_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(sender_private_key)) + .to_bytes(); + let recipient_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(recipient_private_key)) + .to_bytes(); + let sender_account_id = [0x33; 32]; + let recipient_account_id = [0x44; 32]; + let channel_id = [0x55; 32]; + let plaintext = b"context-bound identity payload".to_vec(); + let sealed = execute_product_device_chat( + &sender_private_key, + sender_account_id, + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: recipient_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: recipient_account_id, + channel_id, + }, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let open = |calling_product_id: &str, cipher_suite: HostProductDeviceChatCipherSuite| { + execute_product_device_chat( + &recipient_private_key, + recipient_account_id, + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: calling_product_id.to_string(), + peer_chat_public_key: sender_public_key, + cipher_suite, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + }; + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Ok(HostProductDeviceChatResponse::Opened { + plaintext: plaintext.clone(), + }) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::LegacyV2 + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id: [0x56; 32], + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.westend", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + #[test] fn product_device_rejects_invalid_peer_keys() { assert_eq!( @@ -921,6 +1085,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key: [0; 32], + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: Vec::new(), }, ), @@ -937,6 +1102,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key: noncanonical_peer_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: Vec::new(), }, ), diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index e2509d57f..904e2e6a2 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -419,20 +419,24 @@ impl Account for ProductRuntimeHost { } v01::HostProductDeviceChatRequest::Seal { peer_chat_public_key, + cipher_suite, plaintext, .. } => ProductDeviceChatAuthorityRequest::Seal { calling_product_id: self.product_id(), peer_chat_public_key, + cipher_suite, plaintext, }, v01::HostProductDeviceChatRequest::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, .. } => ProductDeviceChatAuthorityRequest::Open { calling_product_id: self.product_id(), peer_chat_public_key, + cipher_suite, combined_ciphertext, }, }; diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index d825279f0..62b9e8db0 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -660,22 +660,26 @@ impl PairingHost { ProductDeviceChatAuthorityRequest::Seal { calling_product_id, peer_chat_public_key, + cipher_suite, plaintext, } => ( calling_product_id, SsoProductDeviceChatOperation::Seal { peer_chat_public_key, + cipher_suite, plaintext, }, ), ProductDeviceChatAuthorityRequest::Open { calling_product_id, peer_chat_public_key, + cipher_suite, combined_ciphertext, } => ( calling_product_id, SsoProductDeviceChatOperation::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, }, ), diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 266e3ec7f..d99e23ebc 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -991,18 +991,22 @@ async fn product_device_chat_response( } messages::SsoProductDeviceChatOperation::Seal { peer_chat_public_key, + cipher_suite, plaintext, } => ProductDeviceChatAuthorityRequest::Seal { calling_product_id, peer_chat_public_key, + cipher_suite, plaintext, }, messages::SsoProductDeviceChatOperation::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, } => ProductDeviceChatAuthorityRequest::Open { calling_product_id, peer_chat_public_key, + cipher_suite, combined_ciphertext, }, }; diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 91eed9d8a..18ac9c490 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -413,6 +413,23 @@ pub enum HostAccountSignVrfError { }, } +/// Cipher suite used by product-device Chat identity-route operations. +/// +/// Legacy v2 preserves current mobile interoperability. Context-bound v1 +/// authenticates the product/network, both account roles, route, and direction. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatCipherSuite { + /// Existing Chat v2 CryptoKit-compatible empty-context HKDF and AEAD. + LegacyV2, + /// Domain-separated encryption for peers that explicitly support it. + ContextBoundV1 { + /// Peer account corresponding to `peer_chat_public_key`. + peer_account_id: [u8; 32], + /// Statement channel carrying the ciphertext. + channel_id: [u8; 32], + }, +} + /// Product-device Chat v2 identity operation. /// /// The wallet Chat identity secret and derived shared key remain host-private. @@ -433,6 +450,8 @@ pub enum HostProductDeviceChatRequest { product_account_id: ProductAccountId, /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; secure callers must never silently downgrade. + cipher_suite: HostProductDeviceChatCipherSuite, /// Identity-route plaintext. plaintext: Vec, }, @@ -442,6 +461,8 @@ pub enum HostProductDeviceChatRequest { product_account_id: ProductAccountId, /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; must match the sender's selected suite. + cipher_suite: HostProductDeviceChatCipherSuite, /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, From 035f12e180e8bce421a2be21b42ef96c55b20ed2 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 20:10:25 -0400 Subject: [PATCH 04/67] fix(codegen): update Chat wire schema golden --- rust/crates/truapi-codegen/tests/golden/wire_table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index e96191a49..46500bd97 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { From 4ccb1140ea3091fae8863698558e877ed84d3d13 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 10 Sep 2026 13:42:25 -0400 Subject: [PATCH 05/67] feat: add dedicated Chat authority permission --- .../Permissions/Model/ProductPermission.swift | 9 +- .../Localization/Products.xcstrings | 51 ++++++++++ .../ProductPermissionPromptViewFactory.swift | 8 ++ .../TrUAPIReviewPromptMapper.swift | 8 ++ .../TrUAPI/TrUAPIConfirmationPresenter.swift | 4 + .../AppPermissionsViewModelFactory.swift | 5 + .../TrUAPIReviewPromptMapperTests.swift | 12 +++ rust/crates/truapi-host-cli/src/platform.rs | 7 ++ rust/crates/truapi-platform/src/lib.rs | 26 ++++++ .../src/host_logic/permissions.rs | 92 +++++++++++++++++-- .../src/host_logic/sso/messages.rs | 29 +++--- rust/crates/truapi-server/src/native.rs | 1 + .../src/runtime/signing_host/sso_service.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 12 ++- 14 files changed, 243 insertions(+), 23 deletions(-) diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index 59d47e04a..0c38bf7a5 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -13,6 +13,7 @@ public enum ProductPermission: Equatable, Sendable { public static let balanceAccessTypeName = "balance_access" public static let statementSubmitAccessTypeName = "statement_submit" public static let userIdentityAccessTypeName = "user_identity_access" + public static let chatAuthorityTypeName = "chat_authority" case deviceCapability(DeviceCapabilityType) case networkAccess(domain: String) @@ -23,6 +24,7 @@ public enum ProductPermission: Equatable, Sendable { case preimageSubmitAccess case statementSubmitAccess case userIdentityAccess + case chatAuthority public var typeName: String { switch self { @@ -44,6 +46,8 @@ public enum ProductPermission: Equatable, Sendable { Self.statementSubmitAccessTypeName case .userIdentityAccess: Self.userIdentityAccessTypeName + case .chatAuthority: + Self.chatAuthorityTypeName } } @@ -60,7 +64,8 @@ public enum ProductPermission: Equatable, Sendable { .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: "" } } @@ -88,6 +93,8 @@ public enum ProductPermission: Equatable, Sendable { return .statementSubmitAccess case userIdentityAccessTypeName: return .userIdentityAccess + case chatAuthorityTypeName: + return .chatAuthority default: return nil } diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 5239e4677..0c7baf1f0 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -341,6 +341,23 @@ } } }, + "app.permission.chatAuthority.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Autoridad de identidad de Chat" + } + } + } + }, "app.permission.userIdentity.title": { "extractionState": "manual", "localizations": { @@ -647,6 +664,23 @@ } } }, + "permission.body.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allows this product to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data." + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Permite que este producto vincule su cuenta de dispositivo a la identidad de Chat de tu cartera y cifre o descifre los datos de enrutamiento de Chat." + } + } + } + }, "permission.body.userIdentityAccess": { "extractionState": "manual", "localizations": { @@ -1004,6 +1038,23 @@ } } }, + "permission.title.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ would like to use your Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ quiere usar tu autoridad de identidad de Chat" + } + } + } + }, "permission.title.deviceCapability": { "extractionState": "manual", "localizations": { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index 78451b066..c066e4a50 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -146,6 +146,12 @@ private extension ProductPermissionPromptViewFactory { body: String(localized: .Products.permissionBodyUserIdentityAccess), icon: makeIcon(systemName: "person.text.rectangle") ) + case .chatAuthority: + PromptContent( + title: String(localized: .Products.permissionTitleChatAuthority(productId: productId)), + body: String(localized: .Products.permissionBodyChatAuthority), + icon: makeIcon(systemName: "message.badge.shield") + ) } } @@ -188,6 +194,8 @@ private extension ProductPermissionPromptViewFactory { ) case .userIdentityAccess: "- " + String(localized: .Products.permissionBodyUserIdentityAccess) + case .chatAuthority: + "- " + String(localized: .Products.permissionBodyChatAuthority) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift index 7f021a447..8541df792 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift @@ -20,6 +20,7 @@ struct TrUAPIAllowanceRequest: Equatable { /// and the statement-sign prompt. protocol TrUAPIReviewPromptMapping: Sendable { func makePermissionRequest(from review: IdentityDisclosureReview) -> TrUAPIPermissionRequest + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: PreimageSubmitReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: AccountAccessReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: ProductSubtreeReview) -> TrUAPIPermissionRequest @@ -40,6 +41,13 @@ struct TrUAPIReviewPromptMapper: TrUAPIReviewPromptMapping { ) } + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest { + TrUAPIPermissionRequest( + productId: review.productId, + permissions: [.chatAuthority] + ) + } + /// `PreimageSubmitReview` carries no product identity: the submit is /// host-mediated, so the prompt is raised without a product scope. func makePermissionRequest(from _: PreimageSubmitReview) -> TrUAPIPermissionRequest { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index 40d3fc8a9..a242ace80 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -56,6 +56,10 @@ private extension TrUAPIConfirmationPresenter { await confirmPermission( promptMapper.makePermissionRequest(from: identityReview) ) + case let .chatAuthority(chatReview): + await confirmPermission( + promptMapper.makePermissionRequest(from: chatReview) + ) case let .preimageSubmit(preimageReview): await confirmPermission( promptMapper.makePermissionRequest(from: preimageReview) diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index f0f736b4c..8eee2167c 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -82,6 +82,11 @@ private extension AppPermissionsViewModelFactory { String(localized: .Products.appPermissionUserIdentityTitle), String(localized: .Products.permissionBodyUserIdentityAccess) ) + case .chatAuthority: + ( + String(localized: .Products.appPermissionChatAuthorityTitle), + String(localized: .Products.permissionBodyChatAuthority) + ) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift index 554ec617a..773ba2b94 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift @@ -21,6 +21,18 @@ struct TrUAPIReviewPromptMapperTests { )) } + @Test + func mapsChatAuthorityToDedicatedPermission() { + let request = mapper.makePermissionRequest( + from: ChatAuthorityReview(productId: "chat.dot") + ) + + #expect(request == TrUAPIPermissionRequest( + productId: "chat.dot", + permissions: [.chatAuthority] + )) + } + @Test func mapsPreimageSubmitToHostProductPermission() { let request = mapper.makePermissionRequest(from: PreimageSubmitReview(size: 1_024)) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index facad2333..ed9435bd1 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -867,6 +867,13 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { review.product_id ), ), + UserConfirmationReview::ChatAuthority(review) => ( + "use Chat identity authority", + format!( + "Product {} requested permission to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data.", + review.product_id + ), + ), } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 170cbaf4e..9f618043e 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1034,6 +1034,9 @@ pub enum PermissionAuthorizationRequest { /// Product whose account context may be accessed. target_product_id: String, }, + /// Product-scoped permission to bind and use wallet-held Chat identity authority. + #[codec(index = 4)] + ChatAuthority, } /// Authorization status for a permission request. @@ -1413,6 +1416,14 @@ impl CoreStorageKey { }, } } + + /// Persisted authorization key for wallet-held Chat identity authority. + pub fn chat_authority_authorization(product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::ChatAuthority, + } + } } /// Canonical storage form for one remote-access domain pattern. @@ -2329,6 +2340,8 @@ mod tests { let account_access = CoreStorageKey::account_access_authorization("product.dot", "target.dot"); let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); + let chat_authority = CoreStorageKey::chat_authority_authorization("product.dot"); + let other_product_chat = CoreStorageKey::chat_authority_authorization("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); @@ -2337,6 +2350,9 @@ mod tests { assert_ne!(identity, other_product_identity); assert_ne!(account_access, other_target); assert_ne!(account_access, camera); + assert_ne!(chat_authority, identity); + assert_ne!(chat_authority, account_access); + assert_ne!(chat_authority, other_product_chat); } #[test] @@ -2734,6 +2750,14 @@ pub struct IdentityDisclosureReview { pub product_id: String, } +/// Review shown before a product binds or uses wallet-held Chat identity authority. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChatAuthorityReview { + /// Product requesting the Chat identity operation. + pub product_id: String, +} + /// Review shown before a product resolves its own account subtree over SSO, /// when the value is not cached and the core must ask the Account Holder. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -2780,6 +2804,8 @@ pub enum UserConfirmationReview { SignVrf(SignVrfReview), /// Resolve a product's own account subtree over SSO. ProductSubtree(ProductSubtreeReview), + /// Allow a product to bind and use wallet-held Chat identity authority. + ChatAuthority(ChatAuthorityReview), } /// Local user confirmation UI for sensitive core-owned operations. diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index 640a6b9de..ff5174d28 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -33,7 +33,7 @@ //! authorized for every remote permission while nothing is stored, and never //! reaches the prompt callback. A stored decision still wins, so a denial //! written through the admin surface revokes the grant. Device permissions, -//! identity disclosure and account access are never covered. +//! identity disclosure, account access, and Chat authority are never covered. use parity_scale_codec::{Decode, Encode}; @@ -42,10 +42,10 @@ use truapi::latest::{ RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ - CoreStorage, CoreStorageKey, DevicePermissionStatus, IdentityDisclosureReview, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionStatusHost, - Permissions, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, - remote_domain_candidates, + ChatAuthorityReview, CoreStorage, CoreStorageKey, DevicePermissionStatus, + IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + PermissionStatusHost, Permissions, UserConfirmation, UserConfirmationReview, + has_trusted_remote_permissions, remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -298,6 +298,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::ChatAuthority => { + authorization_status( + self.storage, + CoreStorageKey::chat_authority_authorization(self.product_id), + ) + .await + } } } @@ -355,6 +362,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::AccountAccess { target_product_id } => { CoreStorageKey::account_access_authorization(self.product_id, target_product_id) } + PermissionAuthorizationRequest::ChatAuthority => { + CoreStorageKey::chat_authority_authorization(self.product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -393,6 +403,38 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a Ok(status) } + /// Resolve the product's Chat authority grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_chat_authority( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::ChatAuthority; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::ChatAuthority(ChatAuthorityReview { + product_id: self.product_id.to_string(), + })) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, prompting the platform's `device_permission` callback /// and persisting the answer when the question is still open. @@ -553,6 +595,7 @@ fn status_into_stored(status: PermissionAuthorizationStatus) -> Option::response_from_message(data), + Some(response_envelope) ); } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 79b503084..a9412e012 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -2321,6 +2321,7 @@ mod tests { cases.push(PermissionAuthorizationRequest::AccountAccess { target_product_id: "other.dot".to_string(), }); + cases.push(PermissionAuthorizationRequest::ChatAuthority); for case in cases { let native = case.clone(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 89b094c6f..2b3c76319 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -511,7 +511,7 @@ impl SigningHostSsoService { &calling_product_id, ); if permissions - .check_or_prompt_identity_disclosure() + .check_or_prompt_chat_authority() .await .map_err(|error| v01::HostProductDeviceChatError::Unknown { reason: error.reason, diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 85c4a2adf..44f9e8300 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -25,7 +25,7 @@ use truapi::v01; use truapi::versioned::account::{HostAccountCreateProofRequest, HostAccountGetAliasRequest}; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, ChatAuthorityReview, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, LocaleHost, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, @@ -95,6 +95,9 @@ pub(crate) struct StubPlatform { pub(crate) identity_disclosure_confirmed: bool, pub(crate) identity_disclosure_error: Option<&'static str>, pub(crate) identity_disclosure_calls: Arc, + pub(crate) chat_authority_confirmed: bool, + pub(crate) chat_authority_error: Option<&'static str>, + pub(crate) chat_authority_reviews: Arc>>, pub(crate) sign_payload_confirmed: bool, pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, @@ -1556,6 +1559,13 @@ impl UserConfirmation for StubPlatform { self.identity_disclosure_confirmed, ) } + UserConfirmationReview::ChatAuthority(review) => { + self.chat_authority_reviews + .lock() + .expect("Chat authority review list mutex poisoned") + .push(review); + (self.chat_authority_error, self.chat_authority_confirmed) + } UserConfirmationReview::ResourceAllocation(review) => { self.resource_allocation_reviews .lock() From a0681e199bb9ed62fde953be768913fd813d4a7a Mon Sep 17 00:00:00 2001 From: w Date: Thu, 10 Sep 2026 13:53:10 -0400 Subject: [PATCH 06/67] chore: regenerate Chat authority artifacts --- CHANGELOG.md | 3 +- README.md | 2 +- rust/crates/truapi-client/src/generated.rs | 2 +- .../truapi-codegen/tests/golden/dispatcher.rs | 2 +- .../tests/golden/host-callbacks.ts | 32 +++++++++++++++++-- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b89018ca1..73545d2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Added - Add `account.productDeviceChat` for host-private Chat v2 identity binding and - identity-route sealing/opening through local or paired account authorities. + identity-route sealing/opening through local or paired account authorities, + guarded by a dedicated, product-scoped Chat-authority permission. - Generate a transport-neutral `no_std` Rust client with typed request, subscription, result-subscription, and host-initiated Worker subscription codecs. diff --git a/README.md b/README.md index d24670b19..346b2f402 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full wallet's Chat v2 identity and seals or opens identity-route payloads without exposing the wallet's X25519 private key. Browser pairing hosts forward the operation over encrypted SSO; signing hosts require the calling product's -identity-disclosure authorization before using local wallet material. +dedicated Chat-authority permission before using local wallet material. ## Repository layout diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 4cc6d384c..57967a527 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0e49a2f7d93138a3"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index f3fa5e94f..792cc84a3 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -387,7 +387,7 @@ where } }; let target_version = request.version(); - let cx = CallContext::with_request_id(request_id.clone()); + let cx = CallContext::with_request_id(request_id); let response: versioned::account::HostProductDeviceChatResponse = match host.product_device_chat(&cx, request).await { Ok(value) => value, Err(err) => { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index b9ccb6ee7..1e62c3b23 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -111,6 +111,16 @@ export type AuthState = */ | { tag: "Authenticating"; value?: undefined }; +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export interface ChatAuthorityReview { + /** + * Product requesting the Chat identity operation. + */ + productId: string; +} + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -314,7 +324,11 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to access another product's account context. */ - | { tag: "AccountAccess"; value: { targetProductId: string } }; + | { tag: "AccountAccess"; value: { targetProductId: string } } + /** + * Product-scoped permission to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value?: undefined }; /** * Authorization status for a permission request. @@ -554,7 +568,11 @@ export type UserConfirmationReview = /** * Resolve a product's own account subtree over SSO. */ - | { tag: "ProductSubtree"; value: ProductSubtreeReview }; + | { tag: "ProductSubtree"; value: ProductSubtreeReview } + /** + * Allow a product to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value: ChatAuthorityReview }; /** * Review shown before a product asks to access another product account. @@ -598,6 +616,14 @@ export const AuthState: S.Codec = S.lazy( }), ); +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export const ChatAuthorityReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ productId: S.str }) as S.Codec, +); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -737,6 +763,7 @@ export const PermissionAuthorizationRequest: S.Codec, + ChatAuthority: S._void, }), ); @@ -893,6 +920,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( AccountAccess: AccountAccessReview, SignVrf: SignVrfReview, ProductSubtree: ProductSubtreeReview, + ChatAuthority: ChatAuthorityReview, }), ); diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 85a835bc4..4d8180aad 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "43581e5572c0315a"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0e49a2f7d93138a3"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { From 43888503775e77f4f1e7dac0aae29efcbba1de79 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 11 Sep 2026 07:50:47 -0400 Subject: [PATCH 07/67] fix(chat): forward product statement proofs over SSO --- .../src/host_logic/sso/messages.rs | 58 +++++++++++++++++++ .../src/host_logic/sso/messages/v1.rs | 7 +++ .../truapi-server/src/runtime/pairing_host.rs | 15 ++--- .../src/runtime/pairing_host/sso_channel.rs | 25 +++++++- .../src/runtime/signing_host/sso_service.rs | 37 +++++++++++- 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b21e2f4a5..1ae7693a6 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -295,6 +295,22 @@ pub struct ProductSubtreeRequest { /// Account Holder response carrying a product subtree public key. pub type ProductSubtreeResponse = Result<[u8; 32], String>; +/// Exact unsigned Statement Store payload to sign with a product-derived account. +/// +/// The signing host validates the payload as canonical unsigned statement +/// fields before signing, so this cannot become a generic signing oracle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + /// Product making the request. + pub calling_product_id: String, + /// Product account that signs the statement payload. + pub account: v01::ProductAccountId, + /// Exact unsigned statement fields, without their SCALE vector prefix. + pub payload: Vec, +} + +/// Account Holder response carrying the product-account sr25519 signature. +pub type StatementStoreProductSignResponse = Result<[u8; 64], String>; /// Request sent when a product asks the signing host to create a transaction /// for a product-derived account. @@ -621,6 +637,7 @@ mod tests { use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{ StatementField, build_signed_statement, decode_statement_data, + unsigned_statement_signing_payload, }; use crate::test_support::sso_host_and_responder_sessions; use schnorrkel::{ExpansionMode, MiniSecretKey}; @@ -1030,6 +1047,47 @@ mod tests { Some(response_envelope) ); } + #[test] + fn statement_store_product_sign_messages_pin_extension_wire_indices() { + let payload = unsigned_statement_signing_payload(vec![ + StatementField::Data(vec![1, 2, 3]), + StatementField::Channel([0x44; 32]), + ]) + .unwrap(); + let request_payload = StatementStoreProductSignRequest { + calling_product_id: "egui-chat.paseo".to_string(), + account: ProductAccountId { + dot_ns_identifier: "egui-chat.paseo".to_string(), + derivation_index: DerivationIndex::Index(0), + }, + payload, + }; + let request = RemoteMessage::request("request".to_string(), request_payload); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 26); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let response_envelope = Response { + responding_to: "request".to_string(), + payload: Ok([0xAB; 64]), + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignResponse( + response_envelope.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 27); + let RemoteMessageData::V1(data) = response.data; + assert_eq!( + StatementStoreProductSignRequest::response_from_message(data), + Some(response_envelope) + ); + } #[test] fn sign_vrf_messages_match_mobile_wire_contract() { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 1df0a69e1..e316cbc90 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -18,6 +18,7 @@ use super::{ RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, Response, RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfResponse, SsoProductDeviceChatOperation, + StatementStoreProductSignRequest, StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -90,4 +91,10 @@ pub enum RemoteMessage { /// Account Holder's product-device Chat v2 response. #[codec(index = 25)] ProductDeviceChatResponse(Response), + /// Ask the Account Holder to sign an exact Statement Store product payload. + #[codec(index = 26)] + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + /// Account Holder's product-account Statement Store signature. + #[codec(index = 27)] + StatementStoreProductSignResponse(Response), } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 4bfa9015c..cf7b4d45c 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -2330,17 +2330,14 @@ impl PairingHost { async fn sign_statement_store_product_payload( &self, - _cx: &CallContext, + cx: &CallContext, session: &AuthoritySession, - _account: v01::ProductAccountId, - _payload: Vec, + account: v01::ProductAccountId, + payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.current_private_session(session)?; - Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing is not supported over the \ - current SSO raw-signing protocol" - .to_string(), - }) + let session = self.current_private_session(session)?; + self.remote_sign_statement_store_product_payload(cx, &session, account, payload) + .await } fn derive_entropy( diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 5f40a3999..8a6b7ee8b 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -19,7 +19,8 @@ use crate::host_logic::sso::messages::{ ProductSubtreeRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, RingVrfError, SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, SsoAllocationOutcome, SsoProductDeviceChatOperation, SsoSessionStatement, - build_outgoing_request_statement, decode_sso_session_statement, v1, + StatementStoreProductSignRequest, build_outgoing_request_statement, + decode_sso_session_statement, v1, }; use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::parse_new_statements_result; @@ -471,6 +472,28 @@ impl PairingHost { .await .map_err(ring_vrf_transport_error)? } + /// Forward exact Statement Store product-account signing to the Account Holder. + pub(super) async fn remote_sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let calling_product_id = account.dot_ns_identifier.clone(); + self.call( + cx, + session, + StatementStoreProductSignRequest { + calling_product_id, + account, + payload, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error) + } /// Forward a product-device Chat v2 operation without exposing wallet key material. pub(super) async fn remote_product_device_chat( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 2b3c76319..3e392f17f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -7,7 +7,8 @@ use tracing::warn; use truapi::{latest as api, v01}; use truapi_platform::{ CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, - SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, + SignPayloadReview, SignRawReview, StatementStoreProductSignReview, UserConfirmationReview, + normalize_product_identifier, }; use super::SigningHost; @@ -28,9 +29,11 @@ use crate::host_logic::sso::messages::{ RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, - SsoProductDeviceChatOperation, + SsoProductDeviceChatOperation, StatementStoreProductSignRequest, + StatementStoreProductSignResponse, }; use crate::host_logic::sso::wire::ResponseOutcome; +use crate::host_logic::statement_store::validate_unsigned_statement_signing_payload; use crate::runtime::authority::{ AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, @@ -495,6 +498,36 @@ impl SigningHostSsoService { .ring_vrf_sign(&cx.call, &cx.session, request) .await } + /// Sign a canonical unsigned Statement Store payload with a product account. + async fn statement_store_product_sign( + &self, + cx: &SsoRequestContext, + request: StatementStoreProductSignRequest, + ) -> StatementStoreProductSignResponse { + let calling_product_id = normalize_product_identifier(&request.calling_product_id) + .map_err(|_| "invalid calling product identifier".to_string())?; + let mut account = request.account; + let account_product_id = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|_| "invalid product account identifier".to_string())?; + if account_product_id != calling_product_id { + return Err("product account does not belong to the calling product".to_string()); + } + account.dot_ns_identifier = calling_product_id; + validate_unsigned_statement_signing_payload(&request.payload) + .map_err(|error| error.to_string())?; + self.confirm(UserConfirmationReview::StatementStoreProductSign( + StatementStoreProductSignReview { + account: account.clone(), + payload: request.payload.clone(), + }, + )) + .await?; + self.signing_host + .sign_statement_store_product_payload(&cx.call, &cx.session, account, request.payload) + .await + .map_err(|error| error.to_string()) + } + /// Perform a Chat identity operation without exposing wallet key material. async fn product_device_chat( &self, From e6e31b53329b34eccdc1d144632be61584fb5919 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 11 Sep 2026 22:08:18 -0400 Subject: [PATCH 08/67] fix(chat): sign first-contact proofs without message framing --- .../src/host_logic/sso/messages.rs | 7 ++ .../truapi-server/src/runtime/authority.rs | 17 +++++ .../src/runtime/capabilities/account.rs | 10 +++ .../src/runtime/pairing_host/sso_channel.rs | 11 +++ .../truapi-server/src/runtime/signing_host.rs | 73 +++++++++++++++++++ .../src/runtime/signing_host/sso_service.rs | 11 +++ rust/crates/truapi/src/v01/account.rs | 12 +++ 7 files changed, 141 insertions(+) diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 1ae7693a6..5e28b2f8e 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -366,6 +366,13 @@ pub enum SsoProductDeviceChatOperation { /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, + /// Sign a canonical Chat first-contact proof payload as the product device. + SignRequestProof { + /// Product account index to derive on the signing host. + derivation_index: v01::DerivationIndex, + /// Canonical SCALE-encoded Chat request proof payload. + payload: Vec, + }, } /// Product-device Chat v2 response returned by the Account Holder. diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 5af203df4..72deef016 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -268,6 +268,11 @@ pub(crate) enum ProductDeviceChatAuthorityRequest { cipher_suite: HostProductDeviceChatCipherSuite, combined_ciphertext: Vec, }, + SignRequestProof { + calling_product_id: String, + product_account_id: ProductAccountId, + payload: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -548,6 +553,12 @@ pub(super) fn execute_product_device_chat( peer_chat_public_key, .. } => *peer_chat_public_key, + ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { + return Err(ProductDeviceChatAuthorityError::Unavailable( + "Chat request proof signing must be handled by the product signing authority" + .to_string(), + )); + } }; if !is_canonical_x25519_public_key(&peer_public_key) { return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); @@ -676,6 +687,12 @@ pub(super) fn execute_product_device_chat( .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; Ok(HostProductDeviceChatResponse::Opened { plaintext }) } + ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { + Err(ProductDeviceChatAuthorityError::Unavailable( + "Chat request proof signing must be handled by the product signing authority" + .to_string(), + )) + } }; fn product_device_chat_aead_material( diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 27ea36326..cbea459e5 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -342,6 +342,9 @@ impl Account for ProductRuntimeHost { } | v01::HostProductDeviceChatRequest::Open { product_account_id, .. + } + | v01::HostProductDeviceChatRequest::SignRequestProof { + product_account_id, .. } => product_account_id.clone(), }; let product_account_id = @@ -415,6 +418,13 @@ impl Account for ProductRuntimeHost { cipher_suite, combined_ciphertext, }, + v01::HostProductDeviceChatRequest::SignRequestProof { payload, .. } => { + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id: self.product_id(), + product_account_id, + payload, + } + } }; remote_authority_call( &cx, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 8a6b7ee8b..b0d595bc1 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -543,6 +543,17 @@ impl PairingHost { combined_ciphertext, }, ), + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id, + product_account_id, + payload, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::SignRequestProof { + derivation_index: product_account_id.derivation_index, + payload, + }, + ), }; self.call( cx, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index cc6804922..7519ff7a7 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1013,6 +1013,28 @@ impl ProductAuthority for SigningHost { ) -> Result { self.require_current_session(session) .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + let request = match request { + ProductDeviceChatAuthorityRequest::SignRequestProof { + calling_product_id, + product_account_id, + payload, + } => { + if product_account_id.dot_ns_identifier != calling_product_id { + return Err(ProductDeviceChatAuthorityError::Unavailable( + "product account does not belong to the calling product".to_string(), + )); + } + let keypair = self.product_keypair(&product_account_id).map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(error.to_string()) + })?; + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes(); + return Ok(v01::HostProductDeviceChatResponse::RequestProofSigned { signature }); + } + request => request, + }; let entropy = self .root_entropy() .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; @@ -2659,6 +2681,57 @@ mod tests { ); } + #[test] + fn product_chat_request_proof_signs_unframed_payload() { + let (services, activation) = signing_runtime_with_platform(Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..StubPlatform::default() + })); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::default(); + let payload = b"canonical chat request proof".to_vec(); + let request = truapi::versioned::account::HostProductDeviceChatRequest::V1( + v01::HostProductDeviceChatRequest::SignRequestProof { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + payload: payload.clone(), + }, + ); + let response = futures::executor::block_on(runtime.product_device_chat(&cx, request)) + .expect("Chat request proof signing succeeds"); + let truapi::versioned::account::HostProductDeviceChatResponse::V1( + v01::HostProductDeviceChatResponse::RequestProofSigned { signature }, + ) = response + else { + panic!("unexpected Chat response"); + }; + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); + let signature = schnorrkel::Signature::from_bytes(&signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .is_ok(), + "signature verifies over the canonical unframed payload", + ); + assert!( + keypair + .public + .verify_simple( + SR25519_SIGNING_CONTEXT, + b"canonical chat request proof", + &signature, + ) + .is_err(), + "Chat proof signing never applies wallet-message framing", + ); + } + #[test] fn reactivation_invalidates_prior_session_snapshot() { let (_services, authority) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 3e392f17f..6ac5022a3 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -600,6 +600,17 @@ impl SigningHostSsoService { cipher_suite, combined_ciphertext, }, + SsoProductDeviceChatOperation::SignRequestProof { + derivation_index, + payload, + } => ProductDeviceChatAuthorityRequest::SignRequestProof { + product_account_id: api::ProductAccountId { + dot_ns_identifier: calling_product_id.clone(), + derivation_index, + }, + calling_product_id, + payload, + }, }; self.signing_host .product_device_chat(&cx.call, &cx.session, authority_request) diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index f68138309..c3e302f11 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -467,6 +467,13 @@ pub enum HostProductDeviceChatRequest { /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, + /// Sign the canonical Chat first-contact proof payload without wallet-message framing. + SignRequestProof { + /// Product account proving ownership of the Chat device. + product_account_id: ProductAccountId, + /// Canonical SCALE-encoded Chat request proof payload. + payload: Vec, + }, } /// Result of a product-device Chat v2 identity operation. @@ -497,6 +504,11 @@ pub enum HostProductDeviceChatResponse { /// Authenticated plaintext. plaintext: Vec, }, + /// Raw sr25519 signature over a canonical Chat request proof payload. + RequestProofSigned { + /// Unframed 64-byte sr25519 signature. + signature: [u8; 64], + }, } /// Product-device Chat v2 identity failure. From 7d4ca68243aade2d4034b25025da2ec93beb2abf Mon Sep 17 00:00:00 2001 From: w Date: Fri, 11 Sep 2026 22:53:10 -0400 Subject: [PATCH 09/67] fix(identity): register Chat-capable X25519 keys --- .../src/host_logic/attestation.rs | 56 +++++++------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index 7e7d23a88..9ab6ff524 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -22,6 +22,7 @@ use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_identity_keypair, derive_lite_person_ring_vrf_entropy, product_public_key_to_address, }; +use crate::host_logic::sso::pairing::{CHAT_ENCRYPTION_DOMAIN, derive_x25519_keypair_from_entropy}; /// sr25519 proof-of-ownership message prefix (exact bytes; one space). /// @@ -30,8 +31,8 @@ use crate::host_logic::product_account::{ /// /// The pallet verifies `MSG_PREFIX || candidate || ring_vrf_key`. const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; -/// Domain label for the P-256 identifier key advertised to the backend. -const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; +/// RFC-0004 type byte for an X25519 account ECDH key. +const X25519_IDENTIFIER_KEY_TYPE: u8 = 0; /// SCALE payload signed for a lite consumer registration. /// @@ -58,8 +59,8 @@ pub struct LiteRegistration { pub ring_vrf_key: [u8; 32], /// Plain bandersnatch VRF proof over the same proof message. pub proof_of_ownership: [u8; 64], - /// 65-byte uncompressed P-256 identifier key. It doubles as the dotNS chat - /// key. + /// RFC-0004 X25519 account ECDH key container: type byte, 32-byte public + /// key, then 32 bytes of zero padding. pub identifier_key: [u8; 65], /// sr25519 signature over the SCALE consumer-registration tuple. pub consumer_registration_signature: [u8; 64], @@ -77,9 +78,6 @@ pub enum LiteRegistrationError { /// Ring-VRF proof-of-ownership failed. #[error("ring-VRF proof-of-ownership failed: {0:?}")] ProofOfOwnership(VerifiableError), - /// P-256 identifier key derivation failed. - #[error("identifier key derivation failed")] - IdentifierKey, } /// Build the lite-person registration parameters for `username_base` @@ -121,7 +119,7 @@ pub fn build_lite_registration( let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) .map_err(LiteRegistrationError::ProofOfOwnership)?; - let identifier_key = derive_identifier_key(entropy)?; + let identifier_key = derive_identifier_key(entropy); let consumer_message = ConsumerRegistrationSigningPayload { account: candidate_public_key, @@ -169,32 +167,12 @@ pub fn build_lite_registration( }) } -fn derive_identifier_key(entropy: &[u8]) -> Result<[u8; 65], LiteRegistrationError> { - use p256::SecretKey; - use p256::elliptic_curve::sec1::ToEncodedPoint; - - for attempt in 0..64 { - let mut message = Vec::with_capacity(IDENTIFIER_KEY_LABEL.len() + 1); - message.extend_from_slice(IDENTIFIER_KEY_LABEL); - message.push(attempt); - let candidate: [u8; 32] = blake2b_simd::Params::new() - .hash_length(32) - .key(entropy) - .hash(&message) - .as_bytes() - .try_into() - .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed"); - let Ok(secret) = SecretKey::from_slice(&candidate) else { - continue; - }; - return Ok(secret - .public_key() - .to_encoded_point(false) - .as_bytes() - .try_into() - .expect("uncompressed P-256 public keys are exactly 65 bytes")); - } - Err(LiteRegistrationError::IdentifierKey) +fn derive_identifier_key(entropy: &[u8]) -> [u8; 65] { + let (_, public_key) = derive_x25519_keypair_from_entropy(entropy, CHAT_ENCRYPTION_DOMAIN); + let mut identifier_key = [0u8; 65]; + identifier_key[0] = X25519_IDENTIFIER_KEY_TYPE; + identifier_key[1..33].copy_from_slice(&public_key); + identifier_key } #[cfg(test)] @@ -241,7 +219,15 @@ mod tests { "a person registered on paseo-next-v2 is not the seed's .dot person" ); - assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert_eq!( + reg.identifier_key[0], X25519_IDENTIFIER_KEY_TYPE, + "RFC-0004 X25519 type" + ); + assert_eq!( + ®.identifier_key[1..33], + &derive_x25519_keypair_from_entropy(&ENTROPY, CHAT_ENCRYPTION_DOMAIN).1 + ); + assert_eq!(®.identifier_key[33..], &[0u8; 32]); assert!( reg.candidate_account_id .chars() From ccf2fc918f35a7ac35c5b0907c99ef33bf997bf6 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 13 Sep 2026 23:20:13 -0400 Subject: [PATCH 10/67] feat(chat): authenticate incoming product-device requests --- rust/crates/truapi-client/src/generated.rs | 2 +- .../src/host_logic/sso/messages.rs | 13 +++ .../truapi-server/src/runtime/authority.rs | 110 ++++++++++++++++-- .../src/runtime/capabilities/account.rs | 24 ++++ .../src/runtime/pairing_host/sso_channel.rs | 18 +++ .../src/runtime/signing_host/sso_service.rs | 15 +++ rust/crates/truapi/src/v01/account.rs | 30 +++++ 7 files changed, 201 insertions(+), 11 deletions(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 96565015f..3eab0fde7 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "bc027235799b1185"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "31bbc7e37560ad31"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 5e28b2f8e..710bb7dad 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -373,6 +373,19 @@ pub enum SsoProductDeviceChatOperation { /// Canonical SCALE-encoded Chat request proof payload. payload: Vec, }, + /// Read the authorized wallet's public Chat identity. + Identity, + /// Verify an incoming peer's identity-to-device binding. + VerifyPeerDevice { + /// Peer wallet identity account. + peer_identity_account_id: [u8; 32], + /// Peer's independently resolved X25519 Chat public key. + peer_chat_public_key: [u8; 32], + /// Device account authenticated by the signed request. + peer_device_account_id: [u8; 32], + /// Keyed identity binding from the request. + proof: [u8; 32], + }, } /// Product-device Chat v2 response returned by the Account Holder. diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 72deef016..815b07ae7 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -273,6 +273,16 @@ pub(crate) enum ProductDeviceChatAuthorityRequest { product_account_id: ProductAccountId, payload: Vec, }, + Identity { + calling_product_id: String, + }, + VerifyPeerDevice { + calling_product_id: String, + peer_identity_account_id: [u8; 32], + peer_chat_public_key: [u8; 32], + peer_device_account_id: [u8; 32], + proof: [u8; 32], + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -552,7 +562,18 @@ pub(super) fn execute_product_device_chat( | ProductDeviceChatAuthorityRequest::Open { peer_chat_public_key, .. + } + | ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + peer_chat_public_key, + .. } => *peer_chat_public_key, + ProductDeviceChatAuthorityRequest::Identity { .. } => { + return Ok(HostProductDeviceChatResponse::Identity { + identity_account_id, + chat_public_key: PublicKey::from(&StaticSecret::from(*identity_chat_private_key)) + .to_bytes(), + }); + } ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { return Err(ProductDeviceChatAuthorityError::Unavailable( "Chat request proof signing must be handled by the product signing authority" @@ -578,16 +599,11 @@ pub(super) fn execute_product_device_chat( peer_identity_account_id, .. } => { - let context = b"mds-chat-request"; - let mut payload = Vec::with_capacity(65 + context.len()); - payload.extend_from_slice(&identity_account_id); - payload.extend_from_slice(&device_account_id); - payload.push((context.len() as u8) << 2); - payload.extend_from_slice(context); - let proof = blake2b_simd::Params::new() - .hash_length(32) - .key(shared_secret.as_ref()) - .hash(&payload); + let proof = chat_device_identity_proof( + &shared_secret, + &identity_account_id, + &device_account_id, + ); let mut proof_bytes = [0; 32]; proof_bytes.copy_from_slice(proof.as_bytes()); let wallet_own_session_id = chat_identity_session_id( @@ -619,6 +635,22 @@ pub(super) fn execute_product_device_chat( wallet_incoming_channel_id, }) } + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + peer_identity_account_id, + peer_device_account_id, + proof, + .. + } => { + let expected = chat_device_identity_proof( + &shared_secret, + &peer_identity_account_id, + &peer_device_account_id, + ); + // Hash's slice comparison is constant-time for this fixed length. + Ok(HostProductDeviceChatResponse::PeerDeviceVerified { + valid: expected.eq(proof.as_slice()), + }) + } ProductDeviceChatAuthorityRequest::Seal { calling_product_id, cipher_suite, @@ -687,6 +719,9 @@ pub(super) fn execute_product_device_chat( .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; Ok(HostProductDeviceChatResponse::Opened { plaintext }) } + ProductDeviceChatAuthorityRequest::Identity { .. } => { + unreachable!("public identity returns before shared-key derivation") + } ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { Err(ProductDeviceChatAuthorityError::Unavailable( "Chat request proof signing must be handled by the product signing authority" @@ -695,6 +730,23 @@ pub(super) fn execute_product_device_chat( } }; + fn chat_device_identity_proof( + shared_secret: &[u8; 32], + identity: &[u8; 32], + device: &[u8; 32], + ) -> blake2b_simd::Hash { + const CONTEXT: &[u8] = b"mds-chat-request"; + let mut payload = [0; 65 + CONTEXT.len()]; + payload[..32].copy_from_slice(identity); + payload[32..64].copy_from_slice(device); + payload[64] = (CONTEXT.len() as u8) << 2; + payload[65..].copy_from_slice(CONTEXT); + blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&payload) + } + fn product_device_chat_aead_material( shared_secret: &[u8; 32], calling_product_id: &str, @@ -908,6 +960,44 @@ mod tests { ); } + #[test] + fn peer_device_binding_verifies_reciprocally_and_rejects_substitution() { + let sender_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x11; 32])).to_bytes(); + // Independent iOS-compatible binding vector from the sender test above. + let proof = hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333"); + let verify = |identity, device, binding| { + execute_product_device_chat( + &[0x22; 32], + [0x55; 32], + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id: "egui-chat.paseo".to_string(), + peer_identity_account_id: identity, + peer_chat_public_key: sender_key, + peer_device_account_id: device, + proof: binding, + }, + ) + .unwrap() + }; + assert_eq!( + verify([0x33; 32], [0x44; 32], proof), + HostProductDeviceChatResponse::PeerDeviceVerified { valid: true } + ); + let mut corrupted = proof; + corrupted[31] ^= 1; + for (identity, device, binding) in [ + ([0x34; 32], [0x44; 32], proof), + ([0x33; 32], [0x45; 32], proof), + ([0x33; 32], [0x44; 32], corrupted), + ] { + assert_eq!( + verify(identity, device, binding), + HostProductDeviceChatResponse::PeerDeviceVerified { valid: false } + ); + } + } + #[test] fn product_device_seal_open_round_trip_and_authenticate() { let identity_chat_private_key = [0x11; 32]; diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index cbea459e5..512f65777 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -345,6 +345,12 @@ impl Account for ProductRuntimeHost { } | v01::HostProductDeviceChatRequest::SignRequestProof { product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Identity { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::VerifyPeerDevice { + product_account_id, .. } => product_account_id.clone(), }; let product_account_id = @@ -425,6 +431,24 @@ impl Account for ProductRuntimeHost { payload, } } + v01::HostProductDeviceChatRequest::Identity { .. } => { + ProductDeviceChatAuthorityRequest::Identity { + calling_product_id: self.product_id(), + } + } + v01::HostProductDeviceChatRequest::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + .. + } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id: self.product_id(), + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, }; remote_authority_call( &cx, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index b0d595bc1..dfb6f68ca 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -554,6 +554,24 @@ impl PairingHost { payload, }, ), + ProductDeviceChatAuthorityRequest::Identity { calling_product_id } => { + (calling_product_id, SsoProductDeviceChatOperation::Identity) + } + ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id, + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, + ), }; self.call( cx, diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 6ac5022a3..268c557a2 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -611,6 +611,21 @@ impl SigningHostSsoService { calling_product_id, payload, }, + SsoProductDeviceChatOperation::Identity => { + ProductDeviceChatAuthorityRequest::Identity { calling_product_id } + } + SsoProductDeviceChatOperation::VerifyPeerDevice { + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { + calling_product_id, + peer_identity_account_id, + peer_chat_public_key, + peer_device_account_id, + proof, + }, }; self.signing_host .product_device_chat(&cx.call, &cx.session, authority_request) diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index c3e302f11..143f27366 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -474,6 +474,24 @@ pub enum HostProductDeviceChatRequest { /// Canonical SCALE-encoded Chat request proof payload. payload: Vec, }, + /// Read the authorized wallet's public Chat identity for incoming requests. + Identity { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + }, + /// Verify a peer's identity-to-device binding without exposing shared keys. + VerifyPeerDevice { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer identity whose binding is being checked. + peer_identity_account_id: [u8; 32], + /// Peer's independently resolved X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Device account authenticated by the signed contact request. + peer_device_account_id: [u8; 32], + /// Keyed identity binding carried by that request. + proof: [u8; 32], + }, } /// Result of a product-device Chat v2 identity operation. @@ -509,6 +527,18 @@ pub enum HostProductDeviceChatResponse { /// Unframed 64-byte sr25519 signature. signature: [u8; 64], }, + /// Public Chat identity of the authorized wallet. + Identity { + /// Canonical wallet identity account. + identity_account_id: [u8; 32], + /// X25519 Chat identity public key; never private key material. + chat_public_key: [u8; 32], + }, + /// Result of verifying a peer identity-to-device binding. + PeerDeviceVerified { + /// Whether the supplied binding matches the authenticated shared key. + valid: bool, + }, } /// Product-device Chat v2 identity failure. From 07a5fc8e64930eeaed4e917596f4b5a47a7d57b9 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 11:50:40 -0400 Subject: [PATCH 11/67] fix: align allowance helpers with wasm target boundaries --- CHANGELOG.md | 2 + rust/crates/truapi-server/src/lib.rs | 3 +- .../runtime/signing_host/allowance_renewal.rs | 45 ++++++++++++++++--- .../src/runtime/statement_allowance.rs | 10 ++++- .../src/runtime/statement_allowance/pgas.rs | 6 ++- .../src/runtime/statement_allowance/rpc.rs | 3 +- 6 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 404d24739..36f34254b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- keep host-backed allowance helpers available on Wasm with browser-compatible + polling clocks, while excluding the native-only renewal driver (#540) - return a decode error instead of trapping when subscription helpers receive a request descriptor diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index c99ee5188..d7ce25cd3 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -66,7 +66,8 @@ pub use native_debug::{DebugSinkError, WsDebugSink}; #[cfg(not(target_arch = "wasm32"))] pub use runtime::StatementRenewalTarget; pub use runtime::login_failure::reports_exhausted_period; -#[cfg(not(target_arch = "wasm32"))] +// These helpers use injected host RPC on both native and browser targets. +// Only the direct-URL RPC constructor is native-only. pub use runtime::statement_allowance; pub use runtime::{LocalIdentity, LocalIdentityContext}; pub use runtime::{PairedSsoPeer, ResponderExit}; diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index 4a4f47ddb..aa3888230 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -6,32 +6,50 @@ //! the active session and runs the chain-pure pass in //! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic //! tick (`start_renewal_loop`). +//! +//! All signing hosts record the ledger during allocation. The resident renewal +//! driver belongs to the native host API; browser hosts currently allocate on +//! demand without starting that driver. +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use futures::lock::Mutex; use parity_scale_codec::{Decode, Encode}; -use tracing::{debug, info, warn}; +#[cfg(not(target_arch = "wasm32"))] +use tracing::debug; +#[cfg(any(test, not(target_arch = "wasm32")))] +use tracing::info; +use tracing::warn; use truapi_platform::{CoreStorage, CoreStorageKey}; use super::SigningHost; +#[cfg(not(target_arch = "wasm32"))] use super::sso_responder::current_unix_secs; -use crate::host_logic::product_account::{ - derive_identity_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, -}; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; +#[cfg(any(test, not(target_arch = "wasm32")))] +use crate::host_logic::product_account::{derive_identity_keypair, derive_sr25519_hard_path}; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::RuntimeServices; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::authority::ProductAuthority; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::renewal::{ - RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, next_tick_delay, - renew_targets, + RenewalChainContext, next_tick_delay, renew_targets, }; +#[cfg(any(test, not(target_arch = "wasm32")))] +use crate::runtime::statement_allowance::renewal::{ResolvedRenewalTarget, StatementRenewalReport}; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::{ self, fetch_chain_state, fetch_metadata, find_including_rings, }; /// Fallback tick delay when the system clock is unusable. +#[cfg(not(target_arch = "wasm32"))] const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); /// A statement-store account the signing host promised to keep renewed. @@ -82,6 +100,7 @@ impl LedgerEntry { } /// Whether this entry belongs to the identity rooted at `owner`. + #[cfg(any(test, not(target_arch = "wasm32")))] fn is_owned_by(&self, owner: [u8; 32]) -> bool { self.owner.is_none_or(|recorded| recorded == owner) } @@ -104,12 +123,14 @@ pub(super) struct RenewalState { /// Serializes read-modify-write cycles on the ledger so a concurrent /// allocation cannot drop another's entry. ledger_lock: Mutex<()>, + #[cfg(not(target_arch = "wasm32"))] loop_started: AtomicBool, /// The most recent pass, so a host that drives the in-process loop can read /// what it achieved. The loop computes a report on every tick and has no /// caller to hand it to, and exhaustion is the outcome a host most needs to /// act on. A blocking lock rather than an async one: every use is a clone or /// a store with no await in between. + #[cfg(any(test, not(target_arch = "wasm32")))] last_report: std::sync::Mutex>, } @@ -123,6 +144,7 @@ impl RenewalState { } /// Record the pass a host has not seen the return value of. + #[cfg(any(test, not(target_arch = "wasm32")))] fn record_report(&self, report: &StatementRenewalReport) { if let Ok(mut last) = self.last_report.lock() { *last = Some(report.clone()); @@ -131,6 +153,7 @@ impl RenewalState { /// The most recent pass the loop ran. `None` until one has run, which a host /// should read as "not yet" rather than as healthy. + #[cfg(any(test, not(target_arch = "wasm32")))] pub(super) fn last_report(&self) -> Option { self.last_report.lock().ok().and_then(|last| last.clone()) } @@ -183,6 +206,7 @@ async fn track_targets( write_entries(storage, &entries).await } +#[cfg(any(test, not(target_arch = "wasm32")))] async fn untrack_account( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -232,6 +256,7 @@ fn decode_entries(blob: &[u8]) -> Result, String> { /// Resolve a ledger entry into a concrete account for this session's entropy. /// The label a target reports under, derivable without an active session so a /// pruned entry reads the same as a renewed one. +#[cfg(any(test, not(target_arch = "wasm32")))] fn target_label(target: &StatementRenewalTarget) -> String { match target { StatementRenewalTarget::ProductStatementAllowance { product_id } => { @@ -242,6 +267,7 @@ fn target_label(target: &StatementRenewalTarget) -> String { } } +#[cfg(any(test, not(target_arch = "wasm32")))] fn resolve_target( entropy: &[u8], network_suffix: &str, @@ -291,6 +317,7 @@ pub(super) async fn track( } /// Stop renewing one fixed statement account for the active identity. +#[cfg(not(target_arch = "wasm32"))] pub(super) async fn untrack_account_for_signing_host( signing_host: &SigningHost, account_id: &[u8; 32], @@ -310,6 +337,7 @@ pub(super) async fn untrack_account_for_signing_host( /// /// A target is skipped rather than failing the pass: one unusable entry must /// not stop every other target from being renewed. +#[cfg(any(test, not(target_arch = "wasm32")))] fn resolve_targets( entropy: &[u8], network_suffix: &str, @@ -344,6 +372,7 @@ fn resolve_targets( /// The labels are logged here rather than only returned. Every step between this /// and the report can fail, and `run_tick` does not read the report at all, so /// the log is the one place a prune is recorded unconditionally. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn owned_targets( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -373,6 +402,7 @@ async fn owned_targets( /// One renewal pass: resolve the ledger against the active session and renew /// every target for the current period. +#[cfg(not(target_arch = "wasm32"))] pub(super) async fn renew_now( services: &Arc, signing_host: &SigningHost, @@ -451,6 +481,7 @@ pub(super) async fn renew_now( /// Spawn the periodic renewal loop; repeated calls are no-ops. The loop holds /// only weak references, so it exits when the owning runtime is dropped. +#[cfg(not(target_arch = "wasm32"))] pub(super) fn start_renewal_loop(services: &Arc, signing_host: &Arc) { if signing_host .renewal @@ -481,6 +512,7 @@ pub(super) fn start_renewal_loop(services: &Arc, signing_host: })); } +#[cfg(not(target_arch = "wasm32"))] async fn run_tick(services: &Arc, signing_host: &SigningHost) { if signing_host.root_entropy().is_err() { debug!("skipping statement-store renewal tick; no active session"); @@ -497,6 +529,7 @@ async fn run_tick(services: &Arc, signing_host: &SigningHost) { /// Split out from [`run_tick`] so the recording is reachable without a runtime: a /// loop that logged but forgot to record would leave a host unable to see /// exhaustion, and that is exactly the wiring worth a test. +#[cfg(any(test, not(target_arch = "wasm32")))] fn absorb_tick(state: &RenewalState, result: Result) { match result { Ok(report) => { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index cf409a502..08adbf493 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -5,6 +5,10 @@ //! `Resources.set_statement_store_account` call, prove personhood ring //! membership with the caller's registry-selected ring-VRF key, and submit the //! resulting unsigned General (v5) extrinsic. +//! +//! These helpers accept host-backed RPC clients on native and browser targets; +//! the host supplies the chain connections and controls which claims it offers. +//! Opening a direct RPC URL is only available on native targets. pub mod collection; pub mod extension; @@ -21,7 +25,9 @@ pub mod view; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; use futures::FutureExt; use parity_scale_codec::{Decode, Encode}; @@ -29,6 +35,8 @@ use serde_json::{Value, json}; use sp_crypto_hashing::twox_128; use thiserror::Error; use tracing::{debug, warn}; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; use collection::PersonhoodCollection; use extension::{ChainState, Metadata, MetadataError}; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index ca0d7542d..37d9e6b40 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -12,12 +12,16 @@ //! authorized against a revision Asset Hub has already imported, so the flow //! waits for it rather than submitting a proof the runtime cannot verify. -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; use parity_scale_codec::{Decode, DecodeAll}; use scale_decode::DecodeAsType; use sp_crypto_hashing::twox_128; use thiserror::Error; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; use super::collection::PersonhoodCollection; use super::extension::{AS_PGAS, Metadata, MetadataError}; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs index d7b77b012..23b12247e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -16,10 +16,11 @@ use super::StatementAllowanceError; /// Timeout for an allowance registration extrinsic to reach a block. const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); -/// Error from the native JSON-RPC surface used by allowance allocation. +/// Error from the host-backed JSON-RPC surface used by allowance allocation. #[derive(Debug, Error)] pub enum RpcError { /// Opening a direct RPC URL failed. + #[cfg(not(target_arch = "wasm32"))] #[error("connect {url}: {source}")] Connect { /// RPC URL. From 65d8080c619260821486bdf3b202ebfe13e463d7 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 12:01:46 -0400 Subject: [PATCH 12/67] fix(chat): enforce dedicated authority on local product calls Keep username disclosure separate from Chat consent. Exercise approval, denial, cached consent, and revocation through local and SSO APIs; move the secret-bearing pairing result instead of cloning it. --- CHANGELOG.md | 4 + .../src/host_logic/permissions.rs | 6 +- rust/crates/truapi-server/src/runtime.rs | 9 + .../src/runtime/capabilities/account.rs | 2 +- .../truapi-server/src/runtime/signing_host.rs | 372 +++++++++++++++++- .../truapi-server/src/runtime/sso_pairing.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 7 +- 7 files changed, 386 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f34254b..211a679a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- require separate Chat-authority consent on the local product API as well as + SSO; existing username-disclosure grants do not authorize Chat operations, + and denial or revocation blocks subsequent binding, sealing, and opening (#709) +- move the secret-bearing SSO pairing result instead of cloning it (#709) - keep host-backed allowance helpers available on Wasm with browser-compatible polling clocks, while excluding the native-only renewal driver (#540) - return a decode error instead of trapping when subscription helpers receive a diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index ff5174d28..8fe2c4f8e 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -1611,11 +1611,7 @@ mod tests { PermissionAuthorizationStatus::Authorized ); assert_eq!( - platform - .chat_authority_reviews - .lock() - .expect("Chat authority review list mutex poisoned") - .as_slice(), + platform.chat_authority_reviews.lock().as_slice(), &[ChatAuthorityReview { product_id: "chat.paseo".to_string(), }] diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index cbd1485b2..cee140192 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -584,6 +584,15 @@ impl ProductRuntimeHost { .map_err(|err| format!("permission storage failed: {err:?}")) } + #[instrument(skip_all, fields(runtime.method = "permissions.chat_authority_authorization"))] + async fn chat_authority_authorization(&self) -> Result { + let product_id = self.product_id(); + self.permissions_service(&product_id) + .check_or_prompt_chat_authority() + .await + .map_err(|err| format!("permission storage failed: {err:?}")) + } + async fn classify_legacy_address_signer( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 512f65777..551e3f10e 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -374,7 +374,7 @@ impl Account for ProductRuntimeHost { ))); }; if self - .identity_disclosure_authorization() + .chat_authority_authorization() .await .map_err(|reason| CallError::HostFailure { reason })? != PermissionAuthorizationStatus::Authorized diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index cfd848e68..d0abf225f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1379,14 +1379,19 @@ mod tests { SR25519_SIGNING_CONTEXT, raw_payload_bytes, }; use crate::host_logic::extrinsic::tests::split_v4; + use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }; - use crate::host_logic::sso::messages::ProductRequest; + use crate::host_logic::sso::messages::{ + ProductDeviceChatResponse, ProductRequest, RemoteMessage, RemoteMessageData, + SsoProductDeviceChatOperation, v1, + }; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, }; + use crate::runtime::sso_service::Dispatch; use crate::runtime::statement_allowance::collection::PersonhoodCollection; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, ResourceAllocation, Signing}; @@ -1394,14 +1399,20 @@ mod tests { HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, }; - use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; + use truapi::versioned::account::{ + HostAccountGetError, HostAccountGetRequest, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, + }; use truapi::versioned::entropy::HostDeriveEntropyRequest; use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, }; use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; use truapi::{CallContext, CallError, v01}; - use truapi_platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}; + use truapi_platform::{ + HostInfo, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, + PlatformInfo, ProductContext, SigningHostConfig, + }; use verifiable::ring::RingDomainSize; const ENTROPY: [u8; 16] = [0xAB; 16]; @@ -2689,7 +2700,7 @@ mod tests { #[test] fn product_chat_request_proof_signs_unframed_payload() { let (services, activation) = signing_runtime_with_platform(Arc::new(StubPlatform { - identity_disclosure_confirmed: true, + chat_authority_confirmed: true, ..StubPlatform::default() })); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) @@ -2737,6 +2748,359 @@ mod tests { ); } + #[test] + fn product_chat_username_grant_does_not_authorize_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform::default()); + let (services, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, activation); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + let cx = CallContext::default(); + let request = + HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: product_account(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key: x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from([0x22; 32]), + ) + .to_bytes(), + }); + + for _ in 0..2 { + assert!(matches!( + runtime.product_device_chat(&cx, request.clone()).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "Chat requires its own prompt, then respects the persisted refusal" + ); + assert_eq!( + runtime + .permission_authorization_status(PermissionAuthorizationRequest::ChatAuthority) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + runtime + .permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + ) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + }); + } + + #[test] + fn product_chat_consent_is_cached_and_revocation_blocks_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + }); + let (services, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, activation); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let cx = CallContext::default(); + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) + .to_bytes(); + let bind = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: product_account(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key, + }); + assert!(matches!( + runtime.product_device_chat(&cx, bind.clone()).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + assert!(platform.chat_authority_reviews.lock().is_empty()); + + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + assert!(matches!( + runtime + .product_device_chat(&cx, bind.clone()) + .await + .unwrap(), + HostProductDeviceChatResponse::V1( + v01::HostProductDeviceChatResponse::IdentityBinding { .. } + ) + )); + let plaintext = b"Chat consent protects private messages".to_vec(); + let seal = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Seal { + product_account_id: product_account(0), + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: plaintext.clone(), + }); + let HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + }) = runtime + .product_device_chat(&cx, seal.clone()) + .await + .unwrap() + else { + panic!("expected sealed Chat message"); + }; + let open = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Open { + product_account_id: product_account(0), + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext, + }); + assert_eq!( + runtime + .product_device_chat(&cx, open.clone()) + .await + .unwrap(), + HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Opened { + plaintext + }) + ); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + + runtime + .set_permission_authorization_status( + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + for request in [bind, seal, open] { + assert!(matches!( + runtime.product_device_chat(&cx, request).await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected + ))) + )); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "revocation must not be overridden by another prompt" + ); + }); + } + + async fn sso_chat( + service: &super::sso_service::SigningHostSsoService, + payload: SsoProductDeviceChatOperation, + ) -> ProductDeviceChatResponse { + let message = RemoteMessage::request( + "chat-consent".to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload, + }, + ); + let Dispatch::Response(answer) = service.dispatch(service.current_session(), message).await + else { + panic!("expected SSO response"); + }; + let RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatResponse(response)) = + answer.message.data + else { + panic!("expected SSO Chat response"); + }; + response.payload + } + + #[test] + fn sso_chat_username_grant_does_not_authorize_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform::default()); + let (_, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let service = super::sso_service::SigningHostSsoService::new(activation); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + let request = SsoProductDeviceChatOperation::Bind { + derivation_index: v01::DerivationIndex::Index(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key: x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::from([0x22; 32]), + ) + .to_bytes(), + }; + + for _ in 0..2 { + assert_eq!( + sso_chat(&service, request.clone()).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "SSO Chat requires its own prompt, then respects the persisted refusal" + ); + assert_eq!( + permissions + .authorization_status(&PermissionAuthorizationRequest::ChatAuthority) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + permissions + .authorization_status(&PermissionAuthorizationRequest::IdentityDisclosure) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + }); + } + + #[test] + fn sso_chat_consent_is_cached_and_revocation_blocks_crypto() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + }); + let (_, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let service = super::sso_service::SigningHostSsoService::new(activation); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) + .to_bytes(); + let bind = SsoProductDeviceChatOperation::Bind { + derivation_index: v01::DerivationIndex::Index(0), + peer_identity_account_id: [0x33; 32], + peer_chat_public_key, + }; + assert_eq!( + sso_chat(&service, bind.clone()).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + assert!(platform.chat_authority_reviews.lock().is_empty()); + + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + assert!(matches!( + sso_chat(&service, bind.clone()).await.unwrap(), + v01::HostProductDeviceChatResponse::IdentityBinding { .. } + )); + let plaintext = b"SSO Chat consent protects private messages".to_vec(); + let seal = SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + plaintext: plaintext.clone(), + }; + let v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + } = sso_chat(&service, seal.clone()).await.unwrap() + else { + panic!("expected sealed SSO Chat message"); + }; + let open = SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, + combined_ciphertext, + }; + assert_eq!( + sso_chat(&service, open.clone()).await.unwrap(), + v01::HostProductDeviceChatResponse::Opened { plaintext } + ); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + for request in [bind, seal, open] { + assert_eq!( + sso_chat(&service, request).await, + Err(v01::HostProductDeviceChatError::Rejected) + ); + } + assert_eq!( + platform.chat_authority_reviews.lock().len(), + 1, + "revocation must not be overridden by another SSO prompt" + ); + }); + } + #[test] fn reactivation_invalidates_prior_session_snapshot() { let (_services, authority) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index b2a564ccf..2d9726baf 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -396,7 +396,7 @@ impl PairingProgress { Ok(Self::Success(Box::new(PairingSuccess { statement: statement.to_vec(), peer_statement_account_id: verified.signer, - success: (*success).clone(), + success: *success, }))) } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 44f9e8300..01c7cfb6a 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -97,7 +97,7 @@ pub(crate) struct StubPlatform { pub(crate) identity_disclosure_calls: Arc, pub(crate) chat_authority_confirmed: bool, pub(crate) chat_authority_error: Option<&'static str>, - pub(crate) chat_authority_reviews: Arc>>, + pub(crate) chat_authority_reviews: Arc>>, pub(crate) sign_payload_confirmed: bool, pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, @@ -1560,10 +1560,7 @@ impl UserConfirmation for StubPlatform { ) } UserConfirmationReview::ChatAuthority(review) => { - self.chat_authority_reviews - .lock() - .expect("Chat authority review list mutex poisoned") - .push(review); + self.chat_authority_reviews.lock().push(review); (self.chat_authority_error, self.chat_authority_confirmed) } UserConfirmationReview::ResourceAllocation(review) => { From 2d43b93f1caf1a7cd5d36657b17369ed6a64dc21 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 13:48:47 -0400 Subject: [PATCH 13/67] Format the SDK 0.16 Chat migration --- rust/crates/truapi-client/src/lib.rs | 314 +++++++++++++++--- rust/crates/truapi-codegen/src/rust.rs | 1 - rust/crates/truapi-codegen/src/rust/client.rs | 6 +- 3 files changed, 264 insertions(+), 57 deletions(-) diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs index dabacfc78..44080579d 100644 --- a/rust/crates/truapi-client/src/lib.rs +++ b/rust/crates/truapi-client/src/lib.rs @@ -202,58 +202,122 @@ pub enum DecodeError { /// Encodes a request without an intermediate payload allocation. pub fn encode_request(request_id: &str, request: &M::Request) -> Vec { - encode_value_frame(request_id, generated_request_ids(M::DESCRIPTOR), MessageType::Request, request) + encode_value_frame( + request_id, + generated_request_ids(M::DESCRIPTOR), + MessageType::Request, + request, + ) } /// Decodes a response's `Result>` payload. pub fn decode_response(frame: &[u8]) -> DecodedResponse { - decode_value_frame(frame, generated_request_ids(M::DESCRIPTOR), MessageType::Response) + decode_value_frame( + frame, + generated_request_ids(M::DESCRIPTOR), + MessageType::Response, + ) } /// Encodes a subscription start. -pub fn encode_subscription_start(request_id: &str, request: &M::Request) -> Vec { - encode_value_frame(request_id, generated_subscription_ids(M::DESCRIPTOR), MessageType::Request, request) +pub fn encode_subscription_start( + request_id: &str, + request: &M::Request, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Request, + request, + ) } /// Encodes subscription cancellation; rejects request descriptors. -pub fn encode_subscription_stop(request_id: &str, descriptor: MethodDescriptor) -> Result, DecodeError> { - Ok(encode_value_frame(request_id, subscription_ids(descriptor)?, MessageType::Stop, &())) +pub fn encode_subscription_stop( + request_id: &str, + descriptor: MethodDescriptor, +) -> Result, DecodeError> { + Ok(encode_value_frame( + request_id, + subscription_ids(descriptor)?, + MessageType::Stop, + &(), + )) } /// Decodes a subscription item. -pub fn decode_subscription_item(frame: &[u8]) -> Result, DecodeError> { - decode_value_frame(frame, generated_subscription_ids(M::DESCRIPTOR), MessageType::Response) +pub fn decode_subscription_item( + frame: &[u8], +) -> Result, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Response, + ) } /// Decodes normal completion (`Ok(())`) or a typed subscription failure. -pub fn decode_subscription_interrupt(frame: &[u8]) -> Result>, DecodeError> { - decode_value_frame(frame, generated_subscription_ids(M::DESCRIPTOR), MessageType::Interrupt) +pub fn decode_subscription_interrupt( + frame: &[u8], +) -> Result>, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Interrupt, + ) } /// Whether a frame interrupts this subscription; rejects request descriptors. -pub fn is_subscription_interrupt(frame: &[u8], descriptor: MethodDescriptor) -> Result { +pub fn is_subscription_interrupt( + frame: &[u8], + descriptor: MethodDescriptor, +) -> Result { let ids = subscription_ids(descriptor)?; let frame = decode_frame(frame)?; Ok(frame.ids == ids && frame.message_type == MessageType::Interrupt as u8) } /// Decodes a host-initiated start for a product worker. -pub fn decode_host_subscription_start(frame: &[u8]) -> Result, DecodeError> { - decode_value_frame(frame, generated_subscription_ids(M::DESCRIPTOR), MessageType::Request) +pub fn decode_host_subscription_start( + frame: &[u8], +) -> Result, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Request, + ) } /// Encodes a product-served subscription item. -pub fn encode_host_subscription_item(request_id: &str, item: &M::Item) -> Vec { - encode_value_frame(request_id, generated_subscription_ids(M::DESCRIPTOR), MessageType::Response, item) +pub fn encode_host_subscription_item( + request_id: &str, + item: &M::Item, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Response, + item, + ) } /// Encodes normal or failed termination of a product-served subscription. -pub fn encode_host_subscription_interrupt(request_id: &str, outcome: &Result<(), CallError>) -> Vec { - encode_value_frame(request_id, generated_subscription_ids(M::DESCRIPTOR), MessageType::Interrupt, outcome) +pub fn encode_host_subscription_interrupt( + request_id: &str, + outcome: &Result<(), CallError>, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Interrupt, + outcome, + ) } /// Whether a host frame cancels this product-served subscription. -pub fn is_host_subscription_stop(frame: &[u8]) -> Result { +pub fn is_host_subscription_stop( + frame: &[u8], +) -> Result { let frame = decode_frame(frame)?; let matches = frame.ids == generated_subscription_ids(M::DESCRIPTOR) && frame.message_type == MessageType::Stop as u8; @@ -266,7 +330,9 @@ pub fn is_host_subscription_stop(frame: &[u8]) -> Res fn generated_request_ids(descriptor: MethodDescriptor) -> MethodIds { match descriptor.wire { MethodWire::Request(ids) => ids, - MethodWire::Subscription(_) => panic!("generated request descriptor must use request wire ids"), + MethodWire::Subscription(_) => { + panic!("generated request descriptor must use request wire ids") + } } } @@ -278,10 +344,16 @@ fn subscription_ids(descriptor: MethodDescriptor) -> Result MethodIds { - subscription_ids(descriptor).expect("generated subscription descriptor must use subscription wire ids") + subscription_ids(descriptor) + .expect("generated subscription descriptor must use subscription wire ids") } -fn encode_value_frame(request_id: &str, ids: MethodIds, message_type: MessageType, value: &T) -> Vec { +fn encode_value_frame( + request_id: &str, + ids: MethodIds, + message_type: MessageType, + value: &T, +) -> Vec { let mut frame = Vec::new(); request_id.encode_to(&mut frame); frame.extend_from_slice(&[ids.trait_id, ids.method_id, message_type as u8]); @@ -301,40 +373,72 @@ fn decode_frame(mut frame: &[u8]) -> Result, DecodeError> { let [trait_id, method_id, message_type, payload @ ..] = frame else { return Err(DecodeError::Malformed); }; - let ids = MethodIds { trait_id: *trait_id, method_id: *method_id }; + let ids = MethodIds { + trait_id: *trait_id, + method_id: *method_id, + }; if ids == PROTOCOL_ERROR_IDS { if *message_type != MessageType::Response as u8 { - return Err(DecodeError::UnexpectedMessageType { expected: MessageType::Response, actual: *message_type }); + return Err(DecodeError::UnexpectedMessageType { + expected: MessageType::Response, + actual: *message_type, + }); } let error = match (payload.first(), payload.get(1)) { (None, _) => return Err(DecodeError::Malformed), (Some(version), _) if *version != 0 => None, (Some(_), Some(variant)) if *variant != 0 => None, _ => { - let (version, variant, trait_id, method_id): (u8, u8, u8, u8) = decode_exact(payload)?; + let (version, variant, trait_id, method_id): (u8, u8, u8, u8) = + decode_exact(payload)?; debug_assert_eq!((version, variant), (0, 0)); - Some(ProtocolError::UnsupportedMessage(MethodIds { trait_id, method_id })) + Some(ProtocolError::UnsupportedMessage(MethodIds { + trait_id, + method_id, + })) } }; return Err(DecodeError::Protocol { request_id, error }); } - Ok(BorrowedFrame { request_id, ids, message_type: *message_type, payload }) + Ok(BorrowedFrame { + request_id, + ids, + message_type: *message_type, + payload, + }) } -fn decode_value_frame(frame: &[u8], ids: MethodIds, message_type: MessageType) -> Result, DecodeError> { +fn decode_value_frame( + frame: &[u8], + ids: MethodIds, + message_type: MessageType, +) -> Result, DecodeError> { let frame = decode_frame(frame)?; if frame.ids != ids { - return Err(DecodeError::UnexpectedMethod { expected: ids, actual: frame.ids }); + return Err(DecodeError::UnexpectedMethod { + expected: ids, + actual: frame.ids, + }); } if frame.message_type != message_type as u8 { - return Err(DecodeError::UnexpectedMessageType { expected: message_type, actual: frame.message_type }); + return Err(DecodeError::UnexpectedMessageType { + expected: message_type, + actual: frame.message_type, + }); } - Ok(Decoded { request_id: frame.request_id, value: decode_exact(frame.payload)? }) + Ok(Decoded { + request_id: frame.request_id, + value: decode_exact(frame.payload)?, + }) } fn decode_exact(mut payload: &[u8]) -> Result { let value = T::decode(&mut payload).map_err(|_| DecodeError::Malformed)?; - if payload.is_empty() { Ok(value) } else { Err(DecodeError::TrailingBytes) } + if payload.is_empty() { + Ok(value) + } else { + Err(DecodeError::TrailingBytes) + } } #[cfg(test)] @@ -347,76 +451,176 @@ mod tests { #[test] fn request_and_response_use_canonical_envelopes() { let request = HostHandshakeRequest::V1(v01::HostHandshakeRequest { codec_version: 2 }); - assert_eq!(encode_request::("p:1", &request), [12, b'p', b':', b'1', 1, 0, 0, 0, 2]); - let decoded = decode_response::(&[12, b'p', b':', b'1', 1, 0, 1, 0, 0]).expect("response frame"); + assert_eq!( + encode_request::("p:1", &request), + [12, b'p', b':', b'1', 1, 0, 0, 0, 2] + ); + let decoded = decode_response::(&[12, b'p', b':', b'1', 1, 0, 1, 0, 0]) + .expect("response frame"); assert_eq!(decoded.request_id, "p:1"); assert_eq!(decoded.value, Ok(HostHandshakeResponse::V1)); } #[test] fn response_domain_error_preserves_versioned_payload() { - let error = CallError::Domain(truapi::versioned::system::HostHandshakeError::V1(v01::HostHandshakeError::UnsupportedProtocolVersion)); + let error = CallError::Domain(truapi::versioned::system::HostHandshakeError::V1( + v01::HostHandshakeError::UnsupportedProtocolVersion, + )); let mut frame = vec![12, b'p', b':', b'1', 1, 0, 1, 1]; error.encode_to(&mut frame); - assert_eq!(decode_response::(&frame).expect("error frame").value, Err(error)); + assert_eq!( + decode_response::(&frame) + .expect("error frame") + .value, + Err(error) + ); } #[test] fn worker_serves_unified_renderer() { - use truapi::versioned::renderer::{ProductRendererRenderItem, ProductRendererRenderRequest}; + use truapi::versioned::renderer::{ + ProductRendererRenderItem, ProductRendererRenderRequest, + }; let request = ProductRendererRenderRequest::V1(v01::ProductRendererRenderRequest { - context: v01::RenderContext::PocketCard { card_id: "loyalty".into() }, + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, payload: vec![1, 2, 3], }); let ids = generated_subscription_ids(RendererRender::DESCRIPTOR); let start = encode_value_frame("host:4", ids, MessageType::Request, &request); - let decoded = decode_host_subscription_start::(&start).expect("start frame"); + let decoded = + decode_host_subscription_start::(&start).expect("start frame"); assert_eq!(decoded.request_id, "host:4"); assert_eq!(decoded.value, request); let item = ProductRendererRenderItem::V1(v01::RendererNode::Nil); let rendered = encode_host_subscription_item::("host:4", &item); - assert_eq!(decode_value_frame::(&rendered, ids, MessageType::Response).expect("item frame").value, item); + assert_eq!( + decode_value_frame::(&rendered, ids, MessageType::Response) + .expect("item frame") + .value, + item + ); let stop = encode_value_frame("host:4", ids, MessageType::Stop, &()); assert_eq!(is_host_subscription_stop::(&stop), Ok(true)); let interrupt = encode_host_subscription_interrupt::("host:4", &Ok(())); - assert_eq!(decode_value_frame::>>(&interrupt, ids, MessageType::Interrupt).expect("interrupt").value, Ok(())); + assert_eq!( + decode_value_frame::>>( + &interrupt, + ids, + MessageType::Interrupt + ) + .expect("interrupt") + .value, + Ok(()) + ); } #[test] fn subscription_completion_and_failure_are_typed_and_strict() { type Method = AccountConnectionStatusSubscribe; let ids = generated_subscription_ids(Method::DESCRIPTOR); - let complete = encode_value_frame("p:1", ids, MessageType::Interrupt, &Ok::<(), CallError>(())); - assert_eq!(decode_subscription_interrupt::(&complete).expect("completion").value, Ok(())); - let failed = encode_value_frame("p:1", ids, MessageType::Interrupt, &Err::<(), _>(CallError::::Denied)); - assert_eq!(decode_subscription_interrupt::(&failed).expect("failure").value, Err(CallError::Denied)); + let complete = encode_value_frame( + "p:1", + ids, + MessageType::Interrupt, + &Ok::<(), CallError>(()), + ); + assert_eq!( + decode_subscription_interrupt::(&complete) + .expect("completion") + .value, + Ok(()) + ); + let failed = encode_value_frame( + "p:1", + ids, + MessageType::Interrupt, + &Err::<(), _>(CallError::::Denied), + ); + assert_eq!( + decode_subscription_interrupt::(&failed) + .expect("failure") + .value, + Err(CallError::Denied) + ); let mut trailing = complete; trailing.push(0); - assert_eq!(decode_subscription_interrupt::(&trailing), Err(DecodeError::TrailingBytes)); + assert_eq!( + decode_subscription_interrupt::(&trailing), + Err(DecodeError::TrailingBytes) + ); let empty = encode_value_frame("p:1", ids, MessageType::Interrupt, &()); - assert_eq!(decode_subscription_interrupt::(&empty), Err(DecodeError::Malformed)); + assert_eq!( + decode_subscription_interrupt::(&empty), + Err(DecodeError::Malformed) + ); } #[test] fn frame_address_and_leg_both_must_match() { let ids = generated_request_ids(SystemHandshake::DESCRIPTOR); - let other = MethodIds { trait_id: ids.trait_id + 1, method_id: ids.method_id }; - let wrong_method = encode_value_frame("p:1", other, MessageType::Response, &Ok::<_, CallError<()>>(HostHandshakeResponse::V1)); - assert_eq!(decode_response::(&wrong_method), Err(DecodeError::UnexpectedMethod { expected: ids, actual: other })); + let other = MethodIds { + trait_id: ids.trait_id + 1, + method_id: ids.method_id, + }; + let wrong_method = encode_value_frame( + "p:1", + other, + MessageType::Response, + &Ok::<_, CallError<()>>(HostHandshakeResponse::V1), + ); + assert_eq!( + decode_response::(&wrong_method), + Err(DecodeError::UnexpectedMethod { + expected: ids, + actual: other + }) + ); let wrong_leg = encode_value_frame("p:1", ids, MessageType::Request, &()); - assert_eq!(decode_response::(&wrong_leg), Err(DecodeError::UnexpectedMessageType { expected: MessageType::Response, actual: 0 })); - assert_eq!(encode_subscription_stop("p:1", SystemHandshake::DESCRIPTOR), Err(DecodeError::WrongMethodKind)); + assert_eq!( + decode_response::(&wrong_leg), + Err(DecodeError::UnexpectedMessageType { + expected: MessageType::Response, + actual: 0 + }) + ); + assert_eq!( + encode_subscription_stop("p:1", SystemHandshake::DESCRIPTOR), + Err(DecodeError::WrongMethodKind) + ); } #[test] fn protocol_failures_keep_correlation_and_accept_future_variants() { let known = [12, b'p', b':', b'1', 255, 255, 1, 0, 0, 4, 7]; - assert_eq!(decode_response::(&known), Err(DecodeError::Protocol { request_id: "p:1".into(), error: Some(ProtocolError::UnsupportedMessage(MethodIds { trait_id: 4, method_id: 7 })) })); + assert_eq!( + decode_response::(&known), + Err(DecodeError::Protocol { + request_id: "p:1".into(), + error: Some(ProtocolError::UnsupportedMessage(MethodIds { + trait_id: 4, + method_id: 7 + })) + }) + ); let future = [12, b'p', b':', b'1', 255, 255, 1, 0, 1, 42]; - assert_eq!(decode_response::(&future), Err(DecodeError::Protocol { request_id: "p:1".into(), error: None })); - assert_eq!(decode_response::(&known[..known.len() - 1]), Err(DecodeError::Malformed)); + assert_eq!( + decode_response::(&future), + Err(DecodeError::Protocol { + request_id: "p:1".into(), + error: None + }) + ); + assert_eq!( + decode_response::(&known[..known.len() - 1]), + Err(DecodeError::Malformed) + ); let mut trailing = known.to_vec(); trailing.push(0); - assert_eq!(decode_response::(&trailing), Err(DecodeError::TrailingBytes)); + assert_eq!( + decode_response::(&trailing), + Err(DecodeError::TrailingBytes) + ); } } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 73f533355..21658bb96 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -233,7 +233,6 @@ mod tests { .collect() } - fn parse_entries(src: &str) -> Vec<(u8, String)> { // Each method's id is emitted as a named const, e.g. // pub const PREIMAGE_SUBMIT: MethodIds = MethodIds { diff --git a/rust/crates/truapi-codegen/src/rust/client.rs b/rust/crates/truapi-codegen/src/rust/client.rs index ba3c2e976..931f11bfb 100644 --- a/rust/crates/truapi-codegen/src/rust/client.rs +++ b/rust/crates/truapi-codegen/src/rust/client.rs @@ -163,7 +163,11 @@ fn emit_method( )?; writeln!(out, "}}\n")?; } - (MethodKind::Subscription, ReturnType::Subscription { item, interrupt }, host_initiated) => { + ( + MethodKind::Subscription, + ReturnType::Subscription { item, interrupt }, + host_initiated, + ) => { let item = rust_type(item, &module)?; let error = rust_type(call_error_domain(interrupt)?, &module)?; let subscription_trait = if host_initiated { From 15d9861b3361e3209a7faa5fa2ed40bdd1a74f46 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 16:39:51 -0400 Subject: [PATCH 14/67] Complete Chat codec two integration coverage and migration notes --- CHANGELOG.md | 7 ++++--- rust/crates/truapi-server/tests/wire_result_shape.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 177d6750c..4cd7e29cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). deadlines, multi-touch input, and image clipboard output (#540) - let browser signing hosts request personhood-backed Statement Store allowances for products instead of reporting the allocator as native-only +- migrate the generic runtime and Rust client to SDK 0.16's scoped wire codec 2; + product guests must be rebuilt rather than sending codec-1 frames ### Added @@ -23,9 +25,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - expose local signing-wallet username registration and chain-verified identity refresh through the browser worker, with native UID proofs and RFC-0004 X25519 identifier keys -- Generate a transport-neutral `no_std` Rust client with typed request, - subscription, result-subscription, and host-initiated Worker subscription - codecs. +- Generate a transport-neutral `no_std` Rust client with typed request responses, + subscription interrupts, and host-initiated Renderer subscription codecs. - Generate complete App, Widget, Worker, and Worker-only method catalogs from the canonical protocol schema. diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 0262d8ad7..ddf9d36c9 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use parity_scale_codec::{Decode, Encode}; -use truapi::{CallError, v01}; +use truapi::{CallError, v01, versioned::account}; use truapi_server::core::TrUApiCore; use truapi_server::frame::{ From 726c697a7052709d7d99a9fc36e91d6d05be0cee Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 19:29:18 -0400 Subject: [PATCH 15/67] fix(ios): route dedicated Chat authority through permission guard --- .changeset/chat-product-authority.md | 5 +++++ .../Products/Permissions/Guard/ProductPermissionGuard.swift | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/chat-product-authority.md diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md new file mode 100644 index 000000000..4f3275b44 --- /dev/null +++ b/.changeset/chat-product-authority.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": minor +--- + +Add product-scoped Chat v2 authority with dedicated Chat authorization, separate from username disclosure. Apply the boundary to local and SSO sessions, expose it in the iOS permission flow, and avoid cloning secret-bearing pairing results. diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift index 7d91d1d80..2aafdad58 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift @@ -67,7 +67,8 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: try await remoteHandler.request(productId: productId, permission: permission) } } @@ -148,7 +149,8 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: try await remoteHandler.isGranted(productId: productId, permission: permission) } } From 91e55b52a1a03a4eedf29768389c2e95b5ca35da Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 19:34:00 -0400 Subject: [PATCH 16/67] style: format Chat changeset --- .changeset/chat-product-authority.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md index 4f3275b44..c45a1c846 100644 --- a/.changeset/chat-product-authority.md +++ b/.changeset/chat-product-authority.md @@ -2,4 +2,6 @@ "@parity/truapi-host": minor --- -Add product-scoped Chat v2 authority with dedicated Chat authorization, separate from username disclosure. Apply the boundary to local and SSO sessions, expose it in the iOS permission flow, and avoid cloning secret-bearing pairing results. +Add product-scoped Chat v2 authority with dedicated Chat authorization, separate from username disclosure. Apply the +boundary to local and SSO sessions, expose it in the iOS permission flow, and avoid cloning secret-bearing pairing +results. From 5bfe42deaf249c4e2758d4e9cc4d02c96359dd7a Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 20:46:57 -0400 Subject: [PATCH 17/67] fix(ios): fund selected product statement-store accounts without exporting keys --- .changeset/chat-product-authority.md | 2 + .../Model/AllocatableResource+Codable.swift | 6 +- .../AllocatableResource+ScaleCodable.swift | 13 +++- .../Products/Model/AllocatableResource.swift | 2 + .../Services/ProductsAccountManager.swift | 10 +++ .../ProductStatementStoreAllowanceTests.swift | 78 +++++++++++++++++++ .../AllowancePromptViewFactory.swift | 3 +- .../Products/ProductsNativeApi+Resource.swift | 3 +- .../TrUAPIReviewPromptMapper.swift | 2 + .../TrUAPIReviewPromptMapperTests.swift | 6 +- 10 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md index c45a1c846..3071334ac 100644 --- a/.changeset/chat-product-authority.md +++ b/.changeset/chat-product-authority.md @@ -5,3 +5,5 @@ Add product-scoped Chat v2 authority with dedicated Chat authorization, separate from username disclosure. Apply the boundary to local and SSO sessions, expose it in the iOS permission flow, and avoid cloning secret-bearing pairing results. + +Support keyless Statement Store allowances for a selected product account in the native signing host. diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift index 24db62775..904f99d05 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+Codable.swift @@ -21,6 +21,9 @@ extension AllocatableResource: Decodable { self = .smartContractAllowance(dest: dest) case "AutoSigning": self = .autoSigning + case "ProductStatementStoreAllowance": + let dest = try container.decode(ProductAccountSelector.self, forKey: .dest) + self = .productStatementStoreAllowance(dest: dest) default: throw DecodingError.dataCorruptedError( forKey: .kind, @@ -56,7 +59,8 @@ extension AllocationOutcome: Encodable { case let .bulletInAllowance(privateKey): let account = SlotAccount(slotAccountKey: privateKey) try container.encode(account, forKey: .bulletInAllowance) - case .smartContractAllowance: + case .smartContractAllowance, + .productStatementStoreAllowance: break } case .rejected: diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift index 5fa02d05f..26b77bfab 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource+ScaleCodable.swift @@ -17,6 +17,9 @@ extension AllocatableResource: ScaleCodable { self = .smartContractAllowance(dest: dest) case 3: self = .autoSigning + case 4: + let dest = try ProductAccountSelector(scaleDecoder: scaleDecoder) + self = .productStatementStoreAllowance(dest: dest) default: throw ScaleCodingError.unexpectedDecodedValue } @@ -30,7 +33,8 @@ extension AllocatableResource: ScaleCodable { .bulletInAllowance, .autoSigning: break - case let .smartContractAllowance(dest): + case let .smartContractAllowance(dest), + let .productStatementStoreAllowance(dest): try dest.encode(scaleEncoder: scaleEncoder) } } @@ -41,6 +45,7 @@ extension AllocatableResource: ScaleCodable { case .bulletInAllowance: 1 case .smartContractAllowance: 2 case .autoSigning: 3 + case .productStatementStoreAllowance: 4 } } } @@ -103,6 +108,8 @@ extension AllocatedResource: ScaleCodable { case 3: let secrets = try AutoSigningSecrets(scaleDecoder: scaleDecoder) self = .autoSigning(secrets) + case 4: + self = .productStatementStoreAllowance default: throw ScaleCodingError.unexpectedDecodedValue } @@ -116,7 +123,8 @@ extension AllocatedResource: ScaleCodable { try key.encode(scaleEncoder: scaleEncoder) case let .bulletInAllowance(key): try key.encode(scaleEncoder: scaleEncoder) - case .smartContractAllowance: + case .smartContractAllowance, + .productStatementStoreAllowance: break case let .autoSigning(secrets): try secrets.encode(scaleEncoder: scaleEncoder) @@ -129,6 +137,7 @@ extension AllocatedResource: ScaleCodable { case .bulletInAllowance: 1 case .smartContractAllowance: 2 case .autoSigning: 3 + case .productStatementStoreAllowance: 4 } } } diff --git a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift index 2a5908c6c..4c34772c8 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Model/AllocatableResource.swift @@ -6,6 +6,7 @@ public enum AllocatableResource: Equatable { case bulletInAllowance case smartContractAllowance(dest: ProductAccountSelector) case autoSigning + case productStatementStoreAllowance(dest: ProductAccountSelector) } public enum AllocationOutcome: Equatable { @@ -19,6 +20,7 @@ public enum AllocatedResource: Equatable { case statementStoreAllowance(privateKey: Data) case bulletInAllowance(privateKey: Data) case smartContractAllowance + case productStatementStoreAllowance } public enum AutoSigningSecretsError: Error, Equatable { diff --git a/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift b/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift index 5758a064e..c2dd01edb 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Services/ProductsAccountManager.swift @@ -129,6 +129,16 @@ private extension ProductsAccountManager { ) let privateKey = try wallet.fetchRawSecretKey() return .allocated(.statementStoreAllowance(privateKey: privateKey)) + case let .productStatementStoreAllowance(dest): + let accountId = try accountHolder.deriveAccount( + ProductAccountId(productId: productId, derivationIndex: dest) + ) + try await allowanceSupport.sssManager.allocate( + accountId: accountId, + policy: policy, + priority: .normal + ) + return .allocated(.productStatementStoreAllowance) case .bulletInAllowance: let wallet = try accountHolder.deriveBulletInAccount(for: productId) let accountId = try wallet.getRawPublicKey() diff --git a/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift b/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift new file mode 100644 index 000000000..1b32ed46b --- /dev/null +++ b/hosts/ios/Packages/Products/Tests/ProductsTests/ProductStatementStoreAllowanceTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Individuality +import KeyDerivation +import SubstrateSdk +import Testing +import UIKitExt +@testable import Products + +@MainActor +struct ProductStatementStoreAllowanceTests { + @Test + func selectedProductAccountIsFundedWithoutExportingItsKey() async throws { + let entropy = AllocationTestEntropy() + let prompt = AllocationTestPrompt() + let ledger = AllocationTestLedger() + let manager = ProductsAccountManager( + entropyManager: entropy, + allowanceSupport: AllowanceSupport( + allowancePromptRouter: prompt, + sssManager: ledger, + bulletInManager: ledger, + smartContractManager: ledger + ) + ) + // Rust AllocatableResource::ProductStatementStoreAllowance(Index(7)). + let request = try AllocatableResource.fromScaleEncoded(Data([4, 0, 7, 0, 0, 0])) + let expectedAccount = try ProductAccountHolder(entropyManager: entropy).deriveAccount( + ProductAccountId(productId: "chat.dot", derivationIndex: .index(7)) + ) + + let outcomes = try await manager.requestResourceAllocation( + for: "chat.dot", resources: [request], policy: .increase + ) + #expect(await ledger.accounts == [expectedAccount]) + let outcome = try #require(outcomes.first) + // SSO Allocated(ProductStatementStoreAllowance), with no secret payload. + #expect(try outcome.scaleEncoded() == Data([0, 4])) + #expect(try JSONSerialization.jsonObject(with: JSONEncoder().encode(outcome)) as? [String: String] + == ["kind": "Allocated"]) + + prompt.decision = .rejected + let denied = try await manager.requestResourceAllocation( + for: "chat.dot", resources: [request], policy: .increase + ) + #expect(denied == [.rejected]) + #expect(await ledger.accounts == [expectedAccount]) + } +} + +private struct AllocationTestEntropy: RootEntropyManaging { + func fetchRootEntropy() throws -> Data { Data(repeating: 0xAB, count: 16) } + func createRootEntropy(_: Data) throws {} + func hasRootEntropy() throws -> Bool { true } +} + +private actor AllocationTestLedger: AllowanceManaging { + private(set) var accounts: [AccountId] = [] + + func allocate( + accountId: AccountId, + policy _: OnExistingAllowancePolicy, + priority _: AllowanceRecord.Priority + ) async throws { + accounts.append(accountId) + } +} + +@MainActor +private final class AllocationTestPrompt: AllowancePromptRouting { + var decision: AllowancePromptDecision = .approved + var isReady: Bool { true } + + func setPresentationView(_: ControllerBackedProtocol) {} + func present(view _: ControllerBackedProtocol) -> Bool { false } + func showAllowancePrompt(context: AllowancePromptContext) { + context.deliver(decision) + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift index c8d454d6b..0e24ff7a3 100644 --- a/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/AllowancePrompt/AllowancePromptViewFactory.swift @@ -52,7 +52,8 @@ private extension AllowancePromptViewFactory { static func resourceDescription(for resource: AllocatableResource) -> String { switch resource { - case .statementStoreAllowance: + case .statementStoreAllowance, + .productStatementStoreAllowance: String(localized: .Products.allowanceResourceStatementStore) case .bulletInAllowance: String(localized: .Products.allowanceResourceBulletIn) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift index 91a96ae7e..958fa1087 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Resource.swift @@ -54,7 +54,8 @@ private extension ProductsNativeApi { kind: .bulletIn ) case .autoSigning, - .smartContractAllowance: + .smartContractAllowance, + .productStatementStoreAllowance: break } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift index 8541df792..140f7033a 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift @@ -134,6 +134,8 @@ private extension TrUAPIReviewPromptMapper { try .smartContractAllowance(dest: index.toSelector()) case .autoSigning: .autoSigning + case let .productStatementStoreAllowance(index): + try .productStatementStoreAllowance(dest: index.toSelector()) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift index 773ba2b94..d6010305f 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift @@ -121,7 +121,8 @@ struct TrUAPIReviewPromptMapperTests { .statementStoreAllowance, .bulletinAllowance, .smartContractAllowance(.index(4)), - .autoSigning + .autoSigning, + .productStatementStoreAllowance(.index(7)) ] )) @@ -131,7 +132,8 @@ struct TrUAPIReviewPromptMapperTests { .statementStoreAllowance, .bulletInAllowance, .smartContractAllowance(dest: .index(4)), - .autoSigning + .autoSigning, + .productStatementStoreAllowance(dest: .index(7)) ] )) } From a34218ffe84aabde97ef3f0bed17540e4c0f5dde Mon Sep 17 00:00:00 2001 From: w Date: Tue, 15 Sep 2026 23:25:32 -0400 Subject: [PATCH 18/67] fix(ios): separate permission confirmation dispatch --- .../TrUAPIHostAccount+Conversions.swift | 1 + .../TrUAPI/TrUAPIConfirmationPresenter.swift | 52 ++++++++++--------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift index bbee8ce72..2bb9c20cc 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIHostAccount+Conversions.swift @@ -6,6 +6,7 @@ import TrUAPIHost enum TrUAPIReviewMappingError: Error, Equatable { case invalidDerivationIndexLength(Int) case notASigningReview + case notAPermissionReview } // MARK: - TrUAPIHost account conversions diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index a242ace80..f40eee949 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -52,30 +52,13 @@ private extension TrUAPIConfirmationPresenter { await confirmStatementSign( promptMapper.makeStatementSignRequest(from: statementReview) ) - case let .identityDisclosure(identityReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: identityReview) - ) - case let .chatAuthority(chatReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: chatReview) - ) - case let .preimageSubmit(preimageReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: preimageReview) - ) - case let .accountAccess(accessReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: accessReview) - ) - case let .productSubtree(subtreeReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: subtreeReview) - ) - case let .accountAlias(aliasReview): - await confirmPermission( - promptMapper.makePermissionRequest(from: aliasReview) - ) + case .identityDisclosure, + .chatAuthority, + .preimageSubmit, + .accountAccess, + .productSubtree, + .accountAlias: + try await confirmPermissionReview(review) case let .createProof(proofReview): try await confirmCreateProof( promptMapper.makeCreateProofRequest(from: proofReview) @@ -126,6 +109,27 @@ private extension TrUAPIConfirmationPresenter { } } + func confirmPermissionReview(_ review: UserConfirmationReview) async throws -> Bool { + let request: TrUAPIPermissionRequest + switch review { + case let .identityDisclosure(value): + request = promptMapper.makePermissionRequest(from: value) + case let .chatAuthority(value): + request = promptMapper.makePermissionRequest(from: value) + case let .preimageSubmit(value): + request = promptMapper.makePermissionRequest(from: value) + case let .accountAccess(value): + request = promptMapper.makePermissionRequest(from: value) + case let .productSubtree(value): + request = promptMapper.makePermissionRequest(from: value) + case let .accountAlias(value): + request = promptMapper.makePermissionRequest(from: value) + default: + throw TrUAPIReviewMappingError.notAPermissionReview + } + return await confirmPermission(request) + } + func confirmPermission(_ request: TrUAPIPermissionRequest) async -> Bool { await awaitDecision { [routerFacade] in let decision: PermissionDecision = await withCheckedContinuation { continuation in From d956dda370b993f25af50086659f0b27d06def02 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 16 Sep 2026 00:39:26 -0400 Subject: [PATCH 19/67] Persist scoped statement allowance grants without approving silent increases --- CHANGELOG.md | 5 + docs/rfcs/0010-allowance.md | 18 + .../tests/golden/host-callbacks.ts | 13 +- rust/crates/truapi-platform/src/lib.rs | 19 +- rust/crates/truapi-server/README.md | 25 + .../src/host_logic/permissions.rs | 16 + rust/crates/truapi-server/src/native.rs | 44 -- .../src/runtime/capabilities/resources.rs | 197 +++++++- .../truapi-server/src/runtime/signing_host.rs | 430 ++++++++++++++++++ .../runtime/signing_host/allowance_renewal.rs | 144 ++++-- .../src/runtime/signing_host/sso_responder.rs | 97 ++-- .../src/runtime/statement_allowance/slot.rs | 24 +- .../src/runtime/statement_store.rs | 25 +- .../crates/truapi-server/src/runtime/tests.rs | 46 +- 14 files changed, 952 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd7e29cf..ee5f513e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- persist typed Statement Store allowance approvals and denials per product and + account selector for implicit, idempotent provisioning; explicit requests for + additional quota retain per-operation confirmation and increase semantics. + Stop unscoped product background renewal, including previously recorded + targets, so artifact-scoped revocation cannot be bypassed. - require separate Chat-authority consent on the local product API as well as SSO; existing username-disclosure grants do not authorize Chat operations, and denial or revocation blocks subsequent binding, sealing, and opening (#709) diff --git a/docs/rfcs/0010-allowance.md b/docs/rfcs/0010-allowance.md index fe31710da..0392e4534 100644 --- a/docs/rfcs/0010-allowance.md +++ b/docs/rfcs/0010-allowance.md @@ -11,6 +11,24 @@ Products running on a Polkadot Host need to submit data to three allowance-gated systems — the Bulletin chain, the Statement Store, and Asset Hub smart contracts — each of which grants free-to-use resources to users but requires the signing origin to hold the appropriate allowance. This RFC defines how products obtain and use those allowances via TrUAPI without managing the underlying slot-table state themselves, by introducing a single pre-allocation call (`host_request_resource_allocation`) and a companion Accounts Protocol request used by the Host to negotiate private-key material with the Account Holder. +**SDK 0.16 implementation constraint (Statement Store).** Explicit +`ResourceAllocation.request` calls retain the additional-slot (`Increase`) +semantics below and require confirmation for every operation. Implicit +provisioning always uses `Ignore`: an existing current-period allowance is +reused, not scaled up when a product reopens. Durable +`StatementStoreAllowance { derivation_index }` authorization distinguishes the +legacy allowance account (`None`) from each product account (`Some(index)`). +An explicit approval can establish a missing durable grant in its single review; +cancelling a subsequent increase does not revoke that grant. Durable grants +authorize implicit provisioning, not unlimited additional funding. + +Product grant-derived background renewal is disabled, and old product ledger +entries are pruned: the signing host cannot resolve artifact-scoped permission +storage from a product id alone. Next-period provisioning happens on demand. +Wallet and paired-device background renewal remain independent. Revocation +stops subsequent provisioning, not already issued on-chain quota. Paired signing +hosts retain their separate confirmation boundary. + ## Motivation Three systems in the Polkadot ecosystem grant sponsored access via per-user quotas: diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 983e8d473..9137d8c50 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -10,6 +10,7 @@ import { AllocatableResource, Bytes32, ChainIdentifier, + DerivationIndex, HostAccountSignVrfRequest, HostDevicePermissionRequest, HostSignPayloadRequest, @@ -338,7 +339,14 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to bind and use wallet-held Chat identity authority. */ - | { tag: "ChatAuthority"; value?: undefined }; + | { tag: "ChatAuthority"; value?: undefined } + /** + * Product-scoped permission to ensure Statement Store quota, not increase it. + */ + | { + tag: "StatementStoreAllowance"; + value: { derivationIndex?: DerivationIndex }; + }; /** * Authorization status for a permission request. @@ -788,6 +796,9 @@ export const PermissionAuthorizationRequest: S.Codec, ChatAuthority: S._void, + StatementStoreAllowance: S.Struct({ + derivationIndex: S.Option(DerivationIndex), + }) as S.Codec<{ derivationIndex?: DerivationIndex }>, }), ); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index d5ca75c17..162d556bb 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -31,7 +31,7 @@ uniffi::use_remote_type!(truapi::Bytes32); use truapi::Bytes32; use truapi::latest::{ AllocatableResource, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, GenericError, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, DerivationIndex, GenericError, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, @@ -1054,6 +1054,12 @@ pub enum PermissionAuthorizationRequest { /// Product-scoped permission to bind and use wallet-held Chat identity authority. #[codec(index = 4)] ChatAuthority, + /// Product-scoped permission to ensure Statement Store quota, not increase it. + #[codec(index = 5)] + StatementStoreAllowance { + /// `None` selects the legacy allowance account; `Some` selects a product account. + derivation_index: Option, + }, } /// Authorization status for a permission request. @@ -1452,6 +1458,17 @@ impl CoreStorageKey { request: PermissionAuthorizationRequest::ChatAuthority, } } + + /// Persisted authorization for one statement allowance account selector. + pub fn statement_store_allowance_authorization( + product_id: &str, + derivation_index: Option, + ) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }, + } + } } /// Canonical storage form for one remote-access domain pattern. diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index a334612bd..5209eee1b 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -104,6 +104,31 @@ Remote permissions carry one exception. A product whose label is listed in surface revokes it. Device permissions, identity disclosure and account access always prompt. +Statement Store allocation uses the durable +`StatementStoreAllowance { derivation_index }` decision: `None` is the legacy +allowance account; `Some(index)` is that exact product-account selector. Both +approval and denial survive runtime restart through the same `CoreAdmin` +permission APIs. The product connection's storage is authoritative, including +any host-provided artifact namespace. A storage failure prevents provisioning. +Chat, identity disclosure, other resources, and ordinary signing retain their +separate authorization contracts. + +Implicit Statement Store provisioning ensures the current-period allowance +without adding slots and reuses an authorized durable decision without another +grant prompt. An explicit `ResourceAllocation.request` instead requests additional +quota (`Increase`) and always requires per-operation confirmation. Its initial +approval can establish a missing durable grant in that same review; cancelling +an increase never revokes a previously authorized grant. Denied grants, storage +failures, session changes, and changed administrative decisions block allocation. + +Revocation blocks subsequent provisioning but does not withdraw already issued +on-chain quota. Product grants no longer create background renewal promises: +the signing host's global storage cannot resolve an artifact-scoped decision. +Old product-derived renewal entries are pruned; wallet and paired-device renewal +remain enabled. Next-period provisioning happens on demand through the product's +scoped runtime. A remote signing host retains its independent per-operation +confirmation; the product grant does not silently authorize another host. + ```text Product app (product_id = "my-product") diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index 8fe2c4f8e..b366166d5 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -305,6 +305,16 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index } => { + authorization_status( + self.storage, + CoreStorageKey::statement_store_allowance_authorization( + self.product_id, + derivation_index.clone(), + ), + ) + .await + } } } @@ -365,6 +375,12 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::ChatAuthority => { CoreStorageKey::chat_authority_authorization(self.product_id) } + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index } => { + CoreStorageKey::statement_store_allowance_authorization( + self.product_id, + derivation_index.clone(), + ) + } }; set_authorization_status(self.storage, key, status).await } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 1b5b1b0e2..990debd10 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -2758,50 +2758,6 @@ mod tests { )); } - #[test] - fn permission_authorization_request_mirror_round_trips() { - let device_cases = [ - v01::HostDevicePermissionRequest::Notifications, - v01::HostDevicePermissionRequest::Camera, - v01::HostDevicePermissionRequest::Microphone, - v01::HostDevicePermissionRequest::Bluetooth, - v01::HostDevicePermissionRequest::NFC, - v01::HostDevicePermissionRequest::Location, - v01::HostDevicePermissionRequest::Clipboard, - v01::HostDevicePermissionRequest::OpenUrl, - v01::HostDevicePermissionRequest::Biometrics, - ]; - let remote_cases = [ - v01::RemotePermission::Remote { - domains: vec!["a.dot".to_string(), "b.dot".to_string()], - }, - v01::RemotePermission::WebRtc, - v01::RemotePermission::ChainSubmit, - v01::RemotePermission::PreimageSubmit, - v01::RemotePermission::StatementSubmit, - ]; - - let mut cases: Vec = Vec::new(); - cases.extend( - device_cases - .into_iter() - .map(PermissionAuthorizationRequest::Device), - ); - cases.extend(remote_cases.into_iter().map(|permission| { - PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { permission }) - })); - cases.push(PermissionAuthorizationRequest::IdentityDisclosure); - cases.push(PermissionAuthorizationRequest::AccountAccess { - target_product_id: "other.dot".to_string(), - }); - cases.push(PermissionAuthorizationRequest::ChatAuthority); - - for case in cases { - let native = case.clone(); - assert_eq!(native, case); - } - } - #[test] fn native_auth_presenter_forwards_states_across_the_ffi_mirror() { let (callbacks, _events, platform) = event_platform(); diff --git a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs index 2a39d4142..f8cf9009d 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs @@ -10,13 +10,129 @@ use truapi::versioned::resource_allocation::{ HostRequestResourceAllocationResponse, }; use truapi::{CallContext, CallError, v01}; -use truapi_platform::{ResourceAllocationReview, UserConfirmationReview}; +use truapi_platform::{ + PermissionAuthorizationRequest, PermissionAuthorizationStatus, ResourceAllocationReview, + UserConfirmationReview, +}; use crate::runtime::{ ProductRuntimeHost, RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, remote_authority_call, remote_authority_context_with_default, }; +impl ProductRuntimeHost { + /// Resolve a durable allowance grant in the product connection's storage, + /// never the signing host's (potentially differently scoped) storage. + pub(in crate::runtime) async fn require_statement_store_allowance( + &self, + session: &crate::runtime::authority::AuthoritySession, + derivation_index: Option, + ) -> Result<(), String> { + let require_session = || { + if self.authority.current_session().as_ref() == Some(session) { + Ok(()) + } else { + Err("Statement allowance session changed".to_string()) + } + }; + require_session()?; + let product_id = self.product_id(); + let service = self.permissions_service(&product_id); + let request = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: derivation_index.clone(), + }; + let mut status = service + .authorization_status(&request) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + require_session()?; + if status == PermissionAuthorizationStatus::NotDetermined { + let resource = match derivation_index { + Some(index) => v01::AllocatableResource::ProductStatementStoreAllowance(index), + None => v01::AllocatableResource::StatementStoreAllowance, + }; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation( + ResourceAllocationReview { + calling_product_id: product_id.clone(), + resources: vec![resource], + }, + )) + .await + .map_err(|err| { + format!("statement allowance confirmation failed: {}", err.reason) + })?; + require_session()?; + // An administrative decision made while the prompt was open wins. + status = service + .authorization_status(&request) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + require_session()?; + if status != PermissionAuthorizationStatus::NotDetermined { + return Err( + "Statement allowance authorization changed during confirmation".to_string(), + ); + } + status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(&request, status) + .await + .map_err(|err| { + format!( + "statement allowance authorization write failed: {}", + err.reason + ) + })?; + require_session()?; + } + if status != PermissionAuthorizationStatus::Authorized { + return Err("Statement allowance authorization denied".to_string()); + } + Ok(()) + } + + pub(in crate::runtime) async fn check_statement_store_allowance( + &self, + session: &crate::runtime::authority::AuthoritySession, + derivation_index: Option, + ) -> Result<(), String> { + let status = self + .permission_authorization_status( + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }, + ) + .await + .map_err(|err| { + format!( + "statement allowance authorization read failed: {}", + err.reason + ) + })?; + if self.authority.current_session().as_ref() != Some(session) { + return Err("Statement allowance session changed".to_string()); + } + if status != PermissionAuthorizationStatus::Authorized { + return Err("Statement allowance authorization denied".to_string()); + } + Ok(()) + } +} + #[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] @@ -35,11 +151,50 @@ impl ResourceAllocation for ProductRuntimeHost { ))); }; + let require_session = || { + if self.authority.current_session().as_ref() == Some(&session) { + Ok(()) + } else { + Err(CallError::HostFailure { + reason: "Resource allocation session changed".to_string(), + }) + } + }; + let product_id = self.product_id(); + let service = self.permissions_service(&product_id); + let mut grants = Vec::new(); + for resource in &inner.resources { + let derivation_index = match resource { + v01::AllocatableResource::StatementStoreAllowance => None, + v01::AllocatableResource::ProductStatementStoreAllowance(index) => { + Some(index.clone()) + } + _ => continue, + }; + let request = + PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index }; + if grants.iter().any(|(existing, _)| existing == &request) { + continue; + } + let status = service + .authorization_status(&request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + if status == PermissionAuthorizationStatus::Denied { + return Err(CallError::Denied); + } + grants.push((request, status)); + } + + // An explicit request means additional quota, not merely ensure. Always + // confirm it, even when implicit provisioning has a durable grant. The + // same review establishes any missing grants without a second prompt. let confirmed = self .platform .confirm_user_action(UserConfirmationReview::ResourceAllocation( ResourceAllocationReview { - calling_product_id: self.product_id(), + calling_product_id: product_id.clone(), resources: inner.resources.clone(), }, )) @@ -47,13 +202,51 @@ impl ResourceAllocation for ProductRuntimeHost { .map_err(|err| CallError::HostFailure { reason: format!("resource allocation confirmation failed: {err:?}"), })?; + require_session()?; + for (request, before) in &grants { + let current = service + .authorization_status(request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + // Never overwrite a decision changed by administration during the + // review, including Authorized -> NotDetermined resets. + if current != *before { + return Err(CallError::Denied); + } + } + for (request, before) in &grants { + if *before == PermissionAuthorizationStatus::NotDetermined { + let decision = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(request, decision) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + } + } if !confirmed { + // Declining an increase does not revoke an existing durable grant. return Err(CallError::Domain(HostRequestResourceAllocationError::V1( v01::ResourceAllocationError::Unknown { reason: "User rejected resource allocation".to_string(), }, ))); } + for (request, _) in &grants { + let status = service + .authorization_status(request) + .await + .map_err(|err| CallError::HostFailure { reason: err.reason })?; + require_session()?; + if status != PermissionAuthorizationStatus::Authorized { + return Err(CallError::Denied); + } + } let cx = remote_authority_context_with_default( cx, RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 58a6c7d5e..9712d8bce 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1513,6 +1513,436 @@ mod tests { ) } + #[test] + fn statement_allowance_decisions_survive_runtime_restart_and_remain_scoped() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("offline"), + ..Default::default() + }); + let selector = Some(v01::DerivationIndex::Index(0)); + let request = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: selector.clone(), + }; + futures::executor::block_on(async { + let (services, authority) = signing_runtime_with_platform(platform.clone()); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let first = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + first + .require_statement_store_allowance(&session, selector.clone()) + .await + .unwrap(); + drop(first); + drop(authority); + + let (services, authority) = signing_runtime_with_platform(platform.clone()); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let restarted = product_runtime(services.clone(), authority.clone()); + let session = authority.current_session().unwrap(); + restarted + .require_statement_store_allowance(&session, selector.clone()) + .await + .unwrap(); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert_eq!( + restarted + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + for separate in [ + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: Some(v01::DerivationIndex::Index(1)), + }, + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationRequest::IdentityDisclosure, + ] { + assert_eq!( + restarted + .permission_authorization_status(separate) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + let other = product_runtime_for(services.clone(), authority.clone(), "other.dot"); + assert_eq!( + other + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + // A connection with a different artifact store must not inherit a + // decision even with the same product id and the same authority. + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.platform = Arc::new(StubPlatform::default()); + let other_artifact = ProductRuntimeHost::from_services( + services, + adapters, + authority, + ProductContext::new("myapp.dot".to_string()).unwrap(), + ); + assert_eq!( + other_artifact + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + restarted + .set_permission_authorization_status( + request.clone(), + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + let result = ResourceAllocation::request( + &restarted, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::ProductStatementStoreAllowance( + v01::DerivationIndex::Index(0), + )], + }, + ), + ) + .await; + assert!(result.is_err()); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert_eq!( + restarted + .permission_authorization_status(request.clone()) + .await + .unwrap(), + PermissionAuthorizationStatus::Denied + ); + restarted + .set_permission_authorization_status( + request, + PermissionAuthorizationStatus::NotDetermined, + ) + .await + .unwrap(); + restarted + .require_statement_store_allowance(&session, selector) + .await + .unwrap(); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 2 + ); + }); + } + + #[test] + fn explicit_statement_increases_each_prompt_and_initial_approval_also_grants_ensure() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("offline"), + ..Default::default() + }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + for expected_reviews in 1..=2 { + // Chain availability is independent of consent: a failed + // provisioning attempt must not force a second grant prompt. + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + expected_reviews + ); + runtime + .require_statement_store_allowance(&session, None) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + expected_reviews + ); + } + }); + } + + #[test] + fn cancelling_an_explicit_increase_preserves_the_implicit_allowance_grant() { + let platform = Arc::new(StubPlatform::default()); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let grant = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }; + runtime + .set_permission_authorization_status( + grant.clone(), + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + assert!( + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + } + ) + ) + .await + .is_err() + ); + assert_eq!( + runtime + .permission_authorization_status(grant) + .await + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + runtime + .require_statement_store_allowance(&authority.current_session().unwrap(), None) + .await + .unwrap(); + assert_eq!( + platform + .resource_allocation_reviews + .lock() + .expect("reviews") + .len(), + 1 + ); + assert!(platform.sent_rpc.lock().expect("rpc").is_empty()); + }); + } + + #[test] + fn administration_during_explicit_review_wins_over_the_confirmation() { + use futures::FutureExt; + for (before, administrative) in [ + ( + PermissionAuthorizationStatus::NotDetermined, + PermissionAuthorizationStatus::Denied, + ), + ( + PermissionAuthorizationStatus::Authorized, + PermissionAuthorizationStatus::NotDetermined, + ), + ] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .expect("gate") = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority); + let grant = PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }; + runtime + .set_permission_authorization_status(grant.clone(), before) + .await + .unwrap(); + let cx = CallContext::default(); + let allocation = ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + runtime + .set_permission_authorization_status(grant.clone(), administrative) + .await + .unwrap(); + release.send(()).unwrap(); + assert!(allocation.await.is_err()); + assert_eq!( + runtime + .permission_authorization_status(grant) + .await + .unwrap(), + administrative + ); + assert!(platform.sent_rpc.lock().expect("rpc").is_empty()); + }); + } + } + + #[test] + fn statement_allowance_storage_failure_never_prompts_or_allocates() { + let platform = Arc::new(StubPlatform { + local_storage_error: Some("storage unavailable"), + resource_allocation_confirmed: true, + ..Default::default() + }); + let (services, authority) = + signing_runtime_with_platform(Arc::new(StubPlatform::default())); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.platform = platform.clone(); + let runtime = ProductRuntimeHost::from_services( + services, + adapters, + authority, + ProductContext::new("myapp.dot".to_string()).unwrap(), + ); + let result = ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::StatementStoreAllowance], + }, + ), + ) + .await; + assert!(result.is_err()); + assert!( + platform + .resource_allocation_reviews + .lock() + .unwrap() + .is_empty() + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + }); + } + + #[test] + fn statement_consent_cannot_survive_same_account_reactivation() { + use futures::FutureExt; + for explicit in [false, true] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .expect("gate lock") = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let session = authority.current_session().unwrap(); + let consent = async { + if explicit { + ResourceAllocation::request( + &runtime, + &CallContext::default(), + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::StatementStoreAllowance, + ], + }, + ), + ) + .await + .map(|_| ()) + .map_err(|error| format!("{error:?}")) + } else { + runtime + .require_statement_store_allowance(&session, None) + .await + } + }; + futures::pin_mut!(consent); + assert!(consent.as_mut().now_or_never().is_none()); + authority.disconnect().await; + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + release.send(()).unwrap(); + assert!(consent.await.is_err()); + assert_eq!( + runtime + .permission_authorization_status( + PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None + }, + ) + .await + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + assert!(platform.sent_rpc.lock().expect("rpc lock").is_empty()); + }); + } + } + fn vrf_request(product_id: &str) -> v01::HostAccountSignVrfRequest { v01::HostAccountSignVrfRequest { account: v01::ProductAccountId { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index aa3888230..0847770da 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -7,9 +7,10 @@ //! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic //! tick (`start_renewal_loop`). //! -//! All signing hosts record the ledger during allocation. The resident renewal -//! driver belongs to the native host API; browser hosts currently allocate on -//! demand without starting that driver. +//! Only host-owned wallet/paired-device targets are renewed in the background. +//! Product allowance decisions belong to product connection storage, which may +//! be artifact-scoped and is unavailable here. They are ensured on demand by an +//! authorized product request; old unscoped product ledger entries are pruned. #[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; @@ -75,6 +76,18 @@ pub enum StatementRenewalTarget { }, } +impl StatementRenewalTarget { + /// These legacy entries cannot identify the artifact-scoped decision that + /// authorized them. Never infer authorization from host-global storage. + fn is_product_grant(&self) -> bool { + match self { + Self::ProductStatementAllowance { .. } => true, + Self::Account { label, .. } => label.starts_with("product-account:"), + Self::WalletSso => false, + } + } +} + /// One persisted ledger entry. /// /// A derivation recipe resolves under whatever root entropy is active, so it @@ -190,6 +203,12 @@ async fn track_targets( owner: [u8; 32], new_targets: Vec, ) -> Result<(), String> { + if new_targets + .iter() + .any(StatementRenewalTarget::is_product_grant) + { + return Err("Product statement allowances require an authorized product request; background renewal cannot access artifact-scoped permissions".to_string()); + } let _guard = ledger_lock.lock().await; let mut entries = read_entries(storage).await?; let mut changed = false; @@ -382,7 +401,7 @@ async fn owned_targets( let (owned, foreign): (Vec<_>, Vec<_>) = read_entries(storage) .await? .into_iter() - .partition(|entry| entry.is_owned_by(owner)); + .partition(|entry| entry.is_owned_by(owner) && !entry.target.is_product_grant()); let pruned: Vec = foreign .iter() .map(|entry| target_label(&entry.target)) @@ -390,7 +409,7 @@ async fn owned_targets( if !foreign.is_empty() { warn!( dropped = ?pruned, - "pruning renewal targets promised by a previous identity" + "pruning foreign or unscoped product renewal targets" ); write_entries(storage, &owned).await?; } @@ -663,18 +682,26 @@ mod tests { fn concurrent_tracks_do_not_drop_an_entry() { let storage = YieldingStorage::default(); let ledger_lock = lock(); + let first_target = StatementRenewalTarget::Account { + account_id: [8; 32], + label: "device:08".to_string(), + }; + let second_target = StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device:09".to_string(), + }; futures::executor::block_on(async { let (first, second) = futures::join!( - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), - track_targets(&storage, &ledger_lock, OWNER, vec![product("b.dot")]), + track_targets(&storage, &ledger_lock, OWNER, vec![first_target.clone()]), + track_targets(&storage, &ledger_lock, OWNER, vec![second_target.clone()]), ); first.unwrap(); second.unwrap(); let mut targets = read_targets(&storage, OWNER).await.unwrap(); targets.sort_by_key(|target| format!("{target:?}")); - assert_eq!(targets, vec![product("a.dot"), product("b.dot")]); + assert_eq!(targets, vec![first_target, second_target]); }); } @@ -777,14 +804,19 @@ mod tests { let (pruned, tracked) = futures::join!( owned_targets(&storage, &ledger_lock, OWNER), - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), + track_targets( + &storage, + &ledger_lock, + OWNER, + vec![StatementRenewalTarget::WalletSso] + ), ); pruned.unwrap(); tracked.unwrap(); assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![product("a.dot")], + vec![StatementRenewalTarget::WalletSso], "the concurrently tracked target was overwritten by the prune" ); }); @@ -805,20 +837,25 @@ mod tests { track_targets(&storage, &lock(), OTHER_OWNER, vec![device]) .await .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); let (targets, pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - assert_eq!(targets, vec![product("a.dot")]); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso]); // Reported, not just dropped: the pass is a host's only view of the // ledger, so a silent prune is one it cannot notice or re-track. assert_eq!(pruned, vec!["device".to_string()]); // Dropped, not merely skipped, so the cost is paid once. assert_eq!( read_entries(&storage).await.unwrap(), - vec![LedgerEntry::new(product("a.dot"), OWNER)] + vec![LedgerEntry::new(StatementRenewalTarget::WalletSso, OWNER)] ); }); } @@ -849,14 +886,19 @@ mod tests { let storage = MemStorage::default(); futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); let after_seeding = storage.writes(); let (targets, _pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - assert_eq!(targets, vec![product("a.dot")]); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso]); // Every tick calls this; rewriting the ledger each time would be waste. assert_eq!(storage.writes(), after_seeding); }); @@ -919,7 +961,7 @@ mod tests { &storage, &lock(), OWNER, - vec![StatementRenewalTarget::WalletSso, product("a.dot")], + vec![StatementRenewalTarget::WalletSso], ) .await .unwrap(); @@ -928,7 +970,7 @@ mod tests { &lock(), OWNER, vec![ - product("a.dot"), + StatementRenewalTarget::WalletSso, StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string(), @@ -942,7 +984,6 @@ mod tests { read_targets(&storage, OWNER).await.unwrap(), vec![ StatementRenewalTarget::WalletSso, - product("a.dot"), StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string(), @@ -985,17 +1026,62 @@ mod tests { .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 3]) .await .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]) - .await - .unwrap(); + track_targets( + &storage, + &lock(), + OWNER, + vec![StatementRenewalTarget::WalletSso], + ) + .await + .unwrap(); assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![product("a.dot")] + vec![StatementRenewalTarget::WalletSso] ); }); } + #[test] + fn product_grants_cannot_bypass_artifact_revocation_through_renewal() { + let storage = MemStorage::default(); + let legacy = product("a.dot"); + let product_account = StatementRenewalTarget::Account { + account_id: [8; 32], + label: "product-account:a.dot".to_string(), + }; + let device = StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device:09".to_string(), + }; + futures::executor::block_on(async { + for target in [&legacy, &product_account] { + assert!( + track_targets(&storage, &lock(), OWNER, vec![target.clone()]) + .await + .is_err() + ); + } + // Seed the old on-disk format: an upgrade must stop existing promises, + // not merely prevent new ones from being recorded. + write_entries( + &storage, + &[ + LedgerEntry::new(legacy, OWNER), + LedgerEntry::new(product_account, OWNER), + LedgerEntry::new(StatementRenewalTarget::WalletSso, OWNER), + LedgerEntry::new(device.clone(), OWNER), + ], + ) + .await + .unwrap(); + let (targets, pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); + assert_eq!(targets, vec![StatementRenewalTarget::WalletSso, device]); + assert_eq!(pruned, vec!["product:a.dot", "product-account:a.dot"]); + assert_eq!(read_targets(&storage, OWNER).await.unwrap(), targets); + }); + } + #[test] fn product_target_resolves_to_allocation_derivation() { let entropy = [7u8; 32]; @@ -1111,7 +1197,7 @@ mod tests { &storage, &lock(), OWNER, - vec![device.clone(), product("a.dot")], + vec![device.clone(), StatementRenewalTarget::WalletSso], ) .await .unwrap(); @@ -1119,11 +1205,11 @@ mod tests { // The recipe resolves under any identity; the raw account does not. assert_eq!( read_targets(&storage, OWNER).await.unwrap(), - vec![device, product("a.dot")] + vec![device, StatementRenewalTarget::WalletSso] ); assert_eq!( read_targets(&storage, OTHER_OWNER).await.unwrap(), - vec![product("a.dot")] + vec![StatementRenewalTarget::WalletSso] ); }); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 7d696a4c8..9d12d6526 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -615,8 +615,6 @@ pub(super) async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { - use super::allowance_renewal::StatementRenewalTarget; - signing_host.require_current_session(session)?; let entropy = signing_host.root_entropy()?; let allowance = @@ -628,9 +626,6 @@ pub(super) async fn allocate_statement_store_allowance( product_id, allowance.public.to_bytes(), policy, - StatementRenewalTarget::ProductStatementAllowance { - product_id: product_id.to_string(), - }, ) .await?; Ok(allowance.secret.to_bytes().to_vec()) @@ -644,8 +639,6 @@ pub(super) async fn allocate_product_statement_store_allowance( derivation_index: &v01::DerivationIndex, policy: OnExistingAllowancePolicy, ) -> Result<(), AllowanceAllocationError> { - use super::allowance_renewal::StatementRenewalTarget; - signing_host.require_current_session(session)?; let target = signing_host .product_keypair(&v01::ProductAccountId { @@ -654,19 +647,8 @@ pub(super) async fn allocate_product_statement_store_allowance( })? .public .to_bytes(); - register_statement_store_target( - services, - signing_host, - session, - product_id, - target, - policy, - StatementRenewalTarget::Account { - account_id: target, - label: format!("product-account:{product_id}"), - }, - ) - .await + register_statement_store_target(services, signing_host, session, product_id, target, policy) + .await } async fn register_statement_store_target( @@ -676,9 +658,7 @@ async fn register_statement_store_target( product_id: &str, target: [u8; 32], policy: OnExistingAllowancePolicy, - renewal_target: super::allowance_renewal::StatementRenewalTarget, ) -> Result<(), AllowanceAllocationError> { - use super::allowance_renewal; use crate::runtime::statement_allowance::{ self, PooledRegistrationParams, allocated_in, find_including_rings, register_statement_account_pooled, scan_collections, @@ -710,6 +690,7 @@ async fn register_statement_store_target( reuse_existing, ) .await?; + signing_host.require_current_session(session)?; if let Some((collection, seq)) = allocated_in(&scans) { debug!( %product_id, @@ -774,10 +755,6 @@ async fn register_statement_store_target( } } signing_host.require_current_session(session)?; - if let Err(reason) = allowance_renewal::track(signing_host, vec![renewal_target]).await { - warn!(%product_id, %reason, "failed to record statement-store renewal target"); - } - signing_host.require_current_session(session)?; Ok(()) } @@ -1101,7 +1078,7 @@ mod tests { /// rather than passing quietly. #[cfg(not(target_arch = "wasm32"))] #[test] - fn an_existing_allowance_is_served_without_touching_the_ring() { + fn repeated_implicit_provisioning_reuses_existing_allowance_without_submission() { use futures::FutureExt; use crate::host_logic::product_account::derive_sr25519_hard_path; @@ -1151,35 +1128,58 @@ mod tests { "state_getStorage", format!(r#""0x{}""#, hex::encode(&slot_entry)), ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(b"paseo".to_vec().encode())), + ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(&slot_entry)), + ), + ( + "state_getRuntimeVersion", + r#"{"specVersion":1000000,"transactionVersion":1}"#.to_string(), + ), + ( + "chain_getBlockHash", + format!(r#""0x{}""#, hex::encode([0u8; 32])), + ), + ( + "RuntimeViewFunction_execute_view_function", + format!( + r#""0x{}""#, + hex::encode(Ok::, ()>(20u32.encode()).encode()), + ), + ), ], ..Default::default() }); - let (services, signing_host) = signing_fixture(platform.clone()); + let (_services, signing_host) = signing_fixture(platform.clone()); // Bounded, because the failure mode of losing the early return is a // wait on a chain read the stub deliberately does not answer — an // unbounded test would hang instead of reporting. The bound is generous // because it is catching a hang, not asserting latency. - let secret = futures::executor::block_on(async { + futures::executor::block_on(async { let session = signing_host.current_session().unwrap(); - futures::select! { - result = allocate_statement_store_allowance( - &services, - &signing_host, + let cx = truapi::CallContext::default(); + for _ in 0..2 { + let allocation = signing_host.statement_store_allowance_key( + &cx, &session, - product_id, - OnExistingAllowancePolicy::Ignore, - ) - .fuse() => result, - _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { - panic!("allocation blocked on a chain read it should not have made") + product_id.to_string(), + ); + futures::pin_mut!(allocation); + let response = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("allocation blocked on a chain read it should not have made") + } } + .expect("existing allowance succeeds"); + assert_eq!(response.public_key, allowance.public.to_bytes()); } - }) - .expect("an existing allowance is returned"); - - assert_eq!(secret, allowance.secret.to_bytes().to_vec()); - + }); let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); let methods: Vec = sent .iter() @@ -1205,15 +1205,6 @@ mod tests { .any(|method| method.starts_with("author_submit")), "an extrinsic was submitted for an allowance already in place: {methods:?}" ); - // The suffix and one slot read answered it; the scan stopped at the first match. - assert_eq!( - methods - .iter() - .filter(|method| *method == "state_getStorage") - .count(), - 2, - "expected one suffix and one slot read: {methods:?}" - ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 3e3300cee..b1bb5f94e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -678,7 +678,7 @@ mod tests { /// Run `scan_slot_excluding` for `[0x22; 32]` against a scripted period /// whose slot occupancy is `slots`. - fn scripted_find(slots: &[Option<[u8; 32]>]) -> SlotSelection { + fn scripted_find(slots: &[Option<[u8; 32]>], reuse_existing: bool) -> SlotSelection { let metadata = test_fixtures::people(); let entries: Vec = slots .iter() @@ -697,12 +697,23 @@ mod tests { period: 7, target: &[0x22; 32], excluded: &[], - reuse_existing: true, + reuse_existing, }, )) .unwrap() } + #[test] + fn an_explicit_increase_chooses_an_additional_slot_instead_of_reusing_quota() { + let mut slots = [None; SLOTS]; + slots[0] = Some([0x22; 32]); + assert_eq!( + scripted_find(&slots, true), + SlotSelection::AlreadyAllocated(0) + ); + assert_eq!(scripted_find(&slots, false), SlotSelection::Free(1)); + } + /// The scan bound is whatever Asset Hub declares, not a compiled-in constant, /// and it bounds how many keys a full scan hashes and reads. /// @@ -724,7 +735,7 @@ mod tests { #[test] fn an_empty_period_offers_the_first_slot() { - assert_eq!(scripted_find(&[None; SLOTS]), SlotSelection::Free(0)); + assert_eq!(scripted_find(&[None; SLOTS], true), SlotSelection::Free(0)); } #[test] @@ -732,12 +743,15 @@ mod tests { let mut slots = [None; SLOTS]; slots[2] = Some([0x22; 32]); - assert_eq!(scripted_find(&slots), SlotSelection::AlreadyAllocated(2)); + assert_eq!( + scripted_find(&slots, true), + SlotSelection::AlreadyAllocated(2) + ); } #[test] fn a_table_filled_by_other_accounts_reports_full_rather_than_erroring() { - let SlotSelection::Full { max, occupied } = scripted_find(&[Some([0x99; 32]); SLOTS]) + let SlotSelection::Full { max, occupied } = scripted_find(&[Some([0x99; 32]); SLOTS], true) else { panic!("a full table should report Full"); }; diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 602895732..193d5c508 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -358,6 +358,9 @@ impl ProductRuntimeHost { .authority .current_session() .ok_or(StatementProofFailure::NoSession)?; + self.require_statement_store_allowance(&session, None) + .await + .map_err(StatementProofFailure::UnableToSign)?; let cx = remote_authority_context(cx); let allowance = remote_authority_call( &cx, @@ -366,6 +369,9 @@ impl ProductRuntimeHost { ) .await .map_err(statement_authority_failure)?; + self.check_statement_store_allowance(&session, None) + .await + .map_err(StatementProofFailure::UnableToSign)?; create_statement_proof_with_key(statement, &allowance) } } @@ -583,6 +589,7 @@ mod tests { let payload = statement_payload(statement.clone()); let (allowance_secret, expected_signer) = allowance_key(11); let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, sso_response_script: Some(sso_success_response_script( &session, crate::host_logic::sso::messages::RemoteMessage { @@ -612,7 +619,7 @@ mod tests { ); host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("proof-auth-1".to_string()); - let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement.clone()); let response = futures::executor::block_on(StatementStore::create_proof_authorized( &host, &cx, request, @@ -625,6 +632,22 @@ mod tests { }; assert_eq!(signer, expected_signer); assert_sr25519_signature(signer, signature, &payload); + futures::executor::block_on(host.set_permission_authorization_status( + truapi_platform::PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + truapi_platform::PermissionAuthorizationStatus::Denied, + )) + .unwrap(); + // Cached private material must not bypass a revoked product decision. + assert!( + futures::executor::block_on(StatementStore::create_proof_authorized( + &host, + &cx, + RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement), + )) + .is_err() + ); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 6ee0643f7..2a5642f18 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -2429,22 +2429,38 @@ fn resource_allocation_rejects_without_session() { } #[test] -fn resource_allocation_rejects_when_user_declines() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); +fn resource_allocation_remembers_denial_without_provisioning() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); install_pairing_session(&host, session_info()); - let cx = CallContext::default(); - let err = futures::executor::block_on(ResourceAllocation::request( - &host, - &cx, - resource_allocation_request(), - )) - .unwrap_err(); - match err { - CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { reason }, - )) => assert_eq!(reason, "User rejected resource allocation"), - other => panic!("expected user-rejected resource allocation error, got {other:?}"), - } + futures::executor::block_on(async { + for _ in 0..2 { + assert!( + ResourceAllocation::request( + &host, + &CallContext::default(), + resource_allocation_request(), + ) + .await + .is_err() + ); + } + assert_eq!( + host.permission_authorization_status( + truapi_platform::PermissionAuthorizationRequest::StatementStoreAllowance { + derivation_index: None, + }, + ) + .await + .unwrap(), + truapi_platform::PermissionAuthorizationStatus::Denied + ); + }); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); } #[test] From 0d70243e5569cef56ce2b899b6e069c8d4d365b8 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 16 Sep 2026 01:01:52 -0400 Subject: [PATCH 20/67] Keep native renewal ledger storage out of browser builds --- .../runtime/signing_host/allowance_renewal.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index 0847770da..6fe69310f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -20,17 +20,22 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use futures::lock::Mutex; +#[cfg(any(test, not(target_arch = "wasm32")))] use parity_scale_codec::{Decode, Encode}; #[cfg(not(target_arch = "wasm32"))] use tracing::debug; #[cfg(any(test, not(target_arch = "wasm32")))] use tracing::info; +#[cfg(any(test, not(target_arch = "wasm32")))] use tracing::warn; +#[cfg(any(test, not(target_arch = "wasm32")))] use truapi_platform::{CoreStorage, CoreStorageKey}; +#[cfg(any(test, not(target_arch = "wasm32")))] use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use super::sso_responder::current_unix_secs; +#[cfg(any(test, not(target_arch = "wasm32")))] use crate::host_logic::product_account::derive_root_keypair_from_entropy; #[cfg(any(test, not(target_arch = "wasm32")))] use crate::host_logic::product_account::{derive_identity_keypair, derive_sr25519_hard_path}; @@ -58,6 +63,7 @@ const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); /// Entropy-derived variants are recipes, not raw account ids, so the ledger /// survives root-entropy rotation (the CLI rotates auto-managed accounts on /// slot exhaustion). +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] pub enum StatementRenewalTarget { /// `//allowance//statement-store//{product_id}` from the active root entropy. @@ -76,6 +82,7 @@ pub enum StatementRenewalTarget { }, } +#[cfg(any(test, not(target_arch = "wasm32")))] impl StatementRenewalTarget { /// These legacy entries cannot identify the artifact-scoped decision that /// authorized them. Never infer authorization from host-global storage. @@ -95,12 +102,14 @@ impl StatementRenewalTarget { /// not re-derive, so it records the root public key that promised it and is /// ignored under any other identity: without that, a later account would spend /// its own slot-table capacity keeping a previous account's peer allowed. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] struct LedgerEntry { target: StatementRenewalTarget, owner: Option<[u8; 32]>, } +#[cfg(any(test, not(target_arch = "wasm32")))] impl LedgerEntry { /// Record `target` under `owner`, which only raw account ids retain. fn new(target: StatementRenewalTarget, owner: [u8; 32]) -> Self { @@ -121,6 +130,7 @@ impl LedgerEntry { /// Root public key of the identity rooted at `entropy`, used to own raw ledger /// entries. +#[cfg(any(test, not(target_arch = "wasm32")))] fn owner_key(entropy: &[u8]) -> Result<[u8; 32], String> { derive_root_keypair_from_entropy(entropy) .map(|pair| pair.public.to_bytes()) @@ -135,6 +145,7 @@ pub(super) struct RenewalState { registration_lock: Mutex<()>, /// Serializes read-modify-write cycles on the ledger so a concurrent /// allocation cannot drop another's entry. + #[cfg(any(test, not(target_arch = "wasm32")))] ledger_lock: Mutex<()>, #[cfg(not(target_arch = "wasm32"))] loop_started: AtomicBool, @@ -152,6 +163,7 @@ impl RenewalState { &self.registration_lock } + #[cfg(any(test, not(target_arch = "wasm32")))] fn ledger_lock(&self) -> &Mutex<()> { &self.ledger_lock } @@ -178,6 +190,7 @@ impl RenewalState { /// the pass: the entries are recipes and raw account ids that /// [`track_targets`] rebuilds on the next allocation or pairing, so refusing to /// renew anything is strictly worse than starting over. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn read_entries(storage: &(impl CoreStorage + ?Sized)) -> Result, String> { let Some(blob) = storage .read_core_storage(CoreStorageKey::StatementRenewalTargets) @@ -197,6 +210,7 @@ async fn read_entries(storage: &(impl CoreStorage + ?Sized)) -> Result, @@ -252,6 +266,7 @@ async fn untrack_account( Ok(true) } +#[cfg(any(test, not(target_arch = "wasm32")))] async fn write_entries( storage: &(impl CoreStorage + ?Sized), entries: &[LedgerEntry], @@ -262,6 +277,7 @@ async fn write_entries( .map_err(|err| format!("renewal ledger write failed: {}", err.reason)) } +#[cfg(any(test, not(target_arch = "wasm32")))] fn decode_entries(blob: &[u8]) -> Result, String> { let mut input = blob; let entries = Vec::::decode(&mut input) @@ -321,6 +337,7 @@ fn resolve_target( } /// Record `targets` in the ledger under the active identity. +#[cfg(any(test, not(target_arch = "wasm32")))] pub(super) async fn track( signing_host: &SigningHost, targets: Vec, From d0c157ae3a5d61e08258321fc700b1ecc03f8ea0 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 16 Sep 2026 01:30:04 -0400 Subject: [PATCH 21/67] test: isolate method-keyed RPC fixtures across reconnects --- rust/crates/truapi-server/src/test_support.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 6ae9afab6..ab2d30b1d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1018,6 +1018,8 @@ struct RecordingConnection { sent: Arc>>, responses: Vec, method_responses: Vec<(&'static str, String)>, + /// Method scripts must not replay requests from a previously closed connection. + method_requests: Arc>>, sso_response_script: Option, auth_states: Arc>>, pairing_success_response: bool, @@ -1159,6 +1161,12 @@ fn sso_scripted_responses( impl JsonRpcConnection for RecordingConnection { fn send(&self, request: String) { + if !self.method_responses.is_empty() { + self.method_requests + .lock() + .expect("connection rpc list mutex poisoned") + .push(request.clone()); + } self.sent .lock() .expect("rpc list mutex poisoned") @@ -1304,7 +1312,10 @@ impl JsonRpcConnection for RecordingConnection { return sso_scripted_responses(self.sent.clone(), script); } if !self.method_responses.is_empty() { - return method_keyed_responses(self.sent.clone(), self.method_responses.clone()); + return method_keyed_responses( + self.method_requests.clone(), + self.method_responses.clone(), + ); } if self.responses.is_empty() { Box::pin(futures::stream::pending()) @@ -1483,6 +1494,7 @@ impl ChainProvider for StubPlatform { sent: self.sent_rpc.clone(), responses: self.rpc_responses.clone(), method_responses: self.rpc_method_responses.clone(), + method_requests: Arc::default(), sso_response_script: self.sso_response_script.clone(), auth_states: self.auth_states.clone(), pairing_success_response: self.pairing_success_response, From 05d8cb83a20cdce487b42d16434a7a61f9fad660 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 16 Sep 2026 22:50:28 -0400 Subject: [PATCH 22/67] fix(wasm): gate native renewal inspection --- .../src/runtime/signing_host/allowance_renewal.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index e4e9faacb..c71d0b279 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -113,6 +113,7 @@ struct LedgerEntry { /// /// Mirrors the persisted entry rather than resolving it: resolution needs root /// entropy, and a host inspecting its slots may hold none. +#[cfg(any(test, not(target_arch = "wasm32")))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct TrackedStatementRenewalTarget { /// The account, or the recipe for one, that the host promised to renew. @@ -256,6 +257,7 @@ async fn track_targets( /// /// Takes the ledger lock so a listing taken while a track or a prune is running /// reports the settled ledger rather than the state it is replacing. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn list_entries( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -389,6 +391,7 @@ pub(super) async fn track( /// Reads storage alone. A host that has not unlocked an identity still gets /// the list, which is the case a scheduled task runs in: it wakes, asks what /// its finite slots are spent on, and decides whether to renew at all. +#[cfg(any(test, not(target_arch = "wasm32")))] pub(super) async fn list( signing_host: &SigningHost, ) -> Result, String> { @@ -404,6 +407,7 @@ pub(super) async fn list( /// Pairs with [`list`], which reports each entry's owner as stored: comparing /// the two is how a host tells the entries it will actually renew from the ones /// a pass will prune. +#[cfg(any(test, not(target_arch = "wasm32")))] pub(super) fn active_owner_key(signing_host: &SigningHost) -> Result<[u8; 32], String> { let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; owner_key(&entropy) From 97ea37b63a4795f4fdf2e0fad10366d6682fc265 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 21 Sep 2026 19:58:09 -0400 Subject: [PATCH 23/67] feat(chat): move native Chat and main-purse payments into shared Host --- .changeset/chat-product-authority.md | 45 + Cargo.lock | 207 +- LICENSE-AGPL-3.0 | 661 ++++ README.md | 101 +- .../parity/truapi/AndroidHostCoreStorage.kt | 166 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 110 +- docs/rfcs/0017-coinage-payment.md | 15 + docs/rfcs/native-chat-main-purse.md | 454 +++ .../ConnectionTransportFactory.swift | 66 +- .../Username/Networking/UsernameApi.swift | 1 + .../Networking/UsernameRequestModel.swift | 5 +- .../MainPursePaymentPromptViewFactory.swift | 124 + .../TrUAPIChainConnectionPool.swift | 272 +- .../Connection/TrUAPIChainRpcAdapter.swift | 34 +- .../TrUAPI/RustHostRuntimeBridge.swift | 95 + .../TrUAPI/RustProductExecutionBridge.swift | 45 +- .../TrUAPI/RustRuntimeEnvironment.swift | 7 +- .../TrUAPI/Storage/HostStorageBackends.swift | 18 +- .../TrUAPI/Storage/TrUAPIChatFileStore.swift | 376 ++ .../TrUAPI/Storage/TrUAPIWalletStorage.swift | 150 + .../TrUAPI/TrUAPIChatFilePresenter.swift | 138 + .../TrUAPI/TrUAPIConfirmationPresenter.swift | 15 + .../TrUAPI/TrUAPIHostRuntimeProvider.swift | 16 +- .../TrUAPI/TrUAPINativeChatFiles.swift | 77 + .../TrUAPI/Mocks/MockChainConnections.swift | 2 + .../Mocks/UnavailableNativeChatFiles.swift | 4 + .../TrUAPI/RustHostRuntimeBridgeTests.swift | 4 +- .../TrUAPI/RustRuntimeBridgeTests.swift | 2 + .../TrUAPI/TrUAPIChatFileStoreTests.swift | 247 ++ .../TrUAPIIdentityCandidatesTests.swift | 53 + .../TrUAPI/TrUAPIStorageTests.swift | 26 + ios/truapi-host/README.md | 7 + .../Sources/TrUAPIHost/TrUAPIHost.swift | 130 +- js/packages/truapi-host/LICENSE-AGPL-3.0 | 661 ++++ js/packages/truapi-host/NOTICE | 17 + js/packages/truapi-host/README.md | 26 + js/packages/truapi-host/package.json | 6 +- .../truapi-host/src/adapter-support.ts | 143 +- .../src/host-callbacks-adapter.test.ts | 183 + js/packages/truapi-host/src/runtime.ts | 15 +- js/packages/truapi-host/src/test-support.ts | 20 + js/packages/truapi-host/src/wasm-module.ts | 1 + .../src/web/create-worker-host-runtime.ts | 271 +- js/packages/truapi-host/src/web/index.ts | 2 + .../truapi-host/src/web/native-chat-files.ts | 422 +++ .../src/web/native-chat-media.test.ts | 168 + .../truapi-host/src/web/native-chat-media.ts | 219 ++ .../src/web/worker-provider.test.ts | 351 +- .../truapi-host/src/worker-callbacks.test.ts | 10 + .../truapi-host/src/worker-dispatch.test.ts | 18 +- .../truapi-host/src/worker-dispatch.ts | 8 +- .../truapi-host/src/worker-protocol.ts | 14 +- js/packages/truapi-host/src/worker-runtime.ts | 171 +- js/packages/truapi/src/client.test.ts | 46 +- package-lock.json | 2 +- rust/crates/truapi-client/src/generated.rs | 4 +- rust/crates/truapi-client/src/lib.rs | 58 + rust/crates/truapi-codegen/src/main.rs | 4 + .../truapi-codegen/src/platform_callbacks.rs | 18 + .../truapi-codegen/src/rust/wasm_bridge.rs | 16 +- .../truapi-codegen/src/ts/host_callbacks.rs | 148 +- .../tests/golden/host-callbacks-adapter.ts | 82 +- .../tests/golden/host-callbacks.ts | 375 +- .../tests/golden/wasm_bridge.rs | 154 +- .../tests/golden/worker-callbacks.ts | 73 +- .../truapi-codegen/tests/golden_rust_emit.rs | 55 - rust/crates/truapi-coinage/Cargo.toml | 41 + rust/crates/truapi-coinage/LICENSE | 661 ++++ rust/crates/truapi-coinage/LICENSE-MIT | 21 + rust/crates/truapi-coinage/NOTICE | 42 + rust/crates/truapi-coinage/src/allocator.rs | 176 + rust/crates/truapi-coinage/src/balance.rs | 249 ++ rust/crates/truapi-coinage/src/claim.rs | 752 ++++ rust/crates/truapi-coinage/src/claim_plan.rs | 232 ++ rust/crates/truapi-coinage/src/clock.rs | 33 + rust/crates/truapi-coinage/src/constants.rs | 49 + .../crates/truapi-coinage/src/denomination.rs | 234 ++ rust/crates/truapi-coinage/src/index_store.rs | 161 + rust/crates/truapi-coinage/src/keys.rs | 438 +++ rust/crates/truapi-coinage/src/lib.rs | 165 + rust/crates/truapi-coinage/src/members.rs | 60 + rust/crates/truapi-coinage/src/memo.rs | 240 ++ rust/crates/truapi-coinage/src/model.rs | 285 ++ .../truapi-coinage/src/outgoing_transfer.rs | 922 +++++ rust/crates/truapi-coinage/src/pallet.rs | 963 +++++ rust/crates/truapi-coinage/src/query.rs | 1441 ++++++++ rust/crates/truapi-coinage/src/recipient.rs | 581 +++ rust/crates/truapi-coinage/src/recovery.rs | 1296 +++++++ rust/crates/truapi-coinage/src/repo.rs | 571 +++ rust/crates/truapi-coinage/src/ring_proof.rs | 295 ++ .../crates/truapi-coinage/src/secret_claim.rs | 1649 +++++++++ rust/crates/truapi-coinage/src/selection.rs | 911 +++++ rust/crates/truapi-coinage/src/sync.rs | 492 +++ rust/crates/truapi-coinage/src/tasks.rs | 229 ++ rust/crates/truapi-coinage/src/timer.rs | 8 + .../truapi-coinage/src/transfer_sender.rs | 1992 +++++++++++ .../truapi-coinage/src/tx_extensions.rs | 182 + .../truapi-coinage/src/voucher_location.rs | 847 +++++ rust/crates/truapi-coinage/src/wal.rs | 342 ++ rust/crates/truapi-host-cli/Cargo.toml | 6 +- rust/crates/truapi-host-cli/LICENSE | 21 + rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 | 661 ++++ rust/crates/truapi-host-cli/NOTICE | 17 + rust/crates/truapi-host-cli/README.md | 22 +- .../crates/truapi-host-cli/src/attestation.rs | 297 +- rust/crates/truapi-host-cli/src/chain.rs | 226 +- rust/crates/truapi-host-cli/src/chat_files.rs | 737 ++++ rust/crates/truapi-host-cli/src/main.rs | 49 +- rust/crates/truapi-host-cli/src/network.rs | 33 + rust/crates/truapi-host-cli/src/platform.rs | 191 +- rust/crates/truapi-host-cli/src/sessions.rs | 24 + .../crates/truapi-host-cli/src/terminal_ui.rs | 278 +- rust/crates/truapi-platform/src/lib.rs | 305 +- rust/crates/truapi-server/Cargo.toml | 4 +- rust/crates/truapi-server/LICENSE | 21 + rust/crates/truapi-server/LICENSE-AGPL-3.0 | 661 ++++ rust/crates/truapi-server/NOTICE | 17 + rust/crates/truapi-server/README.md | 38 + .../crates/truapi-server/src/chain_runtime.rs | 1 - rust/crates/truapi-server/src/host_core.rs | 38 +- .../src/host_logic/sso/messages.rs | 94 +- .../truapi-server/src/host_logic/sso/wire.rs | 4 +- rust/crates/truapi-server/src/native.rs | 438 ++- rust/crates/truapi-server/src/runtime.rs | 38 +- .../truapi-server/src/runtime/authority.rs | 677 +--- .../truapi-server/src/runtime/bulletin_rpc.rs | 6 +- .../src/runtime/capabilities/account.rs | 141 +- .../truapi-server/src/runtime/chat_device.rs | 1214 +++++++ .../src/runtime/chat_device/rich.rs | 138 + .../src/runtime/chat_identity.rs | 288 ++ .../src/runtime/coinage_chain.rs | 569 +++ .../src/runtime/coinage_chain/crypto.rs | 125 + .../src/runtime/coinage_chain/rpc.rs | 350 ++ .../src/runtime/coinage_chain/sender.rs | 607 ++++ .../src/runtime/coinage_chain/tests.rs | 580 +++ .../src/runtime/coinage_chain/transaction.rs | 748 ++++ .../src/runtime/coinage_store.rs | 473 +++ .../src/runtime/coinage_store/codec.rs | 459 +++ .../src/runtime/coinage_store/repositories.rs | 494 +++ .../src/runtime/coinage_store/tests.rs | 878 +++++ .../truapi-server/src/runtime/native_chat.rs | 444 +++ .../src/runtime/native_chat/actor.rs | 1252 +++++++ .../src/runtime/native_chat/actor/files.rs | 653 ++++ .../native_chat/actor/files/transfer.rs | 679 ++++ .../src/runtime/native_chat/actor/history.rs | 265 ++ .../src/runtime/native_chat/actor/receive.rs | 1153 ++++++ .../src/runtime/native_chat/actor/tests.rs | 3118 +++++++++++++++++ .../native_chat/actor/tests/hop_history.rs | 398 +++ .../actor/tests/hop_history/attachments.rs | 334 ++ .../src/runtime/native_chat/background.rs | 395 +++ .../src/runtime/native_chat/hop.rs | 1071 ++++++ .../src/runtime/native_chat/hop/tests.rs | 485 +++ .../src/runtime/native_chat/hop_access.rs | 102 + .../src/runtime/native_chat/identity.rs | 213 ++ .../src/runtime/native_chat/identity/dotns.rs | 412 +++ .../src/runtime/native_chat/identity/rpc.rs | 272 ++ .../runtime/native_chat/identity/schema.rs | 430 +++ .../native_chat/identity/schema_tests.rs | 201 ++ .../src/runtime/native_chat/payments.rs | 1357 +++++++ .../runtime/native_chat/payments/engine.rs | 279 ++ .../runtime/native_chat/payments/inventory.rs | 462 +++ .../src/runtime/native_chat/payments/tests.rs | 1142 ++++++ .../src/runtime/native_chat/store.rs | 414 +++ .../src/runtime/native_chat/store/codec.rs | 69 + .../src/runtime/native_chat/store/tests.rs | 769 ++++ .../truapi-server/src/runtime/pairing_host.rs | 6 +- .../src/runtime/pairing_host/sso_channel.rs | 102 +- .../truapi-server/src/runtime/services.rs | 19 + .../truapi-server/src/runtime/signing_host.rs | 562 +-- .../src/runtime/signing_host/sso_responder.rs | 52 +- .../src/runtime/signing_host/sso_service.rs | 141 +- .../src/runtime/statement_allowance.rs | 18 + .../runtime/statement_allowance/extension.rs | 10 +- .../src/runtime/statement_allowance/view.rs | 2 +- .../src/runtime/statement_store.rs | 2 +- .../src/runtime/statement_store_rpc.rs | 89 +- rust/crates/truapi-server/src/test_support.rs | 210 +- rust/crates/truapi-server/src/wasm.rs | 274 +- rust/crates/truapi-server/tests/common/mod.rs | 57 +- .../truapi-server/tests/wire_result_shape.rs | 81 +- rust/crates/truapi/src/api/account.rs | 27 +- rust/crates/truapi/src/lib.rs | 14 +- rust/crates/truapi/src/v01/account.rs | 150 - rust/crates/truapi/src/v02.rs | 2 + rust/crates/truapi/src/v02/account.rs | 408 +++ rust/crates/truapi/src/versioned/account.rs | 16 +- scripts/codegen.sh | 6 +- 187 files changed, 52405 insertions(+), 2174 deletions(-) create mode 100644 LICENSE-AGPL-3.0 create mode 100644 android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt create mode 100644 docs/rfcs/native-chat-main-purse.md create mode 100644 hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/MainPursePaymentPromptViewFactory.swift create mode 100644 hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIChatFileStore.swift create mode 100644 hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIWalletStorage.swift create mode 100644 hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIChatFilePresenter.swift create mode 100644 hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPINativeChatFiles.swift create mode 100644 hosts/ios/polkadot-appTests/TrUAPI/Mocks/UnavailableNativeChatFiles.swift create mode 100644 hosts/ios/polkadot-appTests/TrUAPI/TrUAPIChatFileStoreTests.swift create mode 100644 hosts/ios/polkadot-appTests/TrUAPI/TrUAPIIdentityCandidatesTests.swift create mode 100644 js/packages/truapi-host/LICENSE-AGPL-3.0 create mode 100644 js/packages/truapi-host/NOTICE create mode 100644 js/packages/truapi-host/src/web/native-chat-files.ts create mode 100644 js/packages/truapi-host/src/web/native-chat-media.test.ts create mode 100644 js/packages/truapi-host/src/web/native-chat-media.ts create mode 100644 rust/crates/truapi-coinage/Cargo.toml create mode 100644 rust/crates/truapi-coinage/LICENSE create mode 100644 rust/crates/truapi-coinage/LICENSE-MIT create mode 100644 rust/crates/truapi-coinage/NOTICE create mode 100644 rust/crates/truapi-coinage/src/allocator.rs create mode 100644 rust/crates/truapi-coinage/src/balance.rs create mode 100644 rust/crates/truapi-coinage/src/claim.rs create mode 100644 rust/crates/truapi-coinage/src/claim_plan.rs create mode 100644 rust/crates/truapi-coinage/src/clock.rs create mode 100644 rust/crates/truapi-coinage/src/constants.rs create mode 100644 rust/crates/truapi-coinage/src/denomination.rs create mode 100644 rust/crates/truapi-coinage/src/index_store.rs create mode 100644 rust/crates/truapi-coinage/src/keys.rs create mode 100644 rust/crates/truapi-coinage/src/lib.rs create mode 100644 rust/crates/truapi-coinage/src/members.rs create mode 100644 rust/crates/truapi-coinage/src/memo.rs create mode 100644 rust/crates/truapi-coinage/src/model.rs create mode 100644 rust/crates/truapi-coinage/src/outgoing_transfer.rs create mode 100644 rust/crates/truapi-coinage/src/pallet.rs create mode 100644 rust/crates/truapi-coinage/src/query.rs create mode 100644 rust/crates/truapi-coinage/src/recipient.rs create mode 100644 rust/crates/truapi-coinage/src/recovery.rs create mode 100644 rust/crates/truapi-coinage/src/repo.rs create mode 100644 rust/crates/truapi-coinage/src/ring_proof.rs create mode 100644 rust/crates/truapi-coinage/src/secret_claim.rs create mode 100644 rust/crates/truapi-coinage/src/selection.rs create mode 100644 rust/crates/truapi-coinage/src/sync.rs create mode 100644 rust/crates/truapi-coinage/src/tasks.rs create mode 100644 rust/crates/truapi-coinage/src/timer.rs create mode 100644 rust/crates/truapi-coinage/src/transfer_sender.rs create mode 100644 rust/crates/truapi-coinage/src/tx_extensions.rs create mode 100644 rust/crates/truapi-coinage/src/voucher_location.rs create mode 100644 rust/crates/truapi-coinage/src/wal.rs create mode 100644 rust/crates/truapi-host-cli/LICENSE create mode 100644 rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 create mode 100644 rust/crates/truapi-host-cli/NOTICE create mode 100644 rust/crates/truapi-host-cli/src/chat_files.rs create mode 100644 rust/crates/truapi-server/LICENSE create mode 100644 rust/crates/truapi-server/LICENSE-AGPL-3.0 create mode 100644 rust/crates/truapi-server/NOTICE create mode 100644 rust/crates/truapi-server/src/runtime/chat_device.rs create mode 100644 rust/crates/truapi-server/src/runtime/chat_device/rich.rs create mode 100644 rust/crates/truapi-server/src/runtime/chat_identity.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain/crypto.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain/rpc.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain/sender.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain/tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_chain/transaction.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_store.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_store/codec.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_store/repositories.rs create mode 100644 rust/crates/truapi-server/src/runtime/coinage_store/tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/files.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/files/transfer.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history/attachments.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/background.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/hop.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/hop/tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/hop_access.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/identity.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/identity/dotns.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/identity/rpc.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/identity/schema.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/identity/schema_tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/payments.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/payments/engine.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/payments/inventory.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/payments/tests.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/store.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/store/codec.rs create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/store/tests.rs create mode 100644 rust/crates/truapi/src/v02/account.rs diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md index 3071334ac..25a6973d1 100644 --- a/.changeset/chat-product-authority.md +++ b/.changeset/chat-product-authority.md @@ -7,3 +7,48 @@ boundary to local and SSO sessions, expose it in the iOS permission flow, and av results. Support keyless Statement Store allowances for a selected product account in the native signing host. + +Replace raw guest Chat crypto with a Host-owned native actor on account method 12; +retire method 11, including over SSO. Keep main-purse Coinage secrets and durable +claim/recovery plans behind trusted per-payment review. Add private nested HOP +history recovery and resumable native file/image/video attachments with trusted +selection/export, metadata-only guest views and live Bulletin endpoint/session +fences. The combined signing runtime includes AGPL-3.0-only code; retain the +included provenance, licenses and exact Corresponding Source. + +Align Coinage keys with current iOS MAIN_PURSE/page-0 derivations, including the +soft coin item junction. Keep complete exported coin secrets stable for durable +payment replay. Authenticate the new purse layout through snapshot version 3; +reject legacy `//pps` snapshots without discarding pending wallet state. Native +iOS allocator sharing remains a separate integration requirement. + +Read origin-specific free Coinage unload-token limits from the runtime view at +the finalized planning snapshot, rather than a removed metadata constant. +Recover full Statement Store accounts by refreshing only the signed statement's +priority while preserving committed ciphertext and payment IDs; continue other +peers' queued deliveries when one account or channel is blocked. + +Accept validated native push-token metadata without discarding the surrounding +iOS acceptance batch. Keep token credentials out of guest history and storage, +while binding the complete metadata frame into authenticated replay detection. + +Match native iOS acknowledgment direction on identity and device sessions. +Subscribe to peer-originating routes and encrypt replies on the Host's own +outgoing route. Repair previously queued reverse-route acknowledgments on +authenticated replay while preserving payment and message commitments. + +Allow outgoing payments once an active, keyed peer device acknowledges the +legacy-device revocation update. Encrypt only for acknowledged devices and +reject payment acknowledgments from devices excluded from the committed envelope. +Reset eligibility after authenticated roster changes while preserving payment +identity and exact memo custody through rewrapping and retries. + +Document the method 12 request/response, compatibility, custody, device +eligibility, and per-spend consent contract in the unnumbered +[draft native Chat/main-purse RFC](../docs/rfcs/native-chat-main-purse.md), +submitted for review with this implementation. Clarify its relationship to +[RFC 0017](../docs/rfcs/0017-coinage-payment.md), including the distinct integer +amount/asset contracts and the absence of general purse APIs or a Chat balance +query. Treat same-wallet competing native/Rust allocators as a release blocker. +This specification link does not assert RFC approval, publication, or +cross-platform qualification. diff --git a/Cargo.lock b/Cargo.lock index 008fbbc6f..d428b78f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,13 +752,40 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + [[package]] name = "bitcoin_hashes" version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ - "hex-conservative", + "bitcoin-io", + "hex-conservative 0.2.2", ] [[package]] @@ -1373,6 +1400,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1679,7 +1721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2051,6 +2093,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2359,6 +2402,15 @@ dependencies = [ "arrayvec 0.7.8", ] +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec 0.7.8", +] + [[package]] name = "hexf-parse" version = "0.2.1" @@ -3622,6 +3674,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3635,6 +3698,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", + "hmac 0.12.1", + "password-hash", ] [[package]] @@ -3999,7 +4064,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4192,6 +4257,18 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -4320,7 +4397,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4416,6 +4493,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4629,6 +4715,47 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5409,6 +5536,33 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "subxt-signer" +version = "0.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bdcc9159fdcc81aca0f71f0c8c77829a671f7348958fc77fb2fb320ed59a13a" +dependencies = [ + "base64", + "bip39", + "cfg-if", + "crypto_secretbox", + "hex", + "hmac 0.12.1", + "parity-scale-codec", + "pbkdf2", + "regex", + "schnorrkel", + "scrypt", + "secp256k1", + "secrecy", + "serde", + "serde_json", + "sha2 0.10.9", + "sp-crypto-hashing", + "thiserror 2.0.19", + "zeroize", +] + [[package]] name = "subxt-utils-accountid32" version = "0.50.3" @@ -5511,7 +5665,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5950,6 +6104,31 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-coinage" +version = "0.1.0" +dependencies = [ + "async-trait", + "blake2", + "blake2b_simd", + "futures", + "futures-timer", + "getrandom 0.2.17", + "hex", + "parity-scale-codec", + "parking_lot", + "rand 0.8.7", + "rand_chacha", + "schnorrkel", + "serde_json", + "substrate-bip39", + "subxt-signer", + "tokio", + "tracing", + "web-time", + "zeroize", +] + [[package]] name = "truapi-host-cli" version = "0.17.0" @@ -5966,6 +6145,7 @@ dependencies = [ "fs2", "futures", "futures-util", + "getrandom 0.3.4", "hex", "image", "parity-scale-codec", @@ -6106,11 +6286,13 @@ dependencies = [ "tracing", "tracing-subscriber", "truapi", + "truapi-coinage", "truapi-macros", "truapi-platform", "unicode-normalization", "uniffi", "url", + "useragent-chat-v2", "verifiable", "wasm-bindgen", "wasm-bindgen-futures", @@ -6429,6 +6611,19 @@ dependencies = [ "serde", ] +[[package]] +name = "useragent-chat-v2" +version = "0.6.30" +dependencies = [ + "blake2", + "chacha20poly1305", + "hkdf", + "sha2 0.10.9", + "thiserror 2.0.19", + "x25519-dalek", + "zeroize", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -6925,7 +7120,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/LICENSE-AGPL-3.0 b/LICENSE-AGPL-3.0 new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 0d674a7ab..58fe4cf74 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ TrUAPI (Triangle User-Agent Programming Interface) is the API surface that hosts - [TrUAPI reference](https://docs.polkadot.com/reference/apps/protocol/truapi/) - [Rust API reference](https://paritytech.github.io/host-rust-core/) +- [Draft: Host-owned native Chat and main-purse payments](docs/rfcs/native-chat-main-purse.md) @@ -64,11 +65,92 @@ requests after a bounded deadline; pass `requestTimeoutMs` to `createTransport` See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full client reference. -`account.productDeviceChat` binds a product-derived account to the connected -wallet's Chat v2 identity and seals or opens identity-route payloads without -exposing the wallet's X25519 private key. Browser pairing hosts forward the -operation over encrypted SSO; signing hosts require the calling product's -dedicated Chat-authority permission before using local wallet material. +`account.deviceChat` is a high-level, Host-owned native Chat actor +(`Account::product_device_chat` in Rust). +Account method 12 initializes a private device, manages authenticated peers, +receives/decrypts native traffic, and sends ordinary messages or reviewed Coinage +payments. Retired method 11 and its raw Open/Seal/proof operations are unsupported, +including over SSO. Guest and Host must upgrade together. + +The signing Host owns the device secret, encrypted roster/outbox, payment WAL, +and spendable memos. Chat-authority permission is not spending permission: +every outgoing main-purse payment requires a separate trusted Host review. +Stable retries resume the same recipient/amount operation; incoming batches +require durable custody and complete claim plans before acknowledgment. +Delivery acknowledgment and finalized clearing are separate states. + +The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies +the method 12 request/response and compatibility contract, device eligibility, +custody-before-ACK rule, and delivery versus clearing semantics. It is a draft +for review in #709, not an approved standard or a release claim. It builds on +[RFC 0017's](docs/rfcs/0017-coinage-payment.md) main-purse custody model without +implementing its general purse/receivable/cheque APIs. Chat amounts are `u64` +cents of the trusted selected Coinage asset; RFC 0017's `u32` dotUSD-cent +`Balance` is not an interchangeable type. Method 12 provides payment cards, +not a product-visible wallet balance. + +The Coinage engine uses the current iOS MAIN_PURSE/page-0 paths: +`//coinage//4294967295//0/` (soft item) and +`//coinage-ring-vrf//4294967295//0//` (hard item). +Snapshot version 3 rejects legacy `//pps` snapshots without modifying them; +old counters, reservations and pending memos must not be reinterpreted under +the new keys. Native iOS CoreData/Keychain and Host snapshots are still separate: +do not operate both allocators for the same wallet. Same-wallet integration +requires explicit state reconciliation and a single allocator owner. Competing +native and Rust allocators able to spend the same inventory are a release +blocker, not an acceptable temporary integration state. +Runtime storage keys and asset-instance +encoding come from metadata. Instance-scoped runtimes require a trusted +`coinage_instance_id`; the encrypted wallet binds that selection permanently, +so a configuration change cannot retarget pending claims or payments. + +Once initialized and authorized, a Host-owned subscription receives and +reconciles without an open guest. Revocation, logout, or session replacement +stops the old receiver. This is in-process execution, not OS wake support. +The draft requires a durable initialized-product index and post-unlock receiver +restoration only for products whose Chat and transport grants remain valid; +embedding Hosts must qualify that cold-restart path separately. + +Native push-token announcements are validated as private metadata, including +when batched with iOS acceptance controls. Their timestamps are checked and +their digests bind replay detection; token credentials are discarded rather +than persisted or exposed to the guest. This actor has no mobile push provider +and does not wake a backgrounded native client. + +Native requests and acknowledgments use the sender's own outgoing identity or +device session; responses do not reuse the original requester's session. +Authenticated request replay repairs queued acknowledgments from the old +reversed route without replacing message or payment commitments. Outgoing +payment readiness requires at least one active, keyed peer device to acknowledge +the legacy-device revocation update. Payment envelopes include only those +acknowledged devices, so an offline advertised device does not block an eligible +recipient. Payment acknowledgments must come from a recipient of the committed +envelope. Authenticated roster changes reset eligibility; retries preserve the +payment identity and exact memo when rewrapping for the updated recipients. +Ordinary chat does not require these revocation acknowledgments. +Incoming payments instead require an authenticated admitted sender and durable +memo custody and claim plans before acknowledgment; they do not use that +outgoing readiness gate. + +Native HOP history is expanded privately, including nested compacted batches; +all payment claim plans and file references are durable before either HOP or +statement acknowledgment. Attachments use trusted Host selection/export, +bounded encrypted chunk storage, resumable uploads/downloads and immutable +retry IDs/ciphertexts. Guests receive metadata, progress and opaque file IDs, +never claim tickets, URLs, source handles or file bytes. Uploads require the +existing Bulletin allowance and Preimage-submit permission; this grants no +Coinage spending authority. HOP connections use the live trusted Bulletin WSS +allowlist, not arbitrary guest endpoints. + +Protocol/storage fixtures cover acceptance loss, restart and download after +pool deletion. This is not evidence of a funded native-device round trip. +Attachment-bearing first-contact welcomes and call signaling remain rejected. + +The local integration uses matching, unpublished `useragent-chat-v2` attachment +codec changes. Its published Git pin must be advanced together with the guest +SDK before release; the local Cargo override is not a portable release dependency. +Distributing the runtime also requires the exact modified Corresponding Source, +not only the base repository URLs in the notices. ## Repository layout @@ -490,4 +572,11 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License -[MIT](./LICENSE) +Original project code retains its [MIT license](./LICENSE). +The Coinage extraction and native Chat integration include AGPL-3.0-only code; +the combined signing runtime, CLI and distributed Host WASM are **not MIT-only**. +See [the complete AGPL license](./LICENSE-AGPL-3.0) and +[Coinage provenance](./rust/crates/truapi-coinage/NOTICE). +Distributors and network operators must provide the applicable Corresponding +Source, including local modifications and build instructions. This does not +relicense upstream AGPL code as MIT. diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt new file mode 100644 index 000000000..141e5ab3c --- /dev/null +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/AndroidHostCoreStorage.kt @@ -0,0 +1,166 @@ +package io.parity.truapi + +import android.content.ContentValues +import android.content.Context +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import uniffi.truapi_server.HostRejection + +/** + * Durable, device-only core storage, separate from every product's local storage. + * + * The opaque slots include Chat identity/history, incoming payment receipts and + * main-purse allocator/WAL state. A successful write means SQLite committed the + * encrypted value before the callback returns; the core may then acknowledge it. + * Never clear this store when clearing a product's browsing data or logging out. + * One process-owned instance must be shared by all executions of an environment. + * + * The Keystore key intentionally remains usable while the screen is locked: + * process-resident background Chat reception must persist messages and receipts. + * Mnemonic unlocking and native signing-session retirement are separate gates. + */ +class AndroidHostCoreStorage(context: Context, namespace: String) : HostCoreStorage, AutoCloseable { + private val namespace = namespace.also { + require(it.matches(Regex("[a-z0-9][a-z0-9-]{0,62}"))) { "Invalid core storage namespace" } + } + private val keyAlias = "io.parity.truapi.core.$namespace" + private val databaseFile = File(context.noBackupFilesDir, "truapi-core-$namespace.sqlite") + private val database = object : SQLiteOpenHelper( + context, databaseFile.absolutePath, 1, + // OpenParams configures every pooled/reopened connection, unlike a + // one-time PRAGMA in onConfigure/onOpen. + SQLiteDatabase.OpenParams.Builder().setSynchronousMode("FULL").build(), + ) { + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE core_slots (slot TEXT PRIMARY KEY NOT NULL, ciphertext BLOB NOT NULL)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + error("Unsupported private core storage version") + } + } + private var encryptionKey: SecretKey? = null + + @Synchronized + override fun read(key: ByteArray): ByteArray? = storageCall { + database.readableDatabase.query( + "core_slots", arrayOf("ciphertext"), "slot = ?", arrayOf(slot(key)), + null, null, null, + ).use { cursor -> + if (!cursor.moveToFirst()) return@storageCall null + val record = cursor.getBlob(0) + check(record.size >= 1 + IV_BYTES + TAG_BYTES && record[0] == VERSION) { + "Invalid private core storage record" + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, loadKey(create = false), GCMParameterSpec(128, record, 1, IV_BYTES)) + cipher.updateAAD(key) + cipher.doFinal(record, 1 + IV_BYTES, record.size - 1 - IV_BYTES) + } + } + + @Synchronized + override fun write(key: ByteArray, value: ByteArray) = storageCall { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, loadKey(create = true)) + cipher.updateAAD(key) + val record = byteArrayOf(VERSION) + cipher.iv + cipher.doFinal(value) + durableMutation { db -> + db.insertWithOnConflict( + "core_slots", null, + ContentValues(2).apply { + put("slot", slot(key)) + put("ciphertext", record) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ).also { check(it != -1L) { "Private core storage write failed" } } + } + } + + @Synchronized + override fun clear(key: ByteArray) = storageCall { + durableMutation { db -> + db.delete("core_slots", "slot = ?", arrayOf(slot(key))) + } + Unit + } + + private inline fun durableMutation(operation: (SQLiteDatabase) -> Unit) { + val db = database.writableDatabase + db.beginTransaction() + try { + // The transaction pins this thread to the actual writer. Checking + // outside it could inspect a different pooled (read) connection. + val synchronous = DatabaseUtils.longForQuery(db, "PRAGMA synchronous", null) + check(synchronous == 2L || synchronous == 3L) { + "Private core storage requires FULL or EXTRA durability" + } + operation(db) + db.setTransactionSuccessful() + } finally { + // Commit failures propagate through storageCall; never false-ACK. + db.endTransaction() + } + } + + private fun slot(bytes: ByteArray): String { + require(bytes.isNotEmpty()) { "Empty core storage key" } + return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + } + + private fun loadKey(create: Boolean): SecretKey { + encryptionKey?.let { return it } + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val existing = store.getKey(keyAlias, null) as? SecretKey + val resolved = existing ?: run { + // Missing keys must not silently replace an existing wallet's key. + check(create && !hasRecords()) { "Private core storage encryption key unavailable" } + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init( + KeyGenParameterSpec.Builder( + keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(false) + .build(), + ) + }.generateKey() + } + encryptionKey = resolved + return resolved + } + + private fun hasRecords(): Boolean = database.readableDatabase.rawQuery( + "SELECT 1 FROM core_slots LIMIT 1", null, + ).use { it.moveToFirst() } + + private inline fun storageCall(operation: () -> T): T = try { + operation() + } catch (error: Exception) { + // Do not expose key material, ciphertext or platform exception details. + throw HostRejection.Rejected("Private host storage unavailable") + } + + @Synchronized + override fun close() { + database.close() + encryptionKey = null + } + + private companion object { + const val IV_BYTES = 12 + const val TAG_BYTES = 16 + const val VERSION: Byte = 1 + } +} diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index fcce217b9..10bffa8dd 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -54,6 +54,9 @@ import uniffi.truapi.HostLocalStorageReadError import uniffi.truapi.HostNavigateToError import uniffi.truapi_platform.AuthState import uniffi.truapi_platform.HostChainSet +import uniffi.truapi_platform.NativeChatFilePickRequest +import uniffi.truapi_platform.NativeChatPickedFile +import uniffi.truapi_platform.NativeChatFileExportRequest import uniffi.truapi_platform.PermissionAuthorizationRequest import uniffi.truapi_platform.PermissionAuthorizationStatus import uniffi.truapi_platform.UserConfirmationReview @@ -126,6 +129,8 @@ data class HostRuntimeConfig( val networkSuffix: String, val localSessionSecret: ByteArray? = null, val localSessionLiteUsername: String? = null, + /** Trusted asset instance, required for instance-scoped Coinage runtimes. */ + val coinageInstanceId: UInt? = null, ) { internal fun toNative(): UniFfiNativeHostRuntimeConfig = UniFfiNativeHostRuntimeConfig( @@ -141,6 +146,7 @@ data class HostRuntimeConfig( networkSuffix = networkSuffix, localSessionSecret = localSessionSecret, localSessionLiteUsername = localSessionLiteUsername, + coinageInstanceId = coinageInstanceId, ) override fun equals(other: Any?): Boolean { @@ -156,7 +162,8 @@ data class HostRuntimeConfig( assetHubChainGenesisHash.contentEquals(other.assetHubChainGenesisHash) && networkSuffix == other.networkSuffix && localSessionSecret.contentEquals(other.localSessionSecret) && - localSessionLiteUsername == other.localSessionLiteUsername + localSessionLiteUsername == other.localSessionLiteUsername && + coinageInstanceId == other.coinageInstanceId } override fun hashCode(): Int { @@ -171,6 +178,7 @@ data class HostRuntimeConfig( result = 31 * result + networkSuffix.hashCode() result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) + result = 31 * result + (coinageInstanceId?.hashCode() ?: 0) return result } } @@ -220,6 +228,41 @@ interface HostCoreStorage { fun clear(key: ByteArray) } +/** + * Host-private immutable attachment custody. Marshal trusted selection/export UI + * to the main thread. Never expose source/export handles or bytes to a guest. + * Empty selection/null export means user cancellation, never unavailability. + */ +interface NativeChatFilesHost { + @Throws(HostRejection::class) + suspend fun pickChatFiles(request: NativeChatFilePickRequest): List = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun readChatFile(sourceId: String, offset: ULong, length: UInt): ByteArray = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun releaseChatFile(sourceId: String): Unit = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun beginChatFileExport(request: NativeChatFileExportRequest): String? = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun writeChatFileExport(exportId: String, offset: ULong, data: ByteArray): Unit = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun finishChatFileExport(exportId: String): Unit = + throw HostRejection.Rejected("native Chat files unavailable") + + @Throws(HostRejection::class) + suspend fun cancelChatFileExport(exportId: String): Unit = + throw HostRejection.Rejected("native Chat files unavailable") +} + /** * Host-side callback bundle that the Rust core invokes for capabilities the * native shell owns. The interface mirrors the underlying UniFFI surface but @@ -247,7 +290,7 @@ interface HostCoreStorage { * `Dispatchers.Main`. Touching views or the `WebView` directly from a callback * throws `CalledFromWrongThreadException`. */ -interface HostBridge { +interface HostBridge : NativeChatFilesHost { /** Lifecycle logger. Marker is a stable slug, detail is free-form. */ fun onCoreLog(marker: String, detail: String) {} @@ -329,6 +372,18 @@ interface HostBridge { @Throws(HostRejection::class) fun chainConnect(genesisHash: ByteArray): UInt? = null + /** Exact WSS endpoint strings from trusted, current Bulletin configuration. */ + @Throws(HostRejection::class) + suspend fun allowedHopEndpoints(bulletinGenesisHash: ByteArray): List = emptyList() + + /** + * Recheck the exact endpoint against live trusted configuration before dialing. + * Returns null when HOP is unavailable. The id shares chainSend/chainClose + * and notifyChainResponse/notifyChainClosed. + */ + @Throws(HostRejection::class) + fun hopConnect(bulletinGenesisHash: ByteArray, endpoint: String): UInt? = null + /** Send one JSON-RPC request on a native chain connection. */ @Throws(HostRejection::class) fun chainSend(connectionId: UInt, request: String) {} @@ -351,6 +406,13 @@ interface HostBridge { @Throws(HostRejection::class) suspend fun lookupPreimage(key: ByteArray): ByteArray? = null + /** Exact-name AccountId32 candidates; core verifies dotNS ownership and the People key. */ + @Throws(HostRejection::class) + suspend fun identityUsernameCandidates( + username: String, + peopleChainGenesisHash: ByteArray, + ): List = throw HostRejection.Rejected("native identity backend unavailable") + /** Return the current host theme. Hosts with no named themes report [ThemeName.Default]. */ @Throws(HostRejection::class) fun currentTheme(): HostThemeSubscribeItem = @@ -534,18 +596,52 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback override fun chainConnect(genesisHash: ByteArray): UInt? = withHostRejection { bridge.chainConnect(genesisHash) } + override suspend fun allowedHopEndpoints(bulletinGenesisHash: ByteArray): List = + withHostRejection { bridge.allowedHopEndpoints(bulletinGenesisHash) } + + override fun hopConnect(bulletinGenesisHash: ByteArray, endpoint: String): UInt? = + withHostRejection { bridge.hopConnect(bulletinGenesisHash, endpoint) } + override fun chainSend(connectionId: UInt, request: String) = withHostRejection { bridge.chainSend(connectionId, request) } override fun chainClose(connectionId: UInt) = withHostRejection { bridge.chainClose(connectionId) } + override suspend fun pickChatFiles(request: NativeChatFilePickRequest): List = + withChatFileRejection { bridge.pickChatFiles(request) } + + override suspend fun readChatFile(sourceId: String, offset: ULong, length: UInt): ByteArray = + withChatFileRejection { bridge.readChatFile(sourceId, offset, length) } + + override suspend fun releaseChatFile(sourceId: String) = + withChatFileRejection { bridge.releaseChatFile(sourceId) } + + override suspend fun beginChatFileExport(request: NativeChatFileExportRequest): String? = + withChatFileRejection { bridge.beginChatFileExport(request) } + + override suspend fun writeChatFileExport(exportId: String, offset: ULong, data: ByteArray) = + withChatFileRejection { bridge.writeChatFileExport(exportId, offset, data) } + + override suspend fun finishChatFileExport(exportId: String) = + withChatFileRejection { bridge.finishChatFileExport(exportId) } + + override suspend fun cancelChatFileExport(exportId: String) = + withChatFileRejection { bridge.cancelChatFileExport(exportId) } + override suspend fun confirmUserAction(review: UserConfirmationReview): Boolean = withHostRejection { bridge.confirmUserAction(review) } override suspend fun lookupPreimage(key: ByteArray): ByteArray? = withHostRejection { bridge.lookupPreimage(key) } + override suspend fun identityUsernameCandidates( + username: String, + peopleChainGenesisHash: ByteArray, + ): List = withHostRejection { + bridge.identityUsernameCandidates(username, peopleChainGenesisHash) + } + override fun currentTheme(): HostThemeSubscribeItem = withHostRejection { bridge.currentTheme() } @@ -580,6 +676,16 @@ private fun hostRejectionReason(error: Throwable): String = private const val HOST_REJECTION_REASON_MAX_CHARS = 256 +private inline fun withChatFileRejection(operation: () -> T): T = + try { + operation() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + // Provider/filesystem exceptions may contain private paths or handles. + throw HostRejection.Rejected("native Chat file operation unavailable or failed") + } + private inline fun withHostRejection(operation: () -> T): T = try { operation() diff --git a/docs/rfcs/0017-coinage-payment.md b/docs/rfcs/0017-coinage-payment.md index f5f342c2f..f820a3cf6 100644 --- a/docs/rfcs/0017-coinage-payment.md +++ b/docs/rfcs/0017-coinage-payment.md @@ -647,6 +647,21 @@ Those mechanics are host-private. Products receive only RFC 0006 balances, payment receipts and payment status, plus RFC 0017 purse metadata and clearing references where those are explicitly returned. +### Relationship to Host-owned Native Chat + +The [draft Host-owned native Chat/main-purse RFC](native-chat-main-purse.md) +uses this RFC's main-purse and secret-custody model for an authenticated native +Chat channel on Account method 12. Its product-visible payment cards are not +cheques, an acknowledgment is not clearing, and Chat authorization does not +replace trusted per-spend consent. + +That actor does not implement the general purse, receivable, cheque, deposit, +refund, or purse-aware balance APIs specified here. Supporting it therefore +does not establish RFC 0017 compliance. Its `amount_cents: u64` denotes cents +of the Host-selected Coinage asset; conversion to this RFC's `Balance = u32` +requires both a checked range conversion and the actual dotUSD denomination. +A test-network pUSD amount must not be silently relabeled dotUSD. + ### Relationship to Product Payment Layers Merchant checkout, POS, ecommerce, and reconciliation flows can be implemented diff --git a/docs/rfcs/native-chat-main-purse.md b/docs/rfcs/native-chat-main-purse.md new file mode 100644 index 000000000..ec7c44d7c --- /dev/null +++ b/docs/rfcs/native-chat-main-purse.md @@ -0,0 +1,454 @@ +--- +title: "Host-owned native Chat and main-purse payments" +owner: "@replghost" +status: draft +--- + +# RFC — Host-owned native Chat and main-purse payments + +## Summary + +This draft specifies the Host-owned native Chat actor on TrUAPI Account method +12, including one-shot, explicitly reviewed Coinage payments from the user's +main purse. Products receive authenticated conversation views and payment +status, while the signing Host retains device secrets, payment memos, durable +claim plans, and settlement authority. It builds on the custody and main-purse +model of [RFC 0017](0017-coinage-payment.md), without claiming implementation of +that RFC's complete CoinPayment API. + +This proposal is **draft, not approved**. It accompanies the implementation in +[host-rust-core #709](https://github.com/paritytech/host-rust-core/pull/709) for +review; inclusion here is neither release availability nor deployment evidence. + +## Motivation + +Guest-held Chat cryptography lets a product handle identity ciphertext and +spendable payment material that should belong exclusively to the wallet. A +product reload can interrupt receipt between decryption and durable custody; +an acknowledgment can then destroy the sender's recovery path before the +recipient has a recoverable claim. A generic Chat permission also cannot +safely authorize debits from the user's ordinary balance. + +A Host-owned actor gives native and browser products the same restricted +interface. It authenticates the peer roster, retains recoverable operations +independently of a guest call, and separates delivery from finalized clearing. +The product can ask to send a payment, but cannot choose coin inputs, construct +proofs, approve itself, or obtain the secrets needed to spend received funds. + +## Approach + +### Scope and relationship to RFC 0017 + +RFC 0017 defines `MAIN_PURSE = u32::MAX`, the user's ordinary Coinage purse, +its secret-custody boundary, and general purse/receivable/cheque APIs. This RFC +uses that main-purse concept for a particular authenticated native Chat channel. +Incoming Chat payments are claimed into the main purse; outgoing payments debit +it. Conversation identity and product permissions do not create independent +purses or product-owned balances. + +Method 12 does **not** expose `create_purse`, `query_purse`, +`rebalance_purse`, `delete_purse`, `create_receivable`, `create_cheque`, +`listen_for_payment`, `deposit`, or `refund`. A native Chat payment memo is not +a product-visible RFC 0017 cheque. Supporting this RFC MUST NOT be advertised as +complete RFC 0017 support, generic merchant checkout, invoice support, automatic +refunds, or a reusable spending allowance. RFC 0017's proposed purse selectors +on RFC 0006 are not added by this method. + +The normative requirements below describe the proposed contract. The final +section separately identifies implementation evidence and integration limits; +requirements are not assertions that every embedding Host already meets them. + +### Wire contract and compatibility + +The source of truth is: + +- [`api/account.rs`](../../rust/crates/truapi/src/api/account.rs): + `Account::product_device_chat`, trait ID **2**, method ID **12**; +- [`versioned/account.rs`](../../rust/crates/truapi/src/versioned/account.rs): + `HostProductDeviceChatRequest::V1`, response `::V1`, and domain error `::V1`; +- [`v02/account.rs`](../../rust/crates/truapi/src/v02/account.rs): the V1 payload + definitions. The `v02` source module is not a `V2` wire envelope. + +The method is a request/response call returning +`Result>`. +It is not a guest subscription. The Host separately owns receive subscriptions. +Generated bindings MUST preserve the canonical enum encodings, integer widths, +and version envelope; a package version alone is not capability negotiation. + +Account method **11** is permanently retired. Its former raw Open/Seal/proof +interface MUST NOT be forwarded locally or over SSO, reassigned, or emulated by +method 12. Unsupported methods, envelope versions, and unavailable Host +implementations MUST fail through the protocol/`CallError` mechanism rather +than appear successful or leave requests pending. The default method 12 trait +implementation returns `CallError::unavailable()`; the domain error enum has no +`Unsupported` variant. Hosts MUST NOT translate unsupported operations into +empty successful Chat views, fabricated payments, or raw-crypto fallback. + +A method-11 guest and a method-12 Host are intentionally incompatible. Guest, +SDK, generated codecs, core, and platform adapters MUST be upgraded together. +An older release carrying the same broad core version is not sufficient unless +its actual source/artifact contains this contract. + +### Public requests + +The V1 request enum contains exactly these operations. `Id32` in this table +means the canonical `[u8; 32]`, not an additional wire type. + +| Variant | Fields | Contract | +| --- | --- | --- | +| `Initialize` | none | Open or restore the Host-owned device and return public state. It does not authorize spending. | +| `Invite` | `username: String`, `text: String` | Resolve the identity and Chat key on the configured network and create a native invitation with ordinary welcome text. | +| `Receive` | `statement: SignedStatement` | Authenticate the complete signed native statement, decrypt privately, and commit custody-sensitive effects before acknowledgment. Passing bytes is not an authentication assertion. | +| `AcceptInvitation` | `invitation_id: Id32` | Accept an invitation already authenticated and retained by the Host. | +| `RejectInvitation` | `invitation_id: Id32` | Reject a retained invitation; this is not a payment cancellation operation. | +| `Send` | `peer_identity: Id32`, `request_id: String`, `messages: Vec>` | Send validated ordinary native message encodings to the established roster. Payment, arbitrary ciphertext, and device-control injection are forbidden. | +| `SendPayment` | `peer_identity: Id32`, `request_id: String`, `amount_cents: u64` | Propose one main-purse payment to the Host-authenticated recipient, subject to trusted per-spend review. | +| `PaymentStatus` | `operation_id: Id32` | Read this product's durable operation status without proposing a new debit. | +| `Reconcile` | none | Resume authorized durable transport/recovery work and return public views; it grants no new spending permission. | +| `SendAttachments` | `peer_identity: Id32`, `request_id: String`, `text: Option` | Select immutable files in trusted Host UI and send safe rich content. The guest cannot pass a local path or upload credential. | +| `OpenAttachment` | `attachment_id: Id32` | Resume private download and present/export through trusted Host UI, without returning file bytes to the guest. | + +Caller request IDs MUST be nonempty, at most 128 UTF-8 bytes, and contain no +control characters. Welcome text is bounded to 8192 UTF-8 bytes. Native message +and attachment codecs impose further structural and resource bounds; invalid +or forbidden payloads return `InvalidRequest`, not a permissive opaque tunnel. +Hosts MUST bound queues, histories, storage, and nested history expansion, and +fail closed when they cannot retain recovery records. Capacity exhaustion MUST +NOT silently evict live payment custody or idempotency commitments. + +### Public responses and errors + +Every successful operation returns the same V1 response: + +| Field | Public content | +| --- | --- | +| `device: HostNativeChatDevice` | Wallet identity account/public Chat key, product allowance account, and Host-owned device statement account/public Chat key. | +| `peers: Vec` | Authenticated identity, optional resolved username, admitted device accounts/public keys, incoming subscription topics, and `ready_for_payments`. The guest cannot write this roster back. | +| `invitations: Vec` | Stable invitation ID, authenticated peer identity, optional resolved username, native millisecond timestamp, and ordinary welcome text. | +| `messages: Vec` | Peer, incoming/outgoing direction, native request ID, and validated ordinary message encodings with custody-sensitive content removed. | +| `acknowledgments: Vec` | Peer, native request ID, and native `response_code: u8`; zero means successful delivery processing, not clearing. | +| `payments: Vec` | Product-scoped payment cards described below. | +| `rich_messages: Vec` | Authenticated message/reply/edit metadata, text, opaque attachment IDs, safe media metadata, and transfer progress. | + +Public views may repeat retained messages and acknowledgments. Consumers MUST +correlate native request/message IDs and upsert payment cards by `operation_id`; +response arrival is not an exactly-once event or a complete historical archive. +`PaymentStatus` uses the common response, not a separate one-card schema. + +A payment card has `operation_id: [u8; 32]`, `request_id: String`, +`message_id: String`, `timestamp: u64` (milliseconds), +`peer_identity: [u8; 32]`, `direction: Incoming | Outgoing`, +`amount_cents: u64`, and `state: HostNativeChatPaymentState`. +It contains no memo, source coin identifiers, private clearing evidence, asset +selector, or wallet balance. Products MUST obtain asset/network presentation +from trusted Host context, not infer a currency from the integer alone. + +Attachment metadata contains `mime_type`, `size_bytes: u32`, and kind `File`, +`Image { width, height, thumbnail }`, or +`Video { duration_seconds, thumbnail }`. Thumbnails are optional validated +BlurHash bytes, not URLs or executable images. Attachment progress is +`Preparing`, `Uploading { uploaded_bytes }`, +`Downloading { downloaded_bytes }`, `Ready`, or `Recovering`. +These states are independent of both message acknowledgment and payment status. + +Domain errors are the exact `HostProductDeviceChatError` variants: + +| Error | Meaning and caller action | +| --- | --- | +| `NotConnected` | No current authenticated wallet session; restore a session before retry. | +| `AccessNotGranted` | Required Chat/transport capability is absent or revoked; do not bypass it. | +| `UserRejected` | Trusted review, file selection, or export was declined; do not loop prompts automatically. | +| `AllowanceRequired` | The Host device needs its Statement Store allowance. | +| `PeerNotReady` | The peer has not met the applicable establishment/device eligibility requirements. | +| `OperationConflict` | Immutable parameters or an existing asset binding conflict; changing an ID is not a safe automatic retry. | +| `InvalidRequest` | Invalid bounds, amount, identifier, or unsupported native message content. | +| `InvalidStatement` | Authentication, decryption, or authenticated native content validation failed. | +| `RecipientNotFound` | Identity resolution failed on the selected network. | +| `InsufficientBalance` | The main purse cannot fund the proposed payment. | +| `StorageUnavailable` | A safe durable commit is unavailable; never assume the operation had no prior effects. | +| `NetworkUnavailable` | Configured chain/transport is unavailable; reconcile before deciding an operation failed. | +| `OperationNotFound` | No matching payment visible to this product; do not reveal another product's operation. | +| `AttachmentsUnavailable` | This Host cannot select, recover, or present the requested private file. | + +Errors MUST be sanitized. Neither domain errors nor transport diagnostics may +contain secrets, decrypted payment memos, file credentials, or raw backend +errors that carry them. Call failure or timeout is not payment cancellation. + +### Identity, product, network, and device eligibility + +The authenticated calling product comes from Host connection/SSO context, +never request-supplied labels. Chat state is scoped to the wallet, selected +network, product, and Host installation's device. The main-purse allocator is +wallet/network owned and serialized across products. Product operation IDs and +attachment handles MUST NOT provide access across wallets, networks, or products. + +The Host resolves usernames and root Chat public keys using trusted network +configuration and authenticates native invitations and device-control messages. +Guests select only an established peer identity, not encryption keys, device +lists, signing accounts, network endpoints, or Coinage asset instances. SSO +MUST preserve this boundary at the signing Host; the execution Host cannot +substitute its own review for the signing Host's authorization. + +Ordinary Chat requires an established peer with active authenticated devices. +Outgoing payment eligibility additionally requires at least one active, keyed +peer device to acknowledge the legacy-device revocation update. The payment +envelope includes only the eligible acknowledged devices. An offline advertised +device need not block an eligible recipient. A payment ACK MUST originate from +a recipient included in the committed envelope. Authenticated roster changes +invalidate eligibility until it is re-established; rewrapping a retry MUST +preserve the payment identity and exact memo, not create a second spend. + +Incoming payments require an authenticated admitted sender and durable custody; +they do not apply the outgoing `ready_for_payments` gate to the sender. First +contact is not a channel for embedding payments or attachments in welcome text. +Call signaling remains unsupported. Native push-token metadata may be validated +and discarded privately, but this does not imply a push provider or OS wake. + +### Permissions and trusted per-spend consent + +Chat authority is a dedicated authorization, separate from disclosure of a +username and separate from `StatementSubmit`. The Host-owned receiver requires +current Chat authority and Statement Store submission permission. Attachment +uploads additionally require the existing Bulletin allowance and +`PreimageSubmit`; none of these grants permits spending. + +Each new outgoing debit MUST obtain trusted signing-Host review of +`MainPurseChatPaymentReview`: authenticated calling product, recipient identity, +Host-resolved username when available, exact `amount_cents`, +`max_debit_cents` including the approved fee bound, chain `genesis_hash`, +trusted `coinage_instance_id`, and immutable `operation_id`. +The UI MUST make the selected network, asset, recipient amount, and maximum +main-purse debit unambiguous. It MUST NOT render a guest label as authenticated +recipient identity or approve through guest JavaScript, auto-sign policy, +Chat authorization, or generic product signing permission. + +Approval authorizes only that immutable operation and debit bound. Transport +replay of an already accepted memo does not authorize another debit. Resuming +unfinished preparation may require renewed review of the same operation; the +Host MUST NOT silently broaden the prior approval. Denial before effects can +cancel the intent. Denial after an accepted effect prevents new effects but +cannot erase an existing transfer, release ambiguously spent inputs, or promise +a refund. `PaymentStatus` is not a consent prompt or cancellation API. + +### Integer amounts and asset mapping + +Chat V1 `amount_cents` is an unsigned 64-bit integer. An outgoing amount MUST be +in `1..=u64::MAX` and also pass the selected runtime's denomination, checked +conversion, inventory, and maximum-debit bounds. Zero, overflow, unsupported +units, and a non-integral exact received amount MUST be rejected. Products and +bindings MUST NOT pass monetary values through an inexact floating-point or +JavaScript `Number` conversion for values above its safe integer range. + +The unit is one cent of the **Host-selected Coinage asset**, not a chain-native +planck and not an arbitrary fiat quote. The Host obtains denomination metadata +from the selected chain/instance and uses checked arithmetic to convert cents +to `u128` chain units. Exact amounts require integral conversion; partial +clearing reports only proven whole cents (rounding down), while a review's +maximum debit rounds up if necessary so it never understates the bound. +The durable operation binds denomination metadata; a changed runtime mapping +MUST NOT reinterpret an in-flight payment. + +RFC 0017 instead specifies `Balance = u32` in **dotUSD cents**, exponent 2. +These types are not interchangeable aliases. An adapter to that API MUST check +`amount_cents <= u32::MAX` and the actual dotUSD asset/denomination before a +lossless conversion; truncation and relabeling are forbidden. The qualified +local native profile is `paseo-next-v2`, People genesis +`0x4a2b5b737de1da59e209b0000a876ec2fa20035dc34fd292a848da32d255ad48`, +Coinage instance `0`, with pUSD chain-asset precision 6. In that profile, `1` +cent is `0.01 pUSD` (10,000 chain units). These are explicit configuration +values, not values inferred from `MAIN_PURSE`. Hosts MUST verify the live +configured chain and denomination metadata rather than substitute another +Paseo genesis or assume any instance is equivalent. This mapping is not a +claim that pUSD is dotUSD or redeemable fiat USD. + +The chain genesis and optional Coinage instance are Host configuration, not +request fields. `None` is valid only for a legacy single-asset runtime; an +instance-scoped runtime requires its trusted instance ID. An opened encrypted +wallet binds its selected instance permanently. Configuration changes MUST fail +closed instead of retargeting reservations, claims, or pending payments. +A product unable to identify the configured asset MUST NOT invent a currency +label or offer an ambiguously denominated payment. + +### Durable custody, acknowledgment, and clearing + +The Host MUST authenticate and decrypt native traffic privately, validate the +whole batch, and durably retain every payment memo and its complete claim plan +before issuing the corresponding native ACK. This applies to inline batches, +HOP history, and nested compacted history. Private attachment references needed +for later download must likewise be durable before acknowledgment. A storage +failure leaves the message unacknowledged and retryable; partial processing +cannot justify acknowledging the entire batch. + +The sender MUST retain immutable memo custody and an outbox commitment before +transmission. The receiver MUST retain enough information to resume claims after +restart and after remote history or pool data is deleted. A claim submission or +an RPC success is not proof of ownership at finality. Chain evidence, rather +than guest assertions, delivery responses, or synthetic balances, determines +settlement. + +| Payment state | Meaning | +| --- | --- | +| `Preparing` | Approved inputs are reserved and required preparation is in progress. | +| `Delivering` | Durable encrypted memo exists; transport is being attempted or retried. | +| `Delivered` | An eligible peer acknowledged custody/processing; clearing is not yet established. | +| `Claiming` | Incoming secrets and claim plans are durable; on-chain claiming is in progress. | +| `PartiallyCleared { cleared_cents }` | Finalized evidence covers only part of the amount; do not label the whole payment paid. | +| `Cleared` | The complete payment has been verified at chain finality. | +| `Recovering` | Effects are ambiguous and require reconciliation; reservations and recovery records remain. | +| `Failed { reason }` | Definitive failure after possible prior effects have been reconciled. | + +Failure reasons are `Cancelled`, `InsufficientBalance`, `AlreadySpent`, +`InvalidMemo`, and `ChainRejected`. A terminal failure MUST NOT obscure any +value that actually cleared. The V1 `Failed` variant carries no cleared amount; +a Host that cannot represent a mixed outcome truthfully MUST retain the +partial/recovery status rather than collapse it into a misleading failure. + +“Claimed” is not a separate V1 enum variant. UI copy MUST distinguish a claim +attempt (`Claiming`) from finalized ownership (`Cleared`). State observations +may skip intermediate states; implementations MUST preserve established +clearing evidence rather than regress it when an ACK arrives late. + +### Idempotency, retries, and lifecycle + +Outgoing payment identity binds wallet, network, calling product, and caller +request ID. Retrying `SendPayment` with that ID MUST keep the recipient and +amount unchanged or return `OperationConflict`. It resumes the same durable +operation, reservations, and memo. A new ID is a new payment proposal, never an +automatic recovery tactic. Ordinary `Send` deduplication additionally scopes the +request ID to the peer and commits the message content; attachment intents +retain the original recipient, caption, and immutable selected source. + +`Invite` does not accept a caller idempotency key and creates a fresh +invitation; a product MUST reconcile before blindly retrying it after a lost +response. Replayed authenticated incoming requests MUST repair pending ACK +transmission without duplicating custody or replacing original commitments. +Responses use the responder's own outgoing identity/device route, not a copy +of the requester's session direction. + +Dropping a guest call or closing the product does not cancel an already owned +durable operation. While the Host process is running, its authorized receiver +owns subscriptions, reconnects, and reconciliation independently of the guest. +It MUST recheck session and permissions before new effects and stop on +revocation, logout, or session replacement, including during a stalled network +call. Already handed-off durable commits may complete, but stale sessions MUST +NOT create successor work. Revocation does not delete pending custody or undo a +finalized payment. + +Restart restores durable device/payment state, not a guest-held secret cache. +The Host MUST persist a bounded wallet/network index of initialized products +and, after wallet unlock, restore receivers only for entries whose Chat +authority and Statement Store submission permission remain authorized. This +restoration MUST NOT require an open guest or prompt for new authorization. +The index is Host-private wallet state, not product-owned storage. Clearing one +product may durably unregister that product and stop its receiver, but MUST +preserve other registrations and pending payment custody/history. Its storage +adapter must preserve the canonical `CoreStorageKey::NativeChatProducts` entry +(index 16), alongside the actor and wallet stores, and reject malformed or +over-limit data without fabricating an empty initialized-product set. Restoring +a registration MUST NOT regenerate a missing device key. + +OS suspension or termination still stops in-process progress. Background wake, +push delivery, and platform scheduling are embedding-Host responsibilities, +not promises of this API. Hosts MUST expose unavailable or pending service state +honestly rather than label suspended work completed. + +### Secret custody, migration, and balance visibility + +The signing Host owns device private keys, encrypted roster/outbox, payment WAL, +coin inventory, reservations, claim/recovery plans, spendable memos, recycler +and voucher material, and proofs. None may enter guest storage, public errors, +logs, attachment handles, or response payloads. Private file tickets, URLs, +source handles, and file bytes remain Host-owned. HOP uses trusted Bulletin +endpoint configuration, never a guest-supplied network destination. + +Migration is a clean cutover: upgrade the guest to method 12, initialize a +Host-owned device, establish authenticated peer rosters, and complete the +legacy-device revocation handshake before sending payments. An old guest +roster or ciphertext is not authority to import identity keys or spending +material through `Send` or `Receive`. Old pending guest-held payments require a +trusted recovery procedure; method 12 provides no guest secret-import API. +Hosts MUST NOT silently delete unresolved old funds or fall back to method 11. + +The current main-purse key profile uses page 0: +`//coinage//4294967295//0/` (soft item) and +`//coinage-ring-vrf//4294967295//0//` (hard item). Snapshot version 3 +rejects legacy `//pps` snapshots without modifying them. Counters, reservations, +and pending memos MUST NOT be reinterpreted under new derivations. Matching +paths are not sufficient to share an allocator: native iOS CoreData/Keychain +state and the Rust Host snapshot need explicit reconciliation and one allocator +owner before both access the same wallet. A Host MUST NOT enable the Rust +main-purse spend path while an independent native allocator can spend the same +inventory. This is a release blocker until reconciliation and exclusive +ownership are enforced, even when both allocators derive the same keys. + +Method 12 deliberately has no balance query. A product MUST NOT calculate a +spendable main-purse balance by summing conversation cards: cards omit other +products, other payment channels, reservations, and wallet history. Trusted +wallet UI may show a balance backed by its actual reconciled inventory. The +current runtime's `payment.balance_subscribe` rejects with `PermissionDenied`; +this draft does not present it as working Chat balance support. Product-visible +balance or RFC 0017 `query_purse` requires its own implemented, authorized +contract before a product may rely on it. Missing balance access is not zero +balance, and wallet migration MUST preserve existing trusted balance visibility +without inventing a guest balance. + +## Implementation evidence and remaining integration boundaries + +The referenced source implements the actor, typed views, durable stores, +Coinage engine integration, trusted review callback, and in-process receive +service. The implementation effort reports a real local native Host-to-iOS and +iOS-to-Host `0.01 pUSD` payment clearing in each direction. That narrowly scoped +observation is not evidence of general RFC 0017 compliance, Android/desktop or +browser parity, OS background delivery, or a published product release. This +document introduces no additional test or deployment result. + +The reference `hosts/ios` integration still has a separate native +`CoinageService`. It rejects access to `CoreStorageKey::MainPurseCoinage` +(index 13), preventing the Rust actor from scanning, allocating, or claiming +that inventory. Ordinary Chat remains available, but main-purse Chat payment +custody is unavailable and incoming payment batches must not be acknowledged. +This fail-closed guard is not a native-store migration. An embedding Host with +one shared Rust wallet owner may enable payments only when its actual durable +storage, review UI, and lifecycle satisfy the contract above. + +The following remain release/integration obligations rather than implied +capabilities: + +- Each embedding Host must implement the trusted spend review and durable + storage boundary, and consume artifacts built from the matching actor and + native codec source. Merely selecting a core version is insufficient. +- Same-wallet native/Rust allocator migration is a release blocker wherever + competing writers can spend the same inventory. Explicit custody/counter + reconciliation and one allocator owner are required. Separate devices + exchanging payments do not prove safe concurrent allocator sharing. +- Guests need trusted asset/network display context because V1 payment cards do + not carry it. A Host unable to establish the mapping must disable the spend + path, not substitute a familiar currency name. +- Product balance APIs, general RFC 0017 purse/receivable/cheque operations, + and OS wake/push are not supplied here. +- Indexed post-unlock receiver restoration is implemented in the core source, + but must still be qualified with durable permission restoration and platform + storage. The existing in-process receive service and unexecuted regression + cases alone are not evidence of cold-restart conformance. +- Qualification must separately exercise denial, revoked sessions, dropped + calls, interrupted durable writes, restart/replay, roster changes, partial + claims, and ambiguous chain outcomes on the actual consuming Hosts. A funded + happy-path round trip does not establish these guarantees by itself. + +## Trade-offs + +- A Host-owned actor reduces guest flexibility and makes platform adapters and + trusted UI mandatory, but prevents a compromised product from retaining + spendable Chat material or approving its own payment. +- Retiring method 11 requires a coordinated upgrade rather than a transparent + compatibility shim. Keeping the raw interface would preserve the custody + violation this proposal is intended to remove. +- Durable custody before ACK consumes storage and can delay transport progress. + Acknowledging earlier trades that cost for unrecoverable funds and is rejected. +- `u64` native Chat amounts preserve the existing actor contract but require + explicit checked adaptation to RFC 0017's narrower dotUSD `Balance`. +- Sharing the main purse gives users their ordinary wallet funds, but requires + wallet-wide serialization and migration instead of independent per-product + coin allocators. Separate product purses remain the concern of RFC 0017. +- Delivery and settlement are deliberately separate, so a product must show + pending/recovery states. Optimistic “paid” status based on an ACK is rejected. diff --git a/hosts/ios/Packages/ChainRegistry/Sources/ChainRegistry/Services/ConnectionPool/ConnectionTransportFactory.swift b/hosts/ios/Packages/ChainRegistry/Sources/ChainRegistry/Services/ConnectionPool/ConnectionTransportFactory.swift index 054a9c8dc..d01008a79 100644 --- a/hosts/ios/Packages/ChainRegistry/Sources/ChainRegistry/Services/ConnectionPool/ConnectionTransportFactory.swift +++ b/hosts/ios/Packages/ChainRegistry/Sources/ChainRegistry/Services/ConnectionPool/ConnectionTransportFactory.swift @@ -3,7 +3,13 @@ import SubstrateSdk import Starscream public final class ConnectionTransportFactory: WebSocketConnectionFactoryProtocol { - public init() {} + private let shouldConnect: ((URL) -> Bool)? + + /// Optional policy evaluated before every physical dial, including SDK + /// reconnects. A refusal follows the existing engine cancellation path. + public init(shouldConnect: ((URL) -> Bool)? = nil) { + self.shouldConnect = shouldConnect + } public func createConnection( for url: URL, @@ -21,6 +27,64 @@ public final class ConnectionTransportFactory: WebSocketConnectionFactoryProtoco let connection = WebSocket(request: request, engine: engine) connection.callbackQueue = processingQueue + if let shouldConnect { + return GuardedConnection(connection: connection, url: url, shouldConnect: shouldConnect) + } + return connection } } + +private final class GuardedConnection: WebSocketConnectionProtocol { + private let connection: WebSocket + private let url: URL + private let shouldConnect: (URL) -> Bool + + var callbackQueue: DispatchQueue { connection.callbackQueue } + var delegate: WebSocketDelegate? { + get { connection.delegate } + set { connection.delegate = newValue } + } + + init(connection: WebSocket, url: URL, shouldConnect: @escaping (URL) -> Bool) { + self.connection = connection + self.url = url + self.shouldConnect = shouldConnect + } + + func connect() { + guard shouldConnect(url) else { + connection.didReceive(event: .cancelled) + return + } + connection.connect() + } + + func disconnect(closeCode: UInt16) { + connection.disconnect(closeCode: closeCode) + } + + func forceDisconnect() { + connection.forceDisconnect() + } + + func write(string: String, completion: (() -> Void)?) { + connection.write(string: string, completion: completion) + } + + func write(stringData: Data, completion: (() -> Void)?) { + connection.write(stringData: stringData, completion: completion) + } + + func write(data: Data, completion: (() -> Void)?) { + connection.write(data: data, completion: completion) + } + + func write(ping: Data, completion: (() -> Void)?) { + connection.write(ping: ping, completion: completion) + } + + func write(pong: Data, completion: (() -> Void)?) { + connection.write(pong: pong, completion: completion) + } +} diff --git a/hosts/ios/polkadot-app/Common/Username/Networking/UsernameApi.swift b/hosts/ios/polkadot-app/Common/Username/Networking/UsernameApi.swift index 5161137e0..f8ddcf64e 100644 --- a/hosts/ios/polkadot-app/Common/Username/Networking/UsernameApi.swift +++ b/hosts/ios/polkadot-app/Common/Username/Networking/UsernameApi.swift @@ -55,6 +55,7 @@ extension UsernameApi.V1: URLConvertible { switch self { case let .search(usernameRequest): [URLQueryItem(name: "prefix", value: usernameRequest.prefix)] + + (usernameRequest.cursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? []) case .available: [URLQueryItem(name: "version", value: "v1")] default: diff --git a/hosts/ios/polkadot-app/Common/Username/Networking/UsernameRequestModel.swift b/hosts/ios/polkadot-app/Common/Username/Networking/UsernameRequestModel.swift index ce1b8e3df..6f17c87d3 100644 --- a/hosts/ios/polkadot-app/Common/Username/Networking/UsernameRequestModel.swift +++ b/hosts/ios/polkadot-app/Common/Username/Networking/UsernameRequestModel.swift @@ -2,11 +2,14 @@ import Foundation struct UsernameRequestModel { let prefix: String + let cursor: String? init( prefix: String, - caseSensitive: Bool = false + caseSensitive: Bool = false, + cursor: String? = nil ) { self.prefix = caseSensitive ? prefix : prefix.lowercased() + self.cursor = cursor } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/MainPursePaymentPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/MainPursePaymentPromptViewFactory.swift new file mode 100644 index 000000000..e23003344 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/MainPursePaymentPromptViewFactory.swift @@ -0,0 +1,124 @@ +import Foundation +import Foundation_iOS +import FoundationExt +import PolkadotUI +import TrUAPIHost +import UIKit +import UIKitExt + +/// A single exact payment, never a permission or an AutoSigning grant. +@MainActor +final class MainPursePaymentConfirmationContext { + let review: MainPurseChatPaymentReview + private var continuation: CheckedContinuation? + + init(review: MainPurseChatPaymentReview) { + self.review = review + } + + deinit { + continuation?.resume(returning: false) + } + + func setContinuation(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func deliver(_ approved: Bool) { + continuation?.resume(returning: approved) + continuation = nil + } +} + +@MainActor +enum MainPursePaymentPromptViewFactory { + static func createView(context: MainPursePaymentConfirmationContext) -> ControllerBackedProtocol { + let review = context.review + // All identity fields come from the Host review. `.normal` is literal + // text, not HTML/Markdown; product content cannot supply the prompt. + let details = """ + Product: \(review.callingProductId) + + Recipient name (Host-resolved): \(review.recipientUsername ?? "Not available") + Recipient identity: 0x\(review.recipientIdentity.toHex()) + + Recipient receives: \(amount(review.amountCents)) dotUSD + Maximum main-purse debit: \(amount(review.maxDebitCents)) dotUSD + + Host-selected network (genesis): 0x\(review.genesisHash.toHex()) + Coinage asset: \(review.coinageInstanceId.map { "instance \($0)" } ?? "legacy single asset") + Payment operation: 0x\(review.operationId.toHex()) + + Approve only this payment from your main purse. This does not grant permission for future payments. + """ + let viewModel = TitleDetailsSheetViewModel( + graphics: UIImage(systemName: "creditcard"), + title: LocalizableResource { _ in "Confirm main-purse payment" }, + message: LocalizableResource { _ in .normal(details) }, + mainAction: MessageSheetAction( + title: LocalizableResource { _ in "Confirm payment" }, + handler: { context.deliver(true) } + ), + secondaryAction: MessageSheetAction( + title: LocalizableResource { _ in String(localized: .Common.reject) }, + handler: { context.deliver(false) } + ) + ) + let view = TitleDetailsSheetViewFactory.createView( + from: viewModel, + styler: ProductPromptStyler(), + allowsSwipeDown: false + ) + return MainPursePaymentScrollController(content: view.controller) + } + + /// Integer-only formatting preserves every cent, including values beyond + /// Double's exact integer range. There is no whole-coin rounding. + private static func amount(_ cents: UInt64) -> String { + let fraction = cents % 100 + return "\(cents / 100).\(fraction < 10 ? "0" : "")\(fraction)" + } +} + +/// The normal title/details sheet does not scroll. Payment identities and +/// genesis must remain fully readable on small screens and at large text sizes. +@MainActor +private final class MainPursePaymentScrollController: UIViewController, ControllerBackedProtocol { + private let content: UIViewController + + init(content: UIViewController) { + self.content = content + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + isModalInPresentation = true + sheetPresentationController?.detents = [.large()] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + let scroll = UIScrollView() + scroll.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scroll) + addChild(content) + content.view.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(content.view) + NSLayoutConstraint.activate([ + scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scroll.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), + content.view.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), + content.view.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), + content.view.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), + content.view.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), + content.view.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor) + ]) + content.didMove(toParent: self) + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainConnectionPool.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainConnectionPool.swift index df6e7d27f..b54aaf139 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainConnectionPool.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainConnectionPool.swift @@ -2,12 +2,15 @@ import Foundation import os import SDKLogger import SubstrateSdk +import ChainRegistry public protocol TrUAPIChainConnecting: AnyObject, Sendable { /// Receives responses/termination events for all logical connections. var eventHandler: TrUAPIChainEventHandling? { get set } func connect(genesisHash: Data) -> UInt32? + func allowedHopEndpoints(bulletinGenesisHash: Data) -> [String] + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? func send(connectionId: UInt32, request: String) throws func close(connectionId: UInt32) func closeAll() @@ -20,25 +23,58 @@ public protocol TrUAPIChainEventHandling: AnyObject { public enum TrUAPIChainConnectionError: Error { case unknownConnection(UInt32) + case untrustedHopEndpoint + case hopConnectionLimitReached } -/// JSON-RPC pipe per logical chain connection, multiplexed over the host's -/// shared per-chain engines: each connect wraps a TrUAPIChainRpcAdapter -/// around the engine the resolver returns, so N sessions share one physical -/// socket per chain. A chain the resolver cannot serve is unsupported -/// (connect returns nil → the core maps None to "chain provider -/// unavailable"). `chainDidClose` is never emitted here: the shared engine -/// reconnects transparently and stateful subscriptions surface reconnects as -/// synthesized termination events, which is the core's designed recovery path. +/// Shared chain engines and pool-owned HOP engines use the same logical +/// connection ids and JSON-RPC adapter. Shared chains retain their transparent +/// reconnection behavior; a HOP connection ends when its dedicated socket +/// disconnects, so the core must recheck live trust before opening another. public final class TrUAPIChainConnectionPool: TrUAPIChainConnecting, @unchecked Sendable { - /// Resolves the shared engine for a chain genesis hash. The host injects - /// the lookup (e.g. via its chain registry); the pool never dials nodes. public typealias EngineResolver = @Sendable (_ genesisHash: Data) -> JSONRPCEngine? + public typealias HopEndpointResolver = @Sendable (_ bulletinGenesisHash: Data) -> [String] + + private static let maximumHopConnections = 16 + + /// Serializes sends against close so a racing sender cannot restart + /// an engine after it has been removed from the pool. + private final class Connection { + let adapter: TrUAPIChainRpcAdapter + let ownedEngine: WebSocketEngine? + private let lock = NSRecursiveLock() + private var closed = false + + init(adapter: TrUAPIChainRpcAdapter, ownedEngine: WebSocketEngine? = nil) { + self.adapter = adapter + self.ownedEngine = ownedEngine + } + + func send(connectionId: UInt32, request: String) throws { + lock.lock() + defer { lock.unlock() } + guard !closed else { + throw TrUAPIChainConnectionError.unknownConnection(connectionId) + } + adapter.handle(request: request) + } + + func close() { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + ownedEngine?.delegate = nil + adapter.tearDown() + ownedEngine?.disconnectIfNeeded(true) + } + } private struct State { weak var eventHandler: TrUAPIChainEventHandling? - var adapters: [UInt32: TrUAPIChainRpcAdapter] = [:] + var connections: [UInt32: Connection] = [:] var connectionIds: [ObjectIdentifier: UInt32] = [:] + var ownedEngineIds: [ObjectIdentifier: UInt32] = [:] var nextConnectionId: UInt32 = 1 } @@ -48,14 +84,49 @@ public final class TrUAPIChainConnectionPool: TrUAPIChainConnecting, @unchecked } private let engineResolver: EngineResolver + private let hopEndpointResolver: HopEndpointResolver private let logger: SDKLoggerProtocol private let state = OSAllocatedUnfairLock(initialState: State()) - public init(engineResolver: @escaping EngineResolver, logger: SDKLoggerProtocol) { + public init( + engineResolver: @escaping EngineResolver, + hopEndpointResolver: @escaping HopEndpointResolver = { _ in [] }, + logger: SDKLoggerProtocol + ) { self.engineResolver = engineResolver + self.hopEndpointResolver = hopEndpointResolver self.logger = logger } + /// Resolve the configured Bulletin chain on every lookup, never a cached + /// endpoint snapshot or the chain named by an untrusted endpoint. + convenience init(chainRegistry: ChainRegistryProtocol, logger: SDKLoggerProtocol) { + self.init( + engineResolver: { genesisHash in + chainRegistry.getChainByGenesis(for: genesisHash.toHex()).flatMap { chain in + chainRegistry.getConnection(for: chain.chainId) + } + }, + hopEndpointResolver: { bulletinGenesisHash in + guard + let chain = chainRegistry.getChain(for: AppConfig.Chains.bulletInChain), + let genesisHex = chain.explicitGenesisHash, + let genesisHash = try? Data(hexString: genesisHex), + genesisHash == bulletinGenesisHash, + let apis = chain.externalApis?.hop() + else { + return [] + } + return apis.map { $0.url.absoluteString }.sorted() + }, + logger: logger + ) + } + + deinit { + closeAll() + } + public func connect(genesisHash: Data) -> UInt32? { guard let engine = engineResolver(genesisHash) else { logger.debug( @@ -68,40 +139,99 @@ public final class TrUAPIChainConnectionPool: TrUAPIChainConnecting, @unchecked adapter.delegate = self return state.withLock { state in - let connectionId = state.nextConnectionId - state.nextConnectionId += 1 - state.adapters[connectionId] = adapter - state.connectionIds[ObjectIdentifier(adapter)] = connectionId + register(Connection(adapter: adapter), state: &state) + } + } + + public func allowedHopEndpoints(bulletinGenesisHash: Data) -> [String] { + guard bulletinGenesisHash.count == 32 else { return [] } + return hopEndpointResolver(bulletinGenesisHash).filter { Self.hopURL(endpoint: $0) != nil } + } + + public func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? { + let endpoints = allowedHopEndpoints(bulletinGenesisHash: bulletinGenesisHash) + guard !endpoints.isEmpty else { return nil } + // Byte-exact comparison, not URL equality, origin matching, or Swift's + // Unicode-normalizing String equality. + guard + endpoints.contains(where: { $0.utf8.elementsEqual(endpoint.utf8) }), + let url = Self.hopURL(endpoint: endpoint) + else { + throw TrUAPIChainConnectionError.untrustedHopEndpoint + } + + // Rust installs its response/close receiver after this callback + // returns. Keep the engine idle until the first send: SDK callMethod + // queues that request and starts the socket itself. + return try state.withLock { state -> UInt32? in + guard state.ownedEngineIds.count < Self.maximumHopConnections else { + throw TrUAPIChainConnectionError.hopConnectionLimitReached + } + let dialed = OSAllocatedUnfairLock(initialState: false) + let transportFactory = ConnectionTransportFactory { [hopEndpointResolver] dialURL in + dialed.withLock { attempted in + guard !attempted else { return false } + attempted = true + // One physical dial per HOP lease, checked again at the + // transport boundary. Core operations own all retries. + return dialURL.absoluteString.utf8.elementsEqual(endpoint.utf8) + && hopEndpointResolver(bulletinGenesisHash).contains { + $0.utf8.elementsEqual(endpoint.utf8) + } + } + } + guard let engine = WebSocketEngine( + urls: [url], + connectionFactory: transportFactory, + reconnectionStrategy: nil, + autoconnect: false, + logger: nil + ) else { + return nil + } + // HOP parameters/results contain private tickets and payloads. + // Neither the SDK engine nor its adapter may log their frames. + let adapter = TrUAPIChainRpcAdapter(engine: engine, logger: nil, resendOnReconnect: false) + adapter.delegate = self + engine.delegate = self + let connection = Connection(adapter: adapter, ownedEngine: engine) + guard let connectionId = register(connection, state: &state) else { return nil } + state.ownedEngineIds[ObjectIdentifier(engine)] = connectionId return connectionId } } public func send(connectionId: UInt32, request: String) throws { - guard let adapter = adapter(for: connectionId) else { + guard let connection = state.withLock({ $0.connections[connectionId] }) else { throw TrUAPIChainConnectionError.unknownConnection(connectionId) } - adapter.handle(request: request) + try connection.send(connectionId: connectionId, request: request) } public func close(connectionId: UInt32) { - let adapter = state.withLock { state -> TrUAPIChainRpcAdapter? in - let adapter = state.adapters.removeValue(forKey: connectionId) - if let adapter { - state.connectionIds.removeValue(forKey: ObjectIdentifier(adapter)) - } - return adapter + let (connection, handler) = state.withLock { state in + (remove(connectionId: connectionId, state: &state), state.eventHandler) + } + connection?.close() + if connection?.ownedEngine != nil { + handler?.chainDidClose(connectionId: connectionId) } - adapter?.tearDown() } public func closeAll() { - let all = state.withLock { state -> [UInt32: TrUAPIChainRpcAdapter] in - let all = state.adapters - state.adapters.removeAll() + let (all, handler) = state.withLock { state in + let all = state.connections + state.connections.removeAll() state.connectionIds.removeAll() - return all + state.ownedEngineIds.removeAll() + return (all, state.eventHandler) + } + for (connectionId, connection) in all { + connection.close() + if connection.ownedEngine != nil { + handler?.chainDidClose(connectionId: connectionId) + } } - all.values.forEach { $0.tearDown() } } } @@ -116,8 +246,86 @@ extension TrUAPIChainConnectionPool: TrUAPIChainRpcAdapterDelegate { } } +extension TrUAPIChainConnectionPool: WebSocketEngineDelegate { + public func webSocketDidChangeState( + _ connection: AnyObject, + from oldState: WebSocketEngine.State, + to newState: WebSocketEngine.State + ) { + switch newState { + case .connected: + return + case .connecting: + // The initial dial is not a close. An established socket being + // restarted by the engine, however, must not silently replay HOP. + guard case .connected = oldState else { return } + case .notConnected, .waitingReconnection: + break + } + closeOwnedEngine(connection) + } + + public func webSocketDidSwitchURL(_ connection: AnyObject, newUrl _: URL) { + // Dedicated HOP engines have exactly one allowed URL. + closeOwnedEngine(connection) + } +} + private extension TrUAPIChainConnectionPool { - func adapter(for connectionId: UInt32) -> TrUAPIChainRpcAdapter? { - state.withLock { $0.adapters[connectionId] } + static func hopURL(endpoint: String) -> URL? { + guard + endpoint.hasPrefix("wss://"), + !endpoint.unicodeScalars.contains(where: { + $0 == "\\" || $0.properties.isWhitespace || $0.properties.generalCategory == .control + }), + !endpoint.dropFirst(6).prefix(while: { + $0 != "/" && $0 != "?" && $0 != "#" + }).contains("@"), + let url = URL(string: endpoint), + url.scheme == "wss", + url.user == nil, + url.password == nil, + url.fragment == nil, + let host = url.host, + !host.isEmpty, + url.absoluteString.utf8.elementsEqual(endpoint.utf8) + else { + return nil + } + return url + } + + func register(_ connection: Connection, state: inout State) -> UInt32? { + // Never wrap and alias a still-live or delayed native notification. + guard state.nextConnectionId < UInt32.max else { return nil } + let connectionId = state.nextConnectionId + state.nextConnectionId += 1 + state.connections[connectionId] = connection + state.connectionIds[ObjectIdentifier(connection.adapter)] = connectionId + return connectionId + } + + func remove(connectionId: UInt32, state: inout State) -> Connection? { + guard let connection = state.connections.removeValue(forKey: connectionId) else { return nil } + state.connectionIds.removeValue(forKey: ObjectIdentifier(connection.adapter)) + if let engine = connection.ownedEngine { + state.ownedEngineIds.removeValue(forKey: ObjectIdentifier(engine)) + } + return connection + } + + func closeOwnedEngine(_ engine: AnyObject) { + let closed = state.withLock { state -> (UInt32, Connection, TrUAPIChainEventHandling?)? in + guard + let connectionId = state.ownedEngineIds[ObjectIdentifier(engine)], + let connection = remove(connectionId: connectionId, state: &state) + else { + return nil + } + return (connectionId, connection, state.eventHandler) + } + guard let (connectionId, connection, handler) = closed else { return } + connection.close() + handler?.chainDidClose(connectionId: connectionId) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainRpcAdapter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainRpcAdapter.swift index f65ddf633..ef729ec0e 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainRpcAdapter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Connection/TrUAPIChainRpcAdapter.swift @@ -10,7 +10,7 @@ public protocol TrUAPIChainRpcAdapterDelegate: AnyObject { } /// Verbatim JSON-RPC pipe adapter for ONE logical chain connection: parses -/// core frames and drives the shared typed engine. The engine owns wire ids, +/// core frames and drives the host's typed engine. The engine owns wire ids, /// queueing, reconnection, and remote-call routing; the adapter synthesizes /// response envelopes with the core's original request ids. /// @@ -47,13 +47,15 @@ public final class TrUAPIChainRpcAdapter: @unchecked Sendable { public weak var delegate: TrUAPIChainRpcAdapterDelegate? private let engine: JSONRPCEngine - private let logger: SDKLoggerProtocol + private let logger: SDKLoggerProtocol? + private let resendOnReconnect: Bool private let state = OSAllocatedUnfairLock(initialState: State()) private let jsonEncoder = JSONEncoder() - public init(engine: JSONRPCEngine, logger: SDKLoggerProtocol) { + public init(engine: JSONRPCEngine, logger: SDKLoggerProtocol?, resendOnReconnect: Bool = true) { self.engine = engine self.logger = logger + self.resendOnReconnect = resendOnReconnect } /// Handle one raw outgoing frame from the core. @@ -62,9 +64,9 @@ public final class TrUAPIChainRpcAdapter: @unchecked Sendable { case let .request(id, method, params): route(id: id, method: method, params: params) case let .notification(method, _): - logger.warning("TrUAPI rpc adapter: dropping core notification \(method)") + logger?.warning("TrUAPI rpc adapter: dropping core notification \(method)") case .unsupported: - logger.error("TrUAPI rpc adapter: unsupported frame dropped") + logger?.error("TrUAPI rpc adapter: unsupported frame dropped") } } @@ -98,7 +100,7 @@ private extension TrUAPIChainRpcAdapter { func call(id: TrUAPIRpcId, method: String, params: JSON?) { do { - logger.debug("calling [\(id)] \(method) with params: \(String(describing: params))") + logger?.debug("calling [\(id)] \(method) with params: \(String(describing: params))") // Written once before the completion can observe it; the engine // invokes the completion only after callMethod returns. @@ -106,9 +108,9 @@ private extension TrUAPIChainRpcAdapter { engineId = try engine.callMethod( method, params: params, - options: JSONRPCOptions(resendOnReconnect: true) + options: JSONRPCOptions(resendOnReconnect: resendOnReconnect) ) { [weak self, logger] (result: Result) in - logger.debug("call [\(id)] completed \(result)") + logger?.debug("call [\(id)] completed \(result)") self?.finishCall(engineId: engineId, originalId: id, result: result) } @@ -125,7 +127,7 @@ private extension TrUAPIChainRpcAdapter { family: TrUAPISubscriptionMethods.Family ) { if family.events == nil { - logger.warning( + logger?.warning( "TrUAPI rpc adapter: legacy subscription \(method) will not survive reconnects" ) } @@ -133,7 +135,7 @@ private extension TrUAPIChainRpcAdapter { let subscription = SubscriptionState(family: family, pendingRequestId: id) do { - logger.debug("subscribing [\(id)] \(method) with params: \(String(describing: params))") + logger?.debug("subscribing [\(id)] \(method) with params: \(String(describing: params))") let engineId = try engine.subscribe( method, @@ -147,7 +149,7 @@ private extension TrUAPIChainRpcAdapter { self?.deliverUpdate(subscription: subscription, frame: frame) }, failureClosure: { [weak self, logger] error, _ in - logger.debug("subscription failed [\(id)] \(method) with error: \(error)") + logger?.debug("subscription failed [\(id)] \(method) with error: \(error)") self?.finishSubscription(subscription: subscription, error: error) } ) @@ -162,7 +164,7 @@ private extension TrUAPIChainRpcAdapter { } func unsubscribe(id: TrUAPIRpcId, params: JSON?, family: TrUAPISubscriptionMethods.Family) { - logger.debug("unsubscribe [\(id)] with params: \(String(describing: params))") + logger?.debug("unsubscribe [\(id)] with params: \(String(describing: params))") let remoteId = params?.arrayValue?.first.flatMap(TrUAPISubscriptionId.init(paramValue:)) @@ -237,7 +239,7 @@ private extension TrUAPIChainRpcAdapter { do { data = try jsonEncoder.encode(frame) } catch { - logger.error("TrUAPI rpc adapter: update forwarding failed: \(error)") + logger?.error("TrUAPI rpc adapter: update forwarding failed: \(error)") return } @@ -273,7 +275,7 @@ private extension TrUAPIChainRpcAdapter { guard let engineId else { return } engine.cancelForIdentifiers([engineId], sendUnsubscribe: false) - logger.debug("TrUAPI rpc adapter: subscription released by terminal event \(event)") + logger?.debug("TrUAPI rpc adapter: subscription released by terminal event \(event)") } func finishSubscription(subscription: SubscriptionState, error: Error) { @@ -298,7 +300,7 @@ private extension TrUAPIChainRpcAdapter { result: events.connectionLossEvent ) } else { - logger.debug("TrUAPI rpc adapter: subscription ended: \(error)") + logger?.debug("TrUAPI rpc adapter: subscription ended: \(error)") } } } @@ -338,7 +340,7 @@ private extension TrUAPIChainRpcAdapter { guard let json = String(data: data, encoding: .utf8) else { return } delegate?.adapter(self, didProduce: json) } catch { - logger.error("TrUAPI rpc adapter: envelope encoding failed: \(error)") + logger?.error("TrUAPI rpc adapter: envelope encoding failed: \(error)") } } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift index f37def248..62dec400b 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift @@ -2,6 +2,8 @@ import Foundation import TrUAPIHost import ChainRegistry import SubstrateSdk +import Operation_iOS +import StructuredConcurrency /// `HostBridge` for the process-wide ``TrUAPIHostRuntime``. It has no product /// identity: it owns the host-global core storage, host-level chain access, and @@ -19,6 +21,7 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { private let chainRegistry: ChainRegistryProtocol private let chainConnections: TrUAPIChainConnecting private let confirmationPresenter: TrUAPIConfirmationPresenting + private let chatFiles: NativeChatFilesHost private let logger: LoggerProtocol private weak var runtime: TrUAPIHostRuntime? @@ -27,11 +30,13 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { coreStorage: TrUAPILocalStoring, chainConnections: TrUAPIChainConnecting, confirmationPresenter: TrUAPIConfirmationPresenting, + chatFiles: NativeChatFilesHost, logger: LoggerProtocol ) { self.chainRegistry = chainRegistry self.chainConnections = chainConnections self.confirmationPresenter = confirmationPresenter + self.chatFiles = chatFiles self.logger = logger self.coreStorage = CoreStorageBackend(storage: coreStorage) storage = EmptyHostStorageBackend() @@ -64,6 +69,14 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { chainConnections.connect(genesisHash: genesisHash) } + func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] { + chainConnections.allowedHopEndpoints(bulletinGenesisHash: bulletinGenesisHash) + } + + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? { + try chainConnections.hopConnect(bulletinGenesisHash: bulletinGenesisHash, endpoint: endpoint) + } + func chainSend(connectionId: UInt32, request: String) throws { try chainConnections.send(connectionId: connectionId, request: request) } @@ -72,11 +85,93 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { chainConnections.close(connectionId: connectionId) } + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] { + try await chatFiles.pickChatFiles(request: request) + } + + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data { + try await chatFiles.readChatFile(sourceId: sourceId, offset: offset, length: length) + } + + func releaseChatFile(sourceId: String) async throws { + try await chatFiles.releaseChatFile(sourceId: sourceId) + } + + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? { + try await chatFiles.beginChatFileExport(request: request) + } + + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws { + try await chatFiles.writeChatFileExport(exportId: exportId, offset: offset, data: data) + } + + func finishChatFileExport(exportId: String) async throws { + try await chatFiles.finishChatFileExport(exportId: exportId) + } + + func cancelChatFileExport(exportId: String) async throws { + try await chatFiles.cancelChatFileExport(exportId: exportId) + } + func confirmUserAction(review: UserConfirmationReview) async throws -> Bool { // TODO: pass the real SSO host identity once it is available at host level. await confirmationPresenter.confirm(review: review, from: "host") } + func identityUsernameCandidates(username: String, peopleChainGenesisHash: Data) async throws -> [Data] { + let people = try chainRegistry.getChainOrError(for: AppConfig.Chains.usernameChain) + guard let genesis = people.explicitGenesisHash, + try Data(hexString: genesis) == peopleChainGenesisHash, + AppConfigProvider.shared.getRemoteConfig()?.identityBackendUrl != nil else { + throw HostRejection.Rejected(reason: "configured native identity service unavailable for this People chain") + } + // Reuse the native service's RemoteAppConfig URL, request model, JSON + // decoder and JWT provider. No URL or credential is supplied by a guest. + let tokenProvider = JWTTokenManager.shared + let factory = UsernameOperationFactory(tokenProvider: tokenProvider) + var cursor: String? + var seenCursors = Set() + var candidates: [Data] = [] + // Search pagination follows the existing native backend cursor contract. + // Refuse an unbounded/incomplete index rather than choose the first hit. + for _ in 0..<32 { + let wrapper = factory.createJWTAuthorizedRequestWrapper( + endpoint: UsernameApi.V1.search(UsernameRequestModel( + prefix: username, caseSensitive: true, cursor: cursor + )), + responseFactory: UsernameJsonResultFactory(), + tokenProvider: tokenProvider + ) + let result = try await wrapper.asyncExecute() + candidates.append(contentsOf: try Self.identityCandidates(from: result, username: username)) + guard candidates.count <= 32 else { + throw HostRejection.Rejected(reason: "too many native username candidates") + } + guard let next = result.nextCursor else { return candidates } + guard seenCursors.insert(next).inserted else { + throw HostRejection.Rejected(reason: "native username search repeated a cursor") + } + cursor = next + } + throw HostRejection.Rejected(reason: "native username search is incomplete") + } + + static func identityCandidates(from result: UsernameSearchResult, username: String) throws -> [Data] { + // The backend's prefix hits and status labels never establish ownership. + // Core checks every exact-name AccountId32 against dotNS and People. + let exact = result.usernames.filter { $0.username.value == username } + guard exact.count <= 32 else { + throw HostRejection.Rejected(reason: "too many native username candidates") + } + return try exact.map { candidate in + let account = try candidate.accountId.toAccountId() + guard account.count == 32 else { + throw HostRejection.Rejected(reason: "native username candidate is not AccountId32") + } + return account + } + } + func featureSupported(request: HostFeatureSupportedRequest) async throws -> Bool { switch request { case let .chain(genesisHash): diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index 19ed480b8..32b78fdd9 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -24,6 +24,7 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { let productStorage: TrUAPILocalStoring let coreStorage: TrUAPILocalStoring let confirmationPresenter: TrUAPIConfirmationPresenting + let chatFiles: NativeChatFilesHost let preimageCache: TrUAPIPreimageLookuping let hostProvider: ProductHostProviding let logger: LoggerProtocol @@ -95,6 +96,36 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { } } + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] { + guard request.productId == dependencies.productId else { throw ChatFileFailure.unavailable } + return try await dependencies.chatFiles.pickChatFiles(request: request) + } + + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data { + try await dependencies.chatFiles.readChatFile(sourceId: sourceId, offset: offset, length: length) + } + + func releaseChatFile(sourceId: String) async throws { + try await dependencies.chatFiles.releaseChatFile(sourceId: sourceId) + } + + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? { + guard request.productId == dependencies.productId else { throw ChatFileFailure.unavailable } + return try await dependencies.chatFiles.beginChatFileExport(request: request) + } + + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws { + try await dependencies.chatFiles.writeChatFileExport(exportId: exportId, offset: offset, data: data) + } + + func finishChatFileExport(exportId: String) async throws { + try await dependencies.chatFiles.finishChatFileExport(exportId: exportId) + } + + func cancelChatFileExport(exportId: String) async throws { + try await dependencies.chatFiles.cancelChatFileExport(exportId: exportId) + } + func confirmUserAction(review: UserConfirmationReview) async throws -> Bool { await dependencies.confirmationPresenter.confirm(review: review, from: dependencies.productId) } @@ -103,6 +134,16 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { dependencies.chainConnections.connect(genesisHash: genesisHash) } + func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] { + dependencies.chainConnections.allowedHopEndpoints(bulletinGenesisHash: bulletinGenesisHash) + } + + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? { + try dependencies.chainConnections.hopConnect( + bulletinGenesisHash: bulletinGenesisHash, endpoint: endpoint + ) + } + func chainSend(connectionId: UInt32, request: String) throws { try dependencies.chainConnections.send(connectionId: connectionId, request: request) } @@ -115,8 +156,8 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { await dependencies.preimageCache.lookup(key: key) } - func currentTheme() throws -> ThemeVariant { - .dark + func currentTheme() throws -> HostThemeSubscribeItem { + HostThemeSubscribeItem(name: .default, variant: .dark) } func featureSupported(request: HostFeatureSupportedRequest) async throws -> Bool { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift index fbbf0ebea..f70a48a71 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift @@ -63,11 +63,7 @@ private extension RustRuntimeEnvironment { kind: ProductExecutionKind ) throws -> ExecutionModel { let chainConnections = TrUAPIChainConnectionPool( - engineResolver: { [chainRegistry] genesisHash in - chainRegistry.getChainByGenesis(for: genesisHash.toHex()).flatMap { chain in - chainRegistry.getConnection(for: chain.chainId) - } - }, + chainRegistry: chainRegistry, logger: logger ) @@ -103,6 +99,7 @@ private extension RustRuntimeEnvironment { productStorage: TrUAPILocalStorage.createProductLocalStorage(productId: productId), coreStorage: TrUAPILocalStorage.createCoreLocalStorage(), confirmationPresenter: TrUAPIConfirmationPresenter(routerFacade: routers), + chatFiles: TrUAPINativeChatFiles.shared, preimageCache: TrUAPIPreimageCache { [logger, ipfsFetcher] key in do { return try await ipfsFetcher.lookupBy(rawHash: key) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift index 5038513fe..5376c371b 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift @@ -25,10 +25,9 @@ final class ProductStorageBackend: HostStorageBackend, @unchecked Sendable { } } -/// Adapts the host-global core ``TrUAPILocalStoring`` to -/// `HostCoreStorageBackend`. Core keys are SCALE-encoded `Data`; they are -/// hex-encoded for the underlying String-keyed store. Plain Swift errors -/// surface as `HostRejection`. +/// Adapts core-private SCALE keys. Existing slots retain their UserDefaults +/// backing; wallet-state slots use atomic, synced files outside product storage. +/// Plain Swift errors (including ambiguous durable writes) become HostRejection. final class CoreStorageBackend: HostCoreStorageBackend, @unchecked Sendable { private let storage: TrUAPILocalStoring @@ -37,14 +36,23 @@ final class CoreStorageBackend: HostCoreStorageBackend, @unchecked Sendable { } func read(key: Data) throws -> Data? { - try withHostRejection { try storage.read(key: key.toHex()) } + if TrUAPIWalletStorage.owns(key) { + return try withHostRejection { try TrUAPIWalletStorage.shared.read(key: key) } + } + return try withHostRejection { try storage.read(key: key.toHex()) } } func write(key: Data, value: Data) throws { + if TrUAPIWalletStorage.owns(key) { + return try withHostRejection { try TrUAPIWalletStorage.shared.write(key: key, value: value) } + } try withHostRejection { try storage.write(key: key.toHex(), value: value) } } func clear(key: Data) throws { + if TrUAPIWalletStorage.owns(key) { + return try withHostRejection { try TrUAPIWalletStorage.shared.clear(key: key) } + } try withHostRejection { try storage.clear(key: key.toHex()) } } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIChatFileStore.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIChatFileStore.swift new file mode 100644 index 000000000..e0005ccd3 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIChatFileStore.swift @@ -0,0 +1,376 @@ +import Foundation +import Darwin +import ImageIO +import AVFoundation +import UIKit +import UniformTypeIdentifiers +import BlurHash +import TrUAPIHost + +/// Application Support attachment storage, like AttachmentStore's native Chat +/// uploads/downloads. Only canonical random IDs are ever used as filenames. +/// A single process-wide actor serializes reads, release and export writes. +actor TrUAPIChatFileStore { + static let shared = TrUAPIChatFileStore() + private let sources: AttachmentStoring? + private let exports: AttachmentStoring? + private var pendingExports: [String: Export] = [:] + private static let mediaExtensions = [ + "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", + "image/tiff": "tiff", "image/heic": "heic", "image/heif": "heif", + "image/bmp": "bmp", "image/webp": "webp", + "video/mp4": "mp4", "video/quicktime": "mov" + ] + + private struct Export { + let size: UInt64 + var written: UInt64 = 0 + var presenting = false + } + + init( + sources: AttachmentStoring? = AttachmentStore.attachmentsInDocument(directory: "TrUAPIChatSources"), + exports: AttachmentStoring? = AttachmentStore.attachmentsInDocument(directory: "TrUAPIChatExports") + ) { + self.sources = sources + self.exports = exports + } + + func importFiles(_ urls: [URL]) async throws -> [NativeChatPickedFile] { + let store = try prepare(sources) + var imported: [NativeChatPickedFile] = [] + do { + for url in urls { + try Task.checkCancellation() + let id = UUID().uuidString + let destination = store.fileURL(for: id) + let temporary = store.fileURL(for: id + ".pending") + var committed = false + defer { + try? FileManager.default.removeItem(at: temporary) + if !committed { try? FileManager.default.removeItem(at: destination) } + } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + var coordinationError: NSError? + var copyResult: Result? + NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: .withoutChanges, error: &coordinationError) { + coordinatedURL in + copyResult = Result { + _ = try self.fileSize(coordinatedURL) + try FileManager.default.copyItem(at: coordinatedURL, to: temporary) + } + } + if let coordinationError { throw coordinationError } + guard let copyResult else { throw ChatFileFailure.unavailable } + try copyResult.get() + let size = try fileSize(temporary) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + try protectAndSync(temporary) + try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: temporary.path) + try FileManager.default.moveItem(at: temporary, to: destination) + try syncDirectory(destination.deletingLastPathComponent()) + let metadata = await self.metadata(for: destination, size: size) + imported.append(NativeChatPickedFile(sourceId: id, metadata: metadata)) + committed = true + } + try Task.checkCancellation() + return imported + } catch { + for file in imported { try? store.remove(for: file.sourceId) } + throw error + } + } + + func read(sourceId: String, offset: UInt64, length: UInt32) throws -> Data { + try Task.checkCancellation() + guard let store = sources else { throw ChatFileFailure.unavailable } + let url = try sourceURL(sourceId, store: store) + let descriptor = try openRegular(url, flags: O_RDONLY) + defer { Darwin.close(descriptor) } + let size = try size(of: descriptor) + guard length <= 2_000_000, offset <= size, UInt64(length) <= size - offset else { + throw ChatFileFailure.invalidRange + } + var result = Data(count: Int(length)) + try result.withUnsafeMutableBytes { bytes in + var done = 0 + while done < bytes.count { + let count = Darwin.pread(descriptor, bytes.baseAddress!.advanced(by: done), bytes.count - done, off_t(offset) + off_t(done)) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw ChatFileFailure.io } + done += count + } + } + return result + } + + func release(sourceId: String) throws { + let store = try prepare(sources) + let url = try sourceURL(sourceId, store: store) + try removeIfPresent(url) + try syncDirectory(url.deletingLastPathComponent()) + } + + func beginExport(size: UInt32) throws -> String { + try Task.checkCancellation() + let store = try prepare(exports) + let id = UUID().uuidString + let url = store.fileURL(for: id + ".bin") + let descriptor = url.path.withCString { + Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { throw ChatFileFailure.io } + Darwin.close(descriptor) + do { + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } + pendingExports[id] = Export(size: UInt64(size)) + return id + } + + func write(exportId: String, offset: UInt64, data: Data) throws { + try Task.checkCancellation() + guard let store = exports else { throw ChatFileFailure.unavailable } + try validate(exportId) + guard var state = pendingExports[exportId], !state.presenting, + offset == state.written, data.count <= 2_000_000, + UInt64(data.count) <= state.size - state.written else { + throw ChatFileFailure.invalidRange + } + let url = store.fileURL(for: exportId + ".bin") + let descriptor = try openRegular(url, flags: O_WRONLY) + defer { Darwin.close(descriptor) } + guard try size(of: descriptor) == state.written else { throw ChatFileFailure.invalidRange } + try data.withUnsafeBytes { bytes in + var done = 0 + while done < bytes.count { + let count = Darwin.pwrite(descriptor, bytes.baseAddress!.advanced(by: done), bytes.count - done, off_t(offset) + off_t(done)) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw ChatFileFailure.io } + done += count + } + } + state.written += UInt64(data.count) + pendingExports[exportId] = state + } + + /// Seal before presenting Files. The UI only receives an exact-size file, + /// and later writes/cancellation cannot mutate it while Files copies it. + func sealExport(exportId: String) async throws -> URL { + try Task.checkCancellation() + guard let store = exports else { throw ChatFileFailure.unavailable } + try validate(exportId) + guard var state = pendingExports[exportId], !state.presenting, state.written == state.size else { + throw ChatFileFailure.invalidRange + } + let url = store.fileURL(for: exportId + ".bin") + guard UInt64(try fileSize(url)) == state.size else { throw ChatFileFailure.invalidRange } + try protectAndSync(url) + state.presenting = true + pendingExports[exportId] = state + // Derive a safe extension from the sealed bytes, never a remote MIME + // claim. The save UI still never opens or plays the document. + let detected = await metadata(for: url, size: UInt32(state.size)) + try Task.checkCancellation() + guard let suffix = Self.mediaExtensions[detected.mimeType] else { return url } + let destination = store.fileURL(for: exportId + "." + suffix) + try FileManager.default.moveItem(at: url, to: destination) + return destination + } + + /// Only the private staging file is removed, never the user's chosen copy. + func completeExport(exportId: String) throws { + try validate(exportId) + let store = try prepare(exports) + try removeIfPresent(store.fileURL(for: exportId + ".bin")) + for suffix in Self.mediaExtensions.values { + try removeIfPresent(store.fileURL(for: exportId + "." + suffix)) + } + pendingExports.removeValue(forKey: exportId) + } + + func cancelExport(exportId: String) throws { + try validate(exportId) + // finish owns cleanup while a save sheet is in flight. Its cancellation + // handler dismisses the sheet before releasing the staging file. + guard pendingExports[exportId]?.presenting != true else { return } + try completeExport(exportId: exportId) + } + + private func prepare(_ store: AttachmentStoring?) throws -> AttachmentStoring { + guard let store else { throw ChatFileFailure.unavailable } + try store.createDirectoryIfNeeded() + let directory = store.fileURL(for: "").standardizedFileURL + try FileManager.default.setAttributes([ + .posixPermissions: 0o700, + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ], ofItemAtPath: directory.path) + try syncDirectory(directory) + try syncDirectory(directory.deletingLastPathComponent()) + return store + } + + private func validate(_ id: String) throws { + guard let uuid = UUID(uuidString: id), uuid.uuidString == id else { throw ChatFileFailure.invalidHandle } + } + + private func sourceURL(_ id: String, store: AttachmentStoring) throws -> URL { + try validate(id) + return store.fileURL(for: id) + } + + private func openRegular(_ url: URL, flags: Int32) throws -> Int32 { + let descriptor = url.path.withCString { Darwin.open($0, flags | O_NOFOLLOW | O_NONBLOCK) } + guard descriptor >= 0 else { throw ChatFileFailure.io } + do { + _ = try size(of: descriptor) + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private func size(of descriptor: Int32) throws -> UInt64 { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size >= 0, UInt64(info.st_size) <= UInt64(UInt32.max) else { + throw ChatFileFailure.invalidSize + } + return UInt64(info.st_size) + } + + private func fileSize(_ url: URL) throws -> UInt32 { + let descriptor = try openRegular(url, flags: O_RDONLY) + defer { Darwin.close(descriptor) } + return UInt32(try size(of: descriptor)) + } + + private func protectAndSync(_ url: URL) throws { + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + let descriptor = try openRegular(url, flags: O_WRONLY) + defer { Darwin.close(descriptor) } + guard Darwin.fsync(descriptor) == 0, Darwin.fcntl(descriptor, F_FULLFSYNC) == 0 else { + throw ChatFileFailure.io + } + } + + private func syncDirectory(_ url: URL) throws { + let descriptor = url.path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) } + guard descriptor >= 0 else { throw ChatFileFailure.io } + defer { Darwin.close(descriptor) } + guard Darwin.fsync(descriptor) == 0 else { throw ChatFileFailure.io } + } + + private func removeIfPresent(_ url: URL) throws { + let result = url.path.withCString { Darwin.unlink($0) } + guard result == 0 || errno == ENOENT else { throw ChatFileFailure.io } + } + + private func metadata(for url: URL, size: UInt32) async -> HostNativeChatAttachmentMetadata { + // Detect raster image content from the immutable bytes, not a filename + // or provider MIME claim. SVG/HTML and unrecognized files remain opaque. + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let identifier = CGImageSourceGetType(source), + let type = UTType(identifier as String), type.conforms(to: .image), !type.conforms(to: .svg), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber, + width.doubleValue > 0, width.doubleValue <= Double(UInt32.max), + height.doubleValue > 0, height.doubleValue <= Double(UInt32.max) else { + return await videoMetadata(for: url, size: size) + ?? HostNativeChatAttachmentMetadata(mimeType: "application/octet-stream", sizeBytes: size, kind: .file) + } + // Reuse the native Chat's bounded image downsampling and UTF-8 BlurHash + // convention, but do not transcode or consult the mutable original. + let thumbnail = UIImage.downsampleImage( + at: url, maxSideSize: BlurHashConfiguration.encodingMaximumSide, scale: 1 + )?.blurHash(numberOfComponents: BlurHashConfiguration.components) + .flatMap { BlurHash($0) }?.toData() + return HostNativeChatAttachmentMetadata( + mimeType: type.preferredMIMEType ?? "application/octet-stream", + sizeBytes: size, + kind: .image(width: width.uint32Value, height: height.uint32Value, thumbnail: thumbnail) + ) + } + + private func videoMetadata(for url: URL, size: UInt32) async -> HostNativeChatAttachmentMetadata? { + // Only known self-contained ISO-BMFF/QuickTime containers are probed. + // No playlists, URLs, filename-based MIME guesses or external references. + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + let header = try? handle.read(upToCount: 32) + try? handle.close() + guard let header, header.count >= 12, + header[4..<8].elementsEqual([0x66, 0x74, 0x79, 0x70]) else { return nil } + let mime: String + switch String(decoding: header[8..<12], as: UTF8.self) { + case "qt ": mime = "video/quicktime" + case "isom", "iso2", "iso4", "iso5", "iso6", "mp41", "mp42", "avc1", "M4V ", "M4VH", "M4VP": + mime = "video/mp4" + default: return nil + } + let asset = AVURLAsset(url: url, options: [ + AVURLAssetReferenceRestrictionsKey: AVAssetReferenceRestrictions.forbidAll.rawValue + ]) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = BlurHashConfiguration.encodingPreviewSize + return try? await withThrowingTaskGroup(of: HostNativeChatAttachmentMetadata.self) { group in + group.addTask { + guard !(try await asset.loadTracks(withMediaType: .video)).isEmpty else { + throw ChatFileFailure.unavailable + } + let duration = try await asset.load(.duration) + guard duration.seconds.isFinite, duration.seconds >= 0, + duration.seconds <= Double(UInt32.max) else { + throw ChatFileFailure.invalidSize + } + var thumbnail: Data? + // Same bounded frame/BlurHash convention as PHVideoAttachmentProvider. + if let image = try? await generator.image(at: .zero).image { + thumbnail = UIImage(cgImage: image) + .blurHash(numberOfComponents: BlurHashConfiguration.components) + .flatMap { BlurHash($0) }?.toData() + } + try Task.checkCancellation() + return HostNativeChatAttachmentMetadata( + mimeType: mime, sizeBytes: size, + kind: .video(durationSeconds: UInt32(duration.seconds), thumbnail: thumbnail) + ) + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + asset.cancelLoading() + generator.cancelAllCGImageGeneration() + throw ChatFileFailure.unavailable + } + defer { + group.cancelAll() + asset.cancelLoading() + generator.cancelAllCGImageGeneration() + } + guard let result = try await group.next() else { throw ChatFileFailure.unavailable } + return result + } + } +} + +enum ChatFileFailure: Error { + case unavailable + case invalidHandle + case invalidRange + case invalidSize + case io + case userCancelled +} diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIWalletStorage.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIWalletStorage.swift new file mode 100644 index 000000000..2ae0b3fbe --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/TrUAPIWalletStorage.swift @@ -0,0 +1,150 @@ +import CryptoKit +import Darwin +import Foundation +import TrUAPIHost + +/// Host-owned encrypted wallet snapshots, outside every product's storage. +/// Only the new main-purse and native-Chat slots use this backend; existing +/// UserDefaults slots are unchanged. One process-wide lock covers all bridges. +final class TrUAPIWalletStorage: @unchecked Sendable { + static let shared = TrUAPIWalletStorage() + private let lock = NSLock() + + /// Stable SCALE discriminants from truapi_platform::CoreStorageKey. + /// The remaining encoded bytes include wallet root, genesis, and (for the + /// device) product. They are all included in the backing filename digest. + static func owns(_ key: Data) -> Bool { + switch key.first { + case 13, 14, 15, 16: true + default: false + } + } + + func read(key: Data) throws -> Data? { + try requireExclusiveCustody(key) + lock.lock() + defer { lock.unlock() } + let file = try fileURL(key: key) + do { + return try Data(contentsOf: file) + } catch let error as NSError + where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { + return nil + } + } + + func write(key: Data, value: Data) throws { + try requireExclusiveCustody(key) + lock.lock() + defer { lock.unlock() } + let file = try fileURL(key: key) + let directory = file.deletingLastPathComponent() + let temporary = directory.appendingPathComponent(".pending-\(UUID().uuidString)") + let descriptor = temporary.path.withCString { + Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode_t(0o600)) + } + guard descriptor >= 0 else { throw failure("create temporary snapshot") } + var open = true + defer { + if open { Darwin.close(descriptor) } + // Never remove the destination on failure: after rename it may be + // the only surviving durable operation journal. + temporary.path.withCString { _ = Darwin.unlink($0) } + } + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: temporary.path + ) + try value.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let written = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if written < 0 { + if errno == EINTR { continue } + throw failure("write temporary snapshot") + } + guard written > 0 else { throw IOFailure(operation: "write temporary snapshot", code: EIO, ambiguous: false) } + offset += written + } + } + guard Darwin.fsync(descriptor) == 0 else { throw failure("sync temporary snapshot") } + // fsync alone may stop at a device cache on Apple platforms. + guard Darwin.fcntl(descriptor, F_FULLFSYNC) == 0 else { throw failure("flush temporary snapshot") } + let closeResult = Darwin.close(descriptor) + open = false + guard closeResult == 0 else { throw failure("close temporary snapshot") } + let replaced = temporary.path.withCString { source in + file.path.withCString { destination in Darwin.rename(source, destination) } + } + guard replaced == 0 else { throw failure("replace wallet snapshot") } + // A failure here is AMBIGUOUS, not proof of no write. Core must poison + // its in-memory store and reconcile the journal after reopening. + try syncDirectory(directory, ambiguous: true) + } + + func clear(key: Data) throws { + try requireExclusiveCustody(key) + lock.lock() + defer { lock.unlock() } + let file = try fileURL(key: key) + let removed = file.path.withCString { Darwin.unlink($0) } + guard removed == 0 || errno == ENOENT else { throw failure("remove wallet snapshot") } + try syncDirectory(file.deletingLastPathComponent(), ambiguous: true) + } + + /// This reference wallet still spends through its independent native + /// CoinageService. A missing Rust snapshot is not an empty purse: refuse + /// before Core can allocate or claim inventory owned by the native ledger. + private func requireExclusiveCustody(_ key: Data) throws { + guard key.first != 13 else { + throw HostRejection.Rejected(reason: "main-purse custody unavailable: native CoinageService owns this wallet") + } + } + + private func fileURL(key: Data) throws -> URL { + let manager = FileManager.default + let support = try manager.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + var directory = support.appendingPathComponent("TrUAPIWalletState", isDirectory: true) + try manager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [ + .posixPermissions: 0o700, + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ]) + try manager.setAttributes([ + .posixPermissions: 0o700, + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ], ofItemAtPath: directory.path) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try directory.setResourceValues(values) + // Persist creation before acknowledging a snapshot in this directory. + try syncDirectory(directory, ambiguous: false) + try syncDirectory(support, ambiguous: false) + try syncDirectory(support.deletingLastPathComponent(), ambiguous: false) + // SHA-256 of the ENTIRE SCALE key is stable and collision-resistant, + // including product length framing. Unlike raw hex, it cannot exceed + // NAME_MAX for a long but valid product identifier. + let name = SHA256.hash(data: key).map { String(format: "%02x", $0) }.joined() + return directory.appendingPathComponent(name + ".snapshot") + } + + private func syncDirectory(_ directory: URL, ambiguous: Bool) throws { + let descriptor = directory.path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) } + guard descriptor >= 0 else { throw failure("open snapshot directory", ambiguous: ambiguous) } + defer { Darwin.close(descriptor) } + guard Darwin.fsync(descriptor) == 0 else { throw failure("sync snapshot directory", ambiguous: ambiguous) } + } + + private func failure(_ operation: String, ambiguous: Bool = false) -> IOFailure { + IOFailure(operation: operation, code: errno, ambiguous: ambiguous) + } + + private struct IOFailure: Error, CustomStringConvertible { + let operation: String + let code: Int32 + let ambiguous: Bool + + var description: String { + "Wallet storage \(operation) failed (errno \(code)); \(ambiguous ? "durability unknown; reopen and reconcile before spending" : "replacement not acknowledged")" + } + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIChatFilePresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIChatFilePresenter.swift new file mode 100644 index 000000000..8a23cd745 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIChatFilePresenter.swift @@ -0,0 +1,138 @@ +import Foundation +import UIKit +import UIKitExt +import UniformTypeIdentifiers +import TrUAPIHost + +protocol TrUAPIChatFilePresenting: Sendable { + @MainActor func selectFiles(request: NativeChatFilePickRequest) async throws -> [URL] + @MainActor func approveExport(request: NativeChatFileExportRequest) async throws -> Bool + @MainActor func saveFile(_ url: URL, exportId: String) async throws -> Bool +} + +/// Uses the app's top-window presentation convention and Apple's Files UI. +/// Recognized raster/video files retain safe extensions; all other documents +/// use .bin. Nothing is automatically opened, previewed or played. +struct TrUAPIChatFilePresenter: TrUAPIChatFilePresenting { + @MainActor private static var active: Presentation? + + @MainActor + func selectFiles(request: NativeChatFilePickRequest) async throws -> [URL] { + guard request.maxFiles > 0 else { throw ChatFileFailure.invalidRange } + let approved = try await confirm( + title: "Send Chat attachments?", + message: "Product: \(request.productId)\nRecipient: \(request.peerUsername ?? "Chat contact")\nChoose up to \(request.maxFiles) files to send.", + action: "Choose Files" + ) + guard approved else { return [] } + let selected = try await present { operation in + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true) + picker.allowsMultipleSelection = request.maxFiles > 1 + picker.delegate = operation + return picker + } ?? [] + // Do not silently choose a different subset from what the user selected. + guard selected.count <= Int(request.maxFiles) else { throw ChatFileFailure.invalidRange } + return selected + } + + @MainActor + func approveExport(request: NativeChatFileExportRequest) async throws -> Bool { + try await confirm( + title: "Save Chat attachment?", + message: "Product: \(request.productId)\nContact: \(request.peerUsername ?? "Chat contact")\nSize: \(request.metadata.sizeBytes) bytes\nThe attachment will be saved as a file, not opened. Only open files you trust.", + action: "Save to Files" + ) + } + + @MainActor + func saveFile(_ url: URL, exportId _: String) async throws -> Bool { + let result = try await present { operation in + let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true) + picker.delegate = operation + return picker + } + return result != nil + } + + @MainActor + private func confirm(title: String, message: String, action: String) async throws -> Bool { + let result = try await present { operation in + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + operation.finish(.success(nil)) + }) + alert.addAction(UIAlertAction(title: action, style: .default) { _ in + operation.finish(.success([])) + }) + return alert + } + return result != nil + } + + @MainActor + private func present( + makeController: (Presentation) -> UIViewController + ) async throws -> [URL]? { + try Task.checkCancellation() + guard Self.active == nil, + UIApplication.shared.applicationState == .active, + let parent = UIWindow.topWindow?.topmostViewController, + parent.viewIfLoaded?.window != nil, + !parent.isBeingDismissed, !parent.isBeingPresented, + !(parent is UIAlertController), !(parent is UIDocumentPickerViewController) else { + throw ChatFileFailure.unavailable + } + let operation = Presentation() + let controller = makeController(operation) + operation.controller = controller + Self.active = operation + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + operation.continuation = continuation + if Task.isCancelled { + operation.finish(.failure(CancellationError())) + } else { + parent.present(controller, animated: true) + controller.presentationController?.delegate = operation + } + } + } onCancel: { + Task { @MainActor in operation.finish(.failure(CancellationError())) } + } + } + + @MainActor + private final class Presentation: NSObject, UIDocumentPickerDelegate, UIAdaptivePresentationControllerDelegate { + var continuation: CheckedContinuation<[URL]?, Error>? + weak var controller: UIViewController? + + func finish(_ result: Result<[URL]?, Error>) { + guard let continuation else { return } + self.continuation = nil + let complete = { + if TrUAPIChatFilePresenter.active === self { + TrUAPIChatFilePresenter.active = nil + } + continuation.resume(with: result) + } + if let controller, controller.presentingViewController != nil { + controller.dismiss(animated: true, completion: complete) + } else { + complete() + } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + finish(.success(urls)) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + finish(.success(nil)) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish(.success(nil)) + } + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index f40eee949..9dcdf2a2e 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -48,6 +48,8 @@ private extension TrUAPIConfirmationPresenter { .signRaw, .createTransaction: try await confirmSigning(for: review, from: requesterName) + case let .mainPurseChatPayment(paymentReview): + await confirmMainPursePayment(paymentReview) case let .statementStoreProductSign(statementReview): await confirmStatementSign( promptMapper.makeStatementSignRequest(from: statementReview) @@ -108,6 +110,19 @@ private extension TrUAPIConfirmationPresenter { return decision == .approved } } + func confirmMainPursePayment(_ review: MainPurseChatPaymentReview) async -> Bool { + await awaitDecision { [routerFacade] in + await withCheckedContinuation { continuation in + let context = MainPursePaymentConfirmationContext(review: review) + context.setContinuation(continuation) + let prompt = MainPursePaymentPromptViewFactory.createView(context: context) + if !routerFacade.productsRouter.present(view: prompt) { + context.deliver(false) + } + } + } + } + func confirmPermissionReview(_ review: UserConfirmationReview) async throws -> Bool { let request: TrUAPIPermissionRequest diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift index d9bf7b465..f9b89e3ff 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift @@ -79,15 +79,12 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se chainRegistry: chainRegistry, secret: secret, liteUsername: settingsManager.string(for: .username), - networkSuffix: networkSuffix + networkSuffix: networkSuffix, + coinageInstanceId: AppConfig.Coinage.instanceId ) let chainConnections = TrUAPIChainConnectionPool( - engineResolver: { [chainRegistry] genesisHash in - chainRegistry.getChainByGenesis(for: genesisHash.toHex()).flatMap { chain in - chainRegistry.getConnection(for: chain.chainId) - } - }, + chainRegistry: chainRegistry, logger: logger ) @@ -96,6 +93,7 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se coreStorage: coreStorage, chainConnections: chainConnections, confirmationPresenter: TrUAPIConfirmationPresenter(routerFacade: confirmationRouterFacade), + chatFiles: TrUAPINativeChatFiles.shared, logger: logger ) @@ -119,7 +117,8 @@ extension TrUAPIHostRuntimeProvider { chainRegistry: ChainRegistryProtocol, secret: Data, liteUsername: String?, - networkSuffix: String + networkSuffix: String, + coinageInstanceId: UInt32 ) throws -> HostRuntimeConfig { let peopleChain = try chainRegistry.getChainOrError(for: AppConfig.Chains.usernameChain) let bulletinChain = try chainRegistry.getChainOrError(for: AppConfig.Chains.bulletInChain) @@ -151,7 +150,8 @@ extension TrUAPIHostRuntimeProvider { assetHubChainGenesisHash: Data(hexString: assetHubGenesisHex), networkSuffix: networkSuffix, localSessionSecret: secret, - localSessionLiteUsername: liteUsername + localSessionLiteUsername: liteUsername, + coinageInstanceId: coinageInstanceId ) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPINativeChatFiles.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPINativeChatFiles.swift new file mode 100644 index 000000000..c4b3cee49 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPINativeChatFiles.swift @@ -0,0 +1,77 @@ +import Foundation +import TrUAPIHost + +/// The same backend is provided to the runtime and every product execution, so +/// durable sources remain readable after execution teardown or app restart. +actor TrUAPINativeChatFiles: NativeChatFilesHost { + static let shared = TrUAPINativeChatFiles() + private let store: TrUAPIChatFileStore + private let presenter: TrUAPIChatFilePresenting + private var finishing: [String: Task] = [:] + + init( + store: TrUAPIChatFileStore = .shared, + presenter: TrUAPIChatFilePresenting = TrUAPIChatFilePresenter() + ) { + self.store = store + self.presenter = presenter + } + + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] { + guard request.maxFiles > 0 else { throw ChatFileFailure.invalidRange } + let urls = try await presenter.selectFiles(request: request) + try Task.checkCancellation() + guard urls.count <= Int(request.maxFiles) else { throw ChatFileFailure.invalidRange } + return try await store.importFiles(urls) + } + + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data { + try await store.read(sourceId: sourceId, offset: offset, length: length) + } + + func releaseChatFile(sourceId: String) async throws { + try await store.release(sourceId: sourceId) + } + + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? { + guard try await presenter.approveExport(request: request) else { return nil } + try Task.checkCancellation() + return try await store.beginExport(size: request.metadata.sizeBytes) + } + + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws { + try await store.write(exportId: exportId, offset: offset, data: data) + } + + func finishChatFileExport(exportId: String) async throws { + guard finishing[exportId] == nil else { throw ChatFileFailure.invalidHandle } + let operation = Task { [store, presenter] in + do { + let url = try await store.sealExport(exportId: exportId) + guard try await presenter.saveFile(url, exportId: exportId) else { + throw ChatFileFailure.userCancelled + } + try await store.completeExport(exportId: exportId) + } catch { + try? await store.completeExport(exportId: exportId) + throw error + } + } + finishing[exportId] = operation + defer { finishing.removeValue(forKey: exportId) } + try await withTaskCancellationHandler { + try await operation.value + } onCancel: { + operation.cancel() + } + } + + func cancelChatFileExport(exportId: String) async throws { + if let operation = finishing[exportId] { + // Also covers cancellation between sealing and presenting the UI. + operation.cancel() + _ = await operation.result + } + try await store.cancelExport(exportId: exportId) + } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockChainConnections.swift b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockChainConnections.swift index 70424bd57..e27927405 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockChainConnections.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/MockChainConnections.swift @@ -7,6 +7,8 @@ final class MockChainConnections: TrUAPIChainConnecting, @unchecked Sendable { private(set) var closeAllCallCount = 0 func connect(genesisHash _: Data) -> UInt32? { nil } + func allowedHopEndpoints(bulletinGenesisHash _: Data) -> [String] { [] } + func hopConnect(bulletinGenesisHash _: Data, endpoint _: String) throws -> UInt32? { nil } func send(connectionId _: UInt32, request _: String) throws {} func close(connectionId _: UInt32) {} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/UnavailableNativeChatFiles.swift b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/UnavailableNativeChatFiles.swift new file mode 100644 index 000000000..683ab3b03 --- /dev/null +++ b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/UnavailableNativeChatFiles.swift @@ -0,0 +1,4 @@ +import TrUAPIHost + +/// Exercises the SDK's explicit-unavailable defaults without presenting UI. +final class UnavailableNativeChatFiles: NativeChatFilesHost, Sendable {} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift index 73b9044f1..4c65ea4fe 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift @@ -28,6 +28,7 @@ private func makeHostBridge( coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeHostDefaults()), chainConnections: chainConnections, confirmationPresenter: confirmationPresenter, + chatFiles: UnavailableNativeChatFiles(), logger: Logger.shared ) } @@ -125,7 +126,8 @@ struct TrUAPIHostRuntimeProviderConfigTests { chainRegistry: MockChainRegistry(), secret: Data([0x01]), liteUsername: nil, - networkSuffix: "paseo" + networkSuffix: "paseo", + coinageInstanceId: 7 ) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift index 98f3e82e5..caa7b4d42 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift @@ -96,6 +96,7 @@ private func makeBridge( productStorage: productStorage, coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeTestDefaults()), confirmationPresenter: confirmationPresenter, + chatFiles: UnavailableNativeChatFiles(), preimageCache: preimageCache, hostProvider: hostProvider, logger: Logger.shared @@ -477,6 +478,7 @@ struct RustRuntimeBridgeTests { ), coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeTestDefaults()), confirmationPresenter: MockConfirmationPresenter(), + chatFiles: UnavailableNativeChatFiles(), preimageCache: TrUAPIPreimageCache { _ in nil }, hostProvider: StubHostProvider(), logger: Logger.shared diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIChatFileStoreTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIChatFileStoreTests.swift new file mode 100644 index 000000000..9f359bbb3 --- /dev/null +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIChatFileStoreTests.swift @@ -0,0 +1,247 @@ +import Foundation +import Testing +import UIKit +import TrUAPIHost +@testable import polkadot_app + +private final class ChatFileFixture { + let root: URL + let sources: AttachmentStore + let exports: AttachmentStore + let store: TrUAPIChatFileStore + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + sources = AttachmentStore(baseDirectory: root.appendingPathComponent("sources", isDirectory: true)) + exports = AttachmentStore(baseDirectory: root.appendingPathComponent("exports", isDirectory: true)) + store = TrUAPIChatFileStore(sources: sources, exports: exports) + } + + deinit { try? FileManager.default.removeItem(at: root) } + + func original(_ data: Data) throws -> URL { + let url = root.appendingPathComponent("selected.bin") + try data.write(to: url) + return url + } +} + +struct TrUAPIChatFileStoreTests { + @Test func immutableSourceSurvivesOriginalMutationAndStoreRecreationUntilRelease() async throws { + let fixture = try ChatFileFixture() + let original = try fixture.original(Data([1, 2, 3, 4])) + let picked = try await fixture.store.importFiles([original]) + let file = try #require(picked.first) + #expect(file.metadata.sizeBytes == 4) + #expect(file.metadata.kind == .file) + #expect(file.metadata.mimeType == "application/octet-stream") + + try Data([9, 8]).write(to: original) + try FileManager.default.removeItem(at: original) + let reopened = TrUAPIChatFileStore(sources: fixture.sources, exports: fixture.exports) + #expect(try await reopened.read(sourceId: file.sourceId, offset: 1, length: 3) == Data([2, 3, 4])) + try await reopened.release(sourceId: file.sourceId) + try await reopened.release(sourceId: file.sourceId) + await #expect(throws: ChatFileFailure.self) { + try await reopened.read(sourceId: file.sourceId, offset: 0, length: 1) + } + } + + @Test func readsCheckActualBoundsOverflowAndChunkLimit() async throws { + let fixture = try ChatFileFixture() + let original = try fixture.original(Data([1, 2, 3])) + let picked = try await fixture.store.importFiles([original]) + let file = try #require(picked.first) + #expect(try await fixture.store.read(sourceId: file.sourceId, offset: 3, length: 0) == Data()) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.read(sourceId: file.sourceId, offset: 2, length: 2) + } + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.read(sourceId: file.sourceId, offset: UInt64.max, length: 1) + } + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.read(sourceId: file.sourceId, offset: 0, length: 2_000_001) + } + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.read(sourceId: "../selected.bin", offset: 0, length: 1) + } + } + + @Test func oversizedSourcesAndSymlinkHandlesAreRejected() async throws { + let fixture = try ChatFileFixture() + let original = try fixture.original(Data()) + let handle = try FileHandle(forWritingTo: original) + try handle.truncate(atOffset: UInt64(UInt32.max) + 1) + try handle.close() + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.importFiles([original]) + } + try Data([1]).write(to: original) + try fixture.sources.createDirectoryIfNeeded() + let id = UUID().uuidString + try FileManager.default.createSymbolicLink(at: fixture.sources.fileURL(for: id), withDestinationURL: original) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.read(sourceId: id, offset: 0, length: 1) + } + } + + @Test func exportsRequireContiguousExactSizeAndCancelPreservesSavedCopy() async throws { + let fixture = try ChatFileFixture() + let id = try await fixture.store.beginExport(size: 4) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.write(exportId: id, offset: 1, data: Data([1])) + } + try await fixture.store.write(exportId: id, offset: 0, data: Data([1, 2])) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.write(exportId: id, offset: 0, data: Data([1, 2])) + } + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.write(exportId: id, offset: 2, data: Data([3, 4, 5])) + } + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.sealExport(exportId: id) + } + try await fixture.store.write(exportId: id, offset: 2, data: Data([3, 4])) + let sealed = try await fixture.store.sealExport(exportId: id) + #expect(try Data(contentsOf: sealed) == Data([1, 2, 3, 4])) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.write(exportId: id, offset: 4, data: Data()) + } + let savedCopy = fixture.root.appendingPathComponent("user-export.bin") + try FileManager.default.copyItem(at: sealed, to: savedCopy) + try await fixture.store.completeExport(exportId: id) + try await fixture.store.cancelExport(exportId: id) + try await fixture.store.cancelExport(exportId: id) + #expect(try Data(contentsOf: savedCopy) == Data([1, 2, 3, 4])) + } + + @Test func partialExportsCanBeCancelledAfterStoreRecreation() async throws { + let fixture = try ChatFileFixture() + let id = try await fixture.store.beginExport(size: 4) + try await fixture.store.write(exportId: id, offset: 0, data: Data([1])) + let reopened = TrUAPIChatFileStore(sources: fixture.sources, exports: fixture.exports) + try await reopened.cancelExport(exportId: id) + try await reopened.cancelExport(exportId: id) + #expect(!fixture.exports.hasFile(for: id + ".bin")) + await #expect(throws: ChatFileFailure.self) { + try await reopened.write(exportId: id, offset: 1, data: Data([2])) + } + } + + @MainActor + @Test func cancellingInFlightSaveDismissesOperationAndRemovesOnlyStaging() async throws { + let fixture = try ChatFileFixture() + let presenter = WaitingChatFilePresenter() + let backend = TrUAPINativeChatFiles(store: fixture.store, presenter: presenter) + let request = NativeChatFileExportRequest( + productId: "chat.test", + peerIdentity: Data(repeating: 1, count: 32), + peerUsername: nil, + metadata: HostNativeChatAttachmentMetadata(mimeType: "application/octet-stream", sizeBytes: 1, kind: .file) + ) + let id = try #require(try await backend.beginChatFileExport(request: request)) + try await backend.writeChatFileExport(exportId: id, offset: 0, data: Data([7])) + let finish = Task { try await backend.finishChatFileExport(exportId: id) } + await presenter.waitUntilSaving() + try await backend.cancelChatFileExport(exportId: id) + await #expect(throws: CancellationError.self) { try await finish.value } + #expect(!fixture.exports.hasFile(for: id + ".bin")) + try await backend.cancelChatFileExport(exportId: id) + } + + @Test func sdkWithoutBackendRejectsRatherThanReportingUserCancellationOrSuccess() async { + let backend = UnavailableNativeChatFiles() + let pick = NativeChatFilePickRequest( + productId: "chat.test", peerIdentity: Data(repeating: 1, count: 32), peerUsername: nil, maxFiles: 1 + ) + let export = NativeChatFileExportRequest( + productId: pick.productId, peerIdentity: pick.peerIdentity, peerUsername: nil, + metadata: HostNativeChatAttachmentMetadata(mimeType: "application/octet-stream", sizeBytes: 1, kind: .file) + ) + await #expect(throws: HostRejection.self) { try await backend.pickChatFiles(request: pick) } + await #expect(throws: HostRejection.self) { try await backend.readChatFile(sourceId: "", offset: 0, length: 1) } + await #expect(throws: HostRejection.self) { try await backend.releaseChatFile(sourceId: "") } + await #expect(throws: HostRejection.self) { try await backend.beginChatFileExport(request: export) } + await #expect(throws: HostRejection.self) { try await backend.writeChatFileExport(exportId: "", offset: 0, data: Data()) } + await #expect(throws: HostRejection.self) { try await backend.finishChatFileExport(exportId: "") } + await #expect(throws: HostRejection.self) { try await backend.cancelChatFileExport(exportId: "") } + } + + @MainActor + @Test func rasterMetadataUsesActualBytesNotExtensionAndBlurHashIsText() async throws { + let fixture = try ChatFileFixture() + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 3)) + } + let png = try #require(image.pngData()) + let original = try fixture.original(png) + let picked = try await fixture.store.importFiles([original]) + let file = try #require(picked.first) + #expect(file.metadata.mimeType == "image/png") + #expect(file.metadata.sizeBytes == UInt32(png.count)) + guard case let .image(width, height, thumbnail) = file.metadata.kind else { + Issue.record("A real raster image must retain its native media metadata") + return + } + #expect(width == 4) + #expect(height == 3) + let blurHash = try #require(thumbnail) + #expect(String(data: blurHash, encoding: .utf8) != nil) + #expect(try await fixture.store.read(sourceId: file.sourceId, offset: 0, length: UInt32(png.count)) == png) + let export = try await fixture.store.beginExport(size: UInt32(png.count)) + try await fixture.store.write(exportId: export, offset: 0, data: png) + let sealed = try await fixture.store.sealExport(exportId: export) + #expect(sealed.pathExtension == "png") + #expect(try Data(contentsOf: sealed) == png) + try await fixture.store.completeExport(exportId: export) + try await fixture.store.cancelExport(exportId: export) + #expect(!FileManager.default.fileExists(atPath: sealed.path)) + } + + @Test func activeDocumentsStayOpaqueAndExportChunksAreBounded() async throws { + let fixture = try ChatFileFixture() + let svg = Data("".utf8) + let picked = try await fixture.store.importFiles([fixture.original(svg)]) + let file = try #require(picked.first) + #expect(file.metadata.kind == .file) + #expect(file.metadata.mimeType == "application/octet-stream") + let export = try await fixture.store.beginExport(size: UInt32(svg.count)) + try await fixture.store.write(exportId: export, offset: 0, data: svg) + let sealed = try await fixture.store.sealExport(exportId: export) + #expect(sealed.pathExtension == "bin") + #expect(try Data(contentsOf: sealed) == svg) + try await fixture.store.completeExport(exportId: export) + + let oversized = try await fixture.store.beginExport(size: 2_000_001) + await #expect(throws: ChatFileFailure.self) { + try await fixture.store.write(exportId: oversized, offset: 0, data: Data(repeating: 0, count: 2_000_001)) + } + try await fixture.store.cancelExport(exportId: oversized) + } +} + +@MainActor +private final class WaitingChatFilePresenter: TrUAPIChatFilePresenting { + private var saving = false + private var started: CheckedContinuation? + + func selectFiles(request: NativeChatFilePickRequest) async throws -> [URL] { [] } + func approveExport(request: NativeChatFileExportRequest) async throws -> Bool { true } + + func saveFile(_ url: URL, exportId: String) async throws -> Bool { + saving = true + started?.resume() + started = nil + try await Task.sleep(for: .seconds(30)) + return false + } + + func waitUntilSaving() async { + if saving { return } + await withCheckedContinuation { started = $0 } + } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIIdentityCandidatesTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIIdentityCandidatesTests.swift new file mode 100644 index 000000000..bc173aa8e --- /dev/null +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIIdentityCandidatesTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +import SubstrateSdk +@testable import polkadot_app + +struct TrUAPIIdentityCandidatesTests { + private func response(_ rows: [[String: Any]], cursor: String? = nil) throws -> UsernameSearchResult { + var payload: [String: Any] = ["usernames": rows] + payload["nextCursor"] = cursor.map { $0 as Any } ?? NSNull() + return try JSONDecoder().decode( + UsernameSearchResult.self, + from: JSONSerialization.data(withJSONObject: payload) + ) + } + + private func row(_ username: String, account: String, status: String = "ASSIGNED") -> [String: Any] { + [ + "username": username, + "accountId": account, + "status": status, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + ] + } + + @Test + func nativeSearchShapeSuppliesOnlyExactAccountCandidatesNotStatusAuthority() throws { + let account = Data(repeating: 3, count: 32) + let address = try SS58AddressFactory().address(fromAccountId: account, type: 42) + let result = try response([ + row("alice-other", account: "not-an-address"), + row("alice", account: address, status: "RESERVED") + ]) + #expect(try RustHostRuntimeBridge.identityCandidates(from: result, username: "alice") == [account]) + #expect(try RustHostRuntimeBridge.identityCandidates(from: result, username: "ali").isEmpty) + } + + @Test + func malformedNativeAccountsAndOversizedSetsCannotClaimResolution() throws { + let malformed = try response([row("alice", account: "not-an-address")]) + #expect(throws: (any Error).self) { + try RustHostRuntimeBridge.identityCandidates(from: malformed, username: "alice") + } + let address = try SS58AddressFactory().address(fromAccountId: Data(repeating: 3, count: 32), type: 42) + let oversized = try response(Array(repeating: row("alice", account: address), count: 33)) + #expect(throws: (any Error).self) { + try RustHostRuntimeBridge.identityCandidates(from: oversized, username: "alice") + } + #expect(throws: (any Error).self) { + try JSONDecoder().decode(UsernameSearchResult.self, from: Data(#"{"accounts":["alice"]}"#.utf8)) + } + } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift index d94f520a8..d71ccab10 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TrUAPIHost @testable import polkadot_app /// Class suite: a fresh instance per test gives each test its own defaults @@ -63,4 +64,29 @@ final class TrUAPIStorageTests { #expect(try product.read(key: "k") == nil) } + + @Test func independentNativeWalletRefusesRustPurseCustody() throws { + let storage = CoreStorageBackend(storage: TrUAPILocalStorage.createCoreLocalStorage(defaults: defaults)) + // MainPurseCoinage is wallet-root/network scoped. Refusing its read is + // essential: nil would authorize Core to create a competing allocator. + let key = Data([13]) + Data(repeating: 0x42, count: 64) + #expect(throws: HostRejection.self) { try storage.read(key: key) } + #expect(throws: HostRejection.self) { try storage.write(key: key, value: Data([1])) } + #expect(throws: HostRejection.self) { try storage.clear(key: key) } + } + + @Test func nativeChatSnapshotsSurviveReadThenRepeatedReplacement() throws { + let nonce = withUnsafeBytes(of: UUID().uuid) { Data($0) } + let key = Data([16]) + nonce + nonce + Data(repeating: 0, count: 32) + let storage = CoreStorageBackend(storage: TrUAPILocalStorage.createCoreLocalStorage(defaults: defaults)) + defer { try? storage.clear(key: key) } + + #expect(try storage.read(key: key) == nil) + try storage.write(key: key, value: Data([1, 2])) + #expect(try storage.read(key: key) == Data([1, 2])) + try storage.write(key: key, value: Data([3, 4])) + #expect(try storage.read(key: key) == Data([3, 4])) + try storage.clear(key: key) + #expect(try storage.read(key: key) == nil) + } } diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 9515231cc..ecbae3164 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -95,6 +95,13 @@ and the People/Bulletin genesis hashes. It must match the People chain's `NetworkSuffix.NetworkSuffix`. Include this configuration update in the embedding app's package upgrade. +`HostRuntimeConfig.coinageInstanceId` is optional for legacy Coinage runtimes +and required for instance-scoped Coinage operations. Supply the same trusted +asset instance as the app's native Coinage service (`AppConfig.Coinage.instanceId` +in Polkadot App). Do not substitute the main-purse derivation identifier. +Omission fails closed on instance-scoped runtimes. The UniFFI record appends +this field; rebuild bindings and native libraries together with the wrapper. + `HostRuntimeConfig.assetHubChainGenesisHash` is required. Supply the Asset Hub genesis hash from the same network configuration, as 32 bytes. Product manifests are read from the dotNS contracts deployed there, so it is what makes a diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 82696ed61..f053b6aab 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -43,6 +43,9 @@ public struct HostRuntimeConfig: Sendable, Equatable { public let networkSuffix: String public let localSessionSecret: Data? public let localSessionLiteUsername: String? + /// Trusted asset instance, required for instance-scoped Coinage runtimes. + /// This is not the wallet's purse derivation identifier. + public let coinageInstanceId: UInt32? public init( hostName: String, @@ -55,7 +58,8 @@ public struct HostRuntimeConfig: Sendable, Equatable { assetHubChainGenesisHash: Data, networkSuffix: String, localSessionSecret: Data? = nil, - localSessionLiteUsername: String? = nil + localSessionLiteUsername: String? = nil, + coinageInstanceId: UInt32? = nil ) { self.hostName = hostName self.hostIcon = hostIcon @@ -68,6 +72,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { self.networkSuffix = networkSuffix self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername + self.coinageInstanceId = coinageInstanceId } fileprivate var native: NativeHostRuntimeConfig { @@ -83,7 +88,8 @@ public struct HostRuntimeConfig: Sendable, Equatable { networkSuffix: networkSuffix, localSessionSecret: localSessionSecret, localSessionLiteUsername: localSessionLiteUsername, - assetHubChainGenesisHash: assetHubChainGenesisHash + assetHubChainGenesisHash: assetHubChainGenesisHash, + coinageInstanceId: coinageInstanceId ) } } @@ -241,6 +247,43 @@ public protocol HostCoreStorageBackend: AnyObject, Sendable { func clear(key: Data) throws } +/// Host-private immutable attachment custody. These async callbacks may present +/// trusted native UI; source/export handles and bytes must never reach a guest. +/// Empty selection or a nil export denotes user cancellation, not unavailability. +public protocol NativeChatFilesHost: AnyObject, Sendable { + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data + func releaseChatFile(sourceId: String) async throws + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws + func finishChatFileExport(exportId: String) async throws + func cancelChatFileExport(exportId: String) async throws +} + +public extension NativeChatFilesHost { + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func releaseChatFile(sourceId: String) async throws { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func finishChatFileExport(exportId: String) async throws { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } + func cancelChatFileExport(exportId: String) async throws { + throw HostRejection.Rejected(reason: "native Chat files unavailable") + } +} + /// Host-side callback bundle that the Rust core invokes for capabilities the /// native shell owns. The permission split mirrors the Rust `Permissions` /// trait: @@ -264,7 +307,7 @@ public protocol HostCoreStorageBackend: AnyObject, Sendable { /// Any UI work MUST still hop to the main thread, e.g. /// `await MainActor.run { ... }` or `DispatchQueue.main.async { ... }`. Calling /// UIKit/WebKit off the main thread is undefined behaviour. -public protocol HostBridge: AnyObject, Sendable { +public protocol HostBridge: NativeChatFilesHost { /// Lifecycle logger. Marker is a stable slug, detail is free-form. func onCoreLog(marker: String, detail: String) @@ -323,6 +366,15 @@ public protocol HostBridge: AnyObject, Sendable { /// Open a JSON-RPC chain connection and return a host-assigned id, or nil if unsupported. func chainConnect(genesisHash: Data) throws -> UInt32? + /// Exact WSS endpoint strings from trusted, current Bulletin configuration. + /// An unconfigured host returns an empty list. + func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] + + /// Recheck the exact endpoint against live trusted configuration before + /// dialing. Returns nil when HOP is unavailable. The returned id shares + /// chainSend/chainClose and notifyChainResponse/notifyChainClosed. + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? + /// Send one JSON-RPC request on a native chain connection. func chainSend(connectionId: UInt32, request: String) throws @@ -335,6 +387,10 @@ public protocol HostBridge: AnyObject, Sendable { /// Return the current preimage value for `key`, or nil for a miss. func lookupPreimage(key: Data) async throws -> Data? + /// Exact-name AccountId32 candidates from this host's configured authenticated + /// identity service. The core verifies ownership and the People Chat key. + func identityUsernameCandidates(username: String, peopleChainGenesisHash: Data) async throws -> [Data] + /// Return the current host theme. Hosts with no named themes report /// `ThemeName.default`. func currentTheme() throws -> HostThemeSubscribeItem @@ -443,10 +499,15 @@ public extension HostBridge { func cancelNotification(id: UInt32) throws {} func authStateChanged(state: AuthState) {} func chainConnect(genesisHash: Data) throws -> UInt32? { nil } + func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] { [] } + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? { nil } func chainSend(connectionId: UInt32, request: String) throws {} func chainClose(connectionId: UInt32) throws {} func confirmUserAction(review: UserConfirmationReview) async throws -> Bool { false } func lookupPreimage(key: Data) async throws -> Data? { nil } + func identityUsernameCandidates(username: String, peopleChainGenesisHash: Data) async throws -> [Data] { + throw HostRejection.Rejected(reason: "native identity backend unavailable") + } func currentTheme() throws -> HostThemeSubscribeItem { HostThemeSubscribeItem(name: .default, variant: .dark) } @@ -623,6 +684,18 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] { + try await withHostRejection { + try await bridge.allowedHopEndpoints(bulletinGenesisHash: bulletinGenesisHash) + } + } + + func hopConnect(bulletinGenesisHash: Data, endpoint: String) throws -> UInt32? { + try withHostRejection { + try bridge.hopConnect(bulletinGenesisHash: bulletinGenesisHash, endpoint: endpoint) + } + } + func chainSend(connectionId: UInt32, request: String) throws { try withHostRejection { try bridge.chainSend(connectionId: connectionId, request: request) @@ -635,6 +708,49 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func pickChatFiles(request: NativeChatFilePickRequest) async throws -> [NativeChatPickedFile] { + try await withChatFileRejection { try await bridge.pickChatFiles(request: request) } + } + + func readChatFile(sourceId: String, offset: UInt64, length: UInt32) async throws -> Data { + try await withChatFileRejection { + try await bridge.readChatFile(sourceId: sourceId, offset: offset, length: length) + } + } + + func releaseChatFile(sourceId: String) async throws { + try await withChatFileRejection { try await bridge.releaseChatFile(sourceId: sourceId) } + } + + func beginChatFileExport(request: NativeChatFileExportRequest) async throws -> String? { + try await withChatFileRejection { try await bridge.beginChatFileExport(request: request) } + } + + func writeChatFileExport(exportId: String, offset: UInt64, data: Data) async throws { + try await withChatFileRejection { + try await bridge.writeChatFileExport(exportId: exportId, offset: offset, data: data) + } + } + + func finishChatFileExport(exportId: String) async throws { + try await withChatFileRejection { try await bridge.finishChatFileExport(exportId: exportId) } + } + + func cancelChatFileExport(exportId: String) async throws { + try await withChatFileRejection { try await bridge.cancelChatFileExport(exportId: exportId) } + } + + private func withChatFileRejection(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch is CancellationError { + throw CancellationError() + } catch { + // Foundation/provider errors can contain a selected path or handle. + throw HostRejection.Rejected(reason: "native Chat file operation unavailable or failed") + } + } + func confirmUserAction(review: UserConfirmationReview) async throws -> Bool { try await withHostRejection { try await bridge.confirmUserAction(review: review) @@ -647,6 +763,14 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func identityUsernameCandidates(username: String, peopleChainGenesisHash: Data) async throws -> [Data] { + try await withHostRejection { + try await bridge.identityUsernameCandidates( + username: username, peopleChainGenesisHash: peopleChainGenesisHash + ) + } + } + func currentTheme() throws -> HostThemeSubscribeItem { try withHostRejection { try bridge.currentTheme() diff --git a/js/packages/truapi-host/LICENSE-AGPL-3.0 b/js/packages/truapi-host/LICENSE-AGPL-3.0 new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/js/packages/truapi-host/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/js/packages/truapi-host/NOTICE b/js/packages/truapi-host/NOTICE new file mode 100644 index 000000000..857d6a14a --- /dev/null +++ b/js/packages/truapi-host/NOTICE @@ -0,0 +1,17 @@ +The original TypeScript Host adapter is MIT-licensed; see LICENSE. +The distributed Rust signing runtime/WASM includes AGPL-3.0-only code. +It is not an MIT-only distribution. See LICENSE-AGPL-3.0. + +Coinage is a modified extraction from paritytech/brevity-dozer, +revision d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. +Detailed provenance is in rust/crates/truapi-coinage/NOTICE in the source tree. +Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that +same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. +Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, +based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, +including local native-attachment codec and secret-zeroization modifications. + +Corresponding Source must include the exact Host source revision, all local +modifications, the Coinage provenance/license notices and build instructions. +Source repository: https://github.com/paritytech/host-rust-core +A repository URL alone does not provide unpublished local modifications. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 68a1045cc..8500d7087 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -19,6 +19,14 @@ The shipped WASM includes `WasmSigningHostRuntime`. Its configuration requires `runtimeConfig.networkSuffix`: the bare TLD (`dot`, `paseo`, or `testnet`) matching the People chain and the wallet's onboarding configuration. +Signing hosts using instance-scoped Coinage must also supply +`runtimeConfig.coinageInstanceId` (or `hostConfig.coinageInstanceId` in the worker +factory) from trusted host/network configuration. It is an integer from `0` +through `4294967295`, including zero; strings, fractions and out-of-range values +are rejected. Omission preserves legacy Coinage support, but Coinage operations +on an instance-scoped runtime fail closed without it. This asset instance is +not a purse derivation identifier and is never selected by guest product code. + `runtimeConfig.assetHub` is required by both configurations, pairing and signing. It is the Asset Hub genesis hash, in the same shape as `runtimeConfig.people` and `runtimeConfig.bulletin`. Product manifests are read @@ -284,6 +292,24 @@ const secondProvider = await runtime.createProvider({ protocol-iframe MessageChannel handshake. Host code creates one worker runtime and then opens one provider per product id. +When UI callbacks capture a product label, pass that product's typed callbacks +as the second argument to `runtime.createProvider(product, callbacks)`. The +worker routes these callbacks to that execution without replacing the shared +signing authority, main purse, native Chat actors, or private core storage. +Disposing a provider does not dispose the owner runtime. A signing host must +keep one owner alive for background reception, and must not run independent +signing runtimes against the same purse inventory. + +`createBrowserNativeChatFilesHost(sourceStore?)` accepts an optional +`BrowserNativeChatFileSourceStore` with `putSources`, `readSource` and +`releaseSource`. Use it when core custody spans host origins: immutable file +sources must remain reachable wherever the persisted actor is restored. +`putSources` must commit all supplied Blob snapshots durably before resolving; +`readSource` returns that snapshot, not a mutable filesystem reference. +Picker/export consent and bounded reads remain in the SDK. The default source +store is origin-local IndexedDB with private immutable Blobs, not application +encryption at rest. An embedder owns disposal of a file host it supplies. + ## Session lifecycle The core owns the session; the host owns persistence. At boot the core restores diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json index e88428c0a..aff81e7f5 100644 --- a/js/packages/truapi-host/package.json +++ b/js/packages/truapi-host/package.json @@ -2,7 +2,7 @@ "name": "@parity/truapi-host", "version": "0.17.0", "description": "WASM-backed TrUAPI host runtime: embeds the Rust core, with web iframe and Web Worker entry points", - "license": "MIT", + "license": "MIT AND AGPL-3.0-only", "author": "Parity Technologies ", "repository": { "type": "git", @@ -41,7 +41,9 @@ "files": [ "dist", "README.md", - "LICENSE" + "LICENSE", + "LICENSE-AGPL-3.0", + "NOTICE" ], "scripts": { "build": "tsc -b", diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts index 45ddfbc0d..3bb90b2f8 100644 --- a/js/packages/truapi-host/src/adapter-support.ts +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -8,8 +8,13 @@ import { type GenericError, type Result } from "@parity/truapi"; import { hexToBytes } from "@parity/truapi/scale"; import { errorMessage } from "./error.js"; -import type { ChainConnect, ChainConnection } from "./runtime.js"; -import type { ChainProvider } from "./generated/host-callbacks.js"; +import type { ChainConnect, ChainConnection, HopConnect } from "./runtime.js"; +import type { + ChainProvider, + HopProvider, + JsonRpcConnection, + NativeChatFilesHost, +} from "./generated/host-callbacks.js"; type WireResult = | { success: true; value: T } @@ -66,23 +71,35 @@ function pumpIterator( onItem: (value: T) => void, label: string, onError?: (error: GenericError) => void, + onComplete?: () => void, ): () => void { let stopped = false; void (async () => { try { while (!stopped) { const next = await iterator.next(); - if (next.done) return; + if (stopped || next.done) return; onItem(next.value); } } catch (err) { - console.error(`[truapi host callbacks] ${label} failed:`, err); - onError?.({ reason: errorMessage(err) }); + if (!stopped) { + console.error(`[truapi host callbacks] ${label} failed`); + onError?.({ reason: errorMessage(err) }); + } + } finally { + if (!stopped) onComplete?.(); } })(); return () => { + if (stopped) return; stopped = true; - void iterator.return?.(); + try { + void Promise.resolve(iterator.return?.()).catch(() => { + console.error(`[truapi host callbacks] ${label} cleanup failed`); + }); + } catch { + console.error(`[truapi host callbacks] ${label} cleanup failed`); + } }; } @@ -113,18 +130,108 @@ export function driveResultStream( export function chainConnectAdapter( host: Pick, ): ChainConnect { - return async (genesisHash, onResponse): Promise => { - const connection = await host.connect(hexToBytes(genesisHash)); - const iterator = connection.responses()[Symbol.asyncIterator](); - const stopResponses = pumpIterator(iterator, onResponse, "chain responses"); - return { - send(request: string): void { - connection.send(request); - }, - close(): void { - stopResponses(); - connection.close(); + return async (genesisHash, onResponse, onClosed) => + rpcConnectionAdapter( + await host.connect(hexToBytes(genesisHash)), + onResponse, + onClosed, + ); +} + +/** A missing HOP embedding is unavailable, never a successful no-op socket. */ +export const unavailableHopProvider: Required = { + async allowedHopEndpoints() { + return []; + }, + async connectHop() { + throw new Error("HOP provider is unavailable"); + }, +}; + +/** Optional SDK embeddings must fail closed, never invent successful file handles. */ +export const unavailableNativeChatFilesHost: Required = { + async pickChatFiles() { throw new Error("Native Chat files are unavailable"); }, + async readChatFile() { throw new Error("Native Chat files are unavailable"); }, + async releaseChatFile() { throw new Error("Native Chat files are unavailable"); }, + async beginChatFileExport() { throw new Error("Native Chat files are unavailable"); }, + async writeChatFileExport() { throw new Error("Native Chat files are unavailable"); }, + async finishChatFileExport() { throw new Error("Native Chat files are unavailable"); }, + async cancelChatFileExport() { throw new Error("Native Chat files are unavailable"); }, +}; + +export function hopConnectAdapter(host: Required): HopConnect { + return async (genesisHash, endpoint, onResponse, onClosed) => { + const genesis = hexToBytes(genesisHash); + const allowed = await host.allowedHopEndpoints(genesis); + // Check the original string, never a normalized URL against the allowlist. + if ( + !allowed.includes(endpoint) || + !endpoint.startsWith("wss://") || + /[\s\u0000-\u001f\u007f-\u009f#\\]/u.test(endpoint) || + endpoint.slice(6).split(/[/?]/u, 1)[0]!.includes("@") + ) { + throw new Error("HOP endpoint is not an allowed secure WebSocket URL"); + } + const url = new URL(endpoint); + if (!url.hostname || url.username || url.password || url.hash) { + throw new Error("HOP endpoint is not an allowed secure WebSocket URL"); + } + return rpcConnectionAdapter( + await host.connectHop(genesis, endpoint), + onResponse, + onClosed, + ); + }; +} + +/** Chain and HOP share response pumping and exactly-once transport cleanup. */ +function rpcConnectionAdapter( + connection: JsonRpcConnection, + onResponse: (json: string) => void, + onClosed?: () => void, +): ChainConnection { + let closed = false; + let stopResponses: (() => void) | undefined; + const close = (notify: boolean): void => { + if (closed) return; + closed = true; + stopResponses?.(); + try { + connection.close(); + } finally { + if (notify) onClosed?.(); + } + }; + try { + stopResponses = pumpIterator( + connection.responses()[Symbol.asyncIterator](), + onResponse, + "JSON-RPC responses", + undefined, + () => { + try { + close(true); + } catch { + console.error("[truapi host callbacks] JSON-RPC close failed"); + } }, - }; + ); + // A synchronous iterator failure can close before pumpIterator returns. + if (closed) stopResponses(); + } catch (err) { + close(false); + throw err; + } + return { + send(request) { + if (closed) throw new Error("JSON-RPC connection is closed"); + try { + connection.send(request); + } catch (err) { + close(true); + throw err; + } + }, + close: () => close(false), }; } diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 656e2122a..5a1d48c59 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { err, ok } from "neverthrow"; +import { hexToBytes, str, Vector } from "@parity/truapi/scale"; import { HostChatCreateRoomRequest, @@ -25,6 +26,9 @@ import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; import { AuthState, CoreStorageKey, + NativeChatFilePickRequest, + NativeChatFileExportRequest, + NativeChatPickedFile, ProductContext, ProductExecutionKind, UserConfirmationReview, @@ -80,6 +84,86 @@ const SIGN_PAYLOAD: HostSignPayloadData = { }; describe("createWasmRawCallbacks", () => { + it("fails every file operation closed when the embedding has no custody backend", async () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + const context = { productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined }; + const pick = NativeChatFilePickRequest.enc({ ...context, maxFiles: 1 }); + const save = NativeChatFileExportRequest.enc({ + ...context, + metadata: { mimeType: "application/octet-stream", sizeBytes: 0, kind: { tag: "File" } }, + }); + for (const operation of [ + () => raw.pickChatFiles(pick), + () => raw.readChatFile("source", 0n, 0), + () => raw.releaseChatFile("source"), + () => raw.beginChatFileExport(save), + () => raw.writeChatFileExport("export", 0n, new Uint8Array()), + () => raw.finishChatFileExport("export"), + () => raw.cancelChatFileExport("export"), + ]) { + await expect(operation()).rejects.toThrow(); + } + }); + + it("distinguishes explicit file cancellation from unavailable custody", async () => { + const raw = createWasmRawCallbacks(makeHostCallbacks({ + nativeChatFiles: { + pickChatFiles: async () => [], + beginChatFileExport: async () => undefined, + }, + })); + const context = { productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined }; + expect(Vector(NativeChatPickedFile).dec(await raw.pickChatFiles( + NativeChatFilePickRequest.enc({ ...context, maxFiles: 1 }), + ))).toEqual([]); + const cancelled = await raw.beginChatFileExport(NativeChatFileExportRequest.enc({ + ...context, + metadata: { mimeType: "application/octet-stream", sizeBytes: 0, kind: { tag: "File" } }, + })); + expect(cancelled == null).toBe(true); + }); + + it("does not invent an identity search provider for hosts that omit it", () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + expect(raw.identityUsernameCandidates).toBeUndefined(); + }); + + it("encodes username candidate accounts as one SCALE vector", async () => { + const first = new Uint8Array(32).fill(0x11); + const second = new Uint8Array(32).fill(0x22); + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + identityBackend: { + identityUsernameCandidates: async () => [first, second], + }, + }), + ); + + expect( + await raw.identityUsernameCandidates!("alice", new Uint8Array(32)), + ).toEqual(new Uint8Array([8, ...first, ...second])); + }); + + it("keeps backend failure distinct from a successful empty search", async () => { + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + identityBackend: { + identityUsernameCandidates: async (username) => { + if (username === "unavailable") throw new Error("authentication expired"); + return []; + }, + }, + }), + ); + + await expect( + raw.identityUsernameCandidates!("unavailable", new Uint8Array(32)), + ).rejects.toThrow("authentication expired"); + expect( + await raw.identityUsernameCandidates!("absent", new Uint8Array(32)), + ).toEqual(new Uint8Array([0])); + }); + it("decodes requests and encodes typed responses", async () => { const writes: [string, number[]][] = []; const clears: string[] = []; @@ -565,6 +649,105 @@ describe("createWasmRawCallbacks", () => { connection!.close(); expect(closes).toBe(1); }); + + it("keeps an unconfigured HOP provider unavailable", async () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + expect(Vector(str).dec(await raw.allowedHopEndpoints(hexToBytes(GENESIS)))).toEqual([]); + await expect(raw.hopConnect(GENESIS, "wss://hop.example", () => {})).rejects.toThrow(); + }); + + it("requires an exact current trusted WSS endpoint before dialing HOP", async () => { + const endpoint = "wss://hop.example/rpc"; + let allowed = [ + endpoint, + "ws://hop.example/rpc", + "wss://user@hop.example/rpc", + "wss://hop.example/rpc#fragment", + ]; + const dials: string[] = []; + const sent: string[] = []; + const received: string[] = []; + let closes = 0; + let closed = 0; + const raw = createWasmRawCallbacks(makeHostCallbacks({ + hop: { + async allowedHopEndpoints(genesis) { + expect(genesis).toEqual(hexToBytes(GENESIS)); + return allowed; + }, + async connectHop(genesis, url) { + expect(genesis).toEqual(hexToBytes(GENESIS)); + dials.push(url); + return { + send: (request) => sent.push(request), + async *responses() { yield '{"id":1,"result":"ok"}'; }, + close() { closes += 1; }, + }; + }, + }, + })); + expect(Vector(str).dec(await raw.allowedHopEndpoints(hexToBytes(GENESIS)))).toEqual(allowed); + for (const denied of [ + "wss://HOP.example/rpc", + "wss://other.example/rpc", + ...allowed.slice(1), + ]) { + await expect(raw.hopConnect(GENESIS, denied, () => {})).rejects.toThrow(); + } + const connection = await raw.hopConnect( + GENESIS, + endpoint, + (response) => received.push(response), + () => { closed += 1; }, + ); + connection!.send('{"id":1,"method":"hop_info"}'); + await settle(); + expect(dials).toEqual([endpoint]); + expect(sent).toEqual(['{"id":1,"method":"hop_info"}']); + expect(received).toEqual(['{"id":1,"result":"ok"}']); + expect(closes).toBe(1); + expect(closed).toBe(1); + connection!.close(); + expect(closes).toBe(1); + expect(() => connection!.send("{}")).toThrow(); + allowed = []; + await expect(raw.hopConnect(GENESIS, endpoint, () => {})).rejects.toThrow(); + expect(dials).toEqual([endpoint]); + }); + + it("drops responses arriving after a connection is closed", async () => { + const next = Promise.withResolvers>(); + let closes = 0; + let returns = 0; + const raw = createWasmRawCallbacks(makeHostCallbacks({ + chain: { + async connect() { + return { + send() {}, + responses: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => next.promise, + async return() { + returns += 1; + return { done: true, value: undefined }; + }, + }), + }), + close() { closes += 1; }, + }; + }, + }, + })); + const received: string[] = []; + const connection = await raw.chainConnect(GENESIS, (response) => received.push(response)); + connection!.close(); + connection!.close(); + next.resolve({ done: false, value: "late response" }); + await settle(); + expect(received).toEqual([]); + expect(closes).toBe(1); + expect(returns).toBe(1); + }); }); describe("ProductContext codec", () => { diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 41017e567..3fa2213ca 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -39,15 +39,26 @@ export type Awaitable = T | Promise; * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes * `onResponse` so the host can push JSON-RPC replies back asynchronously. * Returning `null` (or throwing) tells the core no provider is available. + * `onClosed`, when supplied, is called when the remote response stream ends + * or fails. Local `close()` is idempotent and does not call it. */ export type ChainConnect = ( genesisHash: string, onResponse: (json: string) => void, + onClosed?: () => void, +) => Awaitable; + +/** Open only a host-allowlisted HOP endpoint for this Bulletin chain. */ +export type HopConnect = ( + bulletinGenesisHash: string, + endpoint: string, + onResponse: (json: string) => void, + onClosed?: () => void, ) => Awaitable; /** - * Per-connection handle returned by `chainConnect`. `send` forwards a - * SCALE-encoded JSON-RPC request; `close` tears the connection down. + * Per-connection handle returned by `chainConnect` or `hopConnect`. `send` + * forwards a JSON-RPC request string; `close` tears the connection down. */ export interface ChainConnection { send(request: string): void; diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index e6f080bdd..e1d7a1e68 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -1,4 +1,5 @@ import type { RequiredHostCallbacks } from "./generated/host-callbacks.js"; +import { unavailableHopProvider, unavailableNativeChatFilesHost } from "./adapter-support.js"; /** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ export type CompleteHostCallbacks = RequiredHostCallbacks; @@ -41,6 +42,7 @@ export function makeHostCallbacks( async *lookupPreimage() {}, }, theme: { async *subscribeTheme() {} }, + locale: { async *subscribeLocale() {} }, chain: { connect: async () => ({ send() {}, @@ -76,7 +78,14 @@ export function makeHostCallbacks( }, preimage: { ...defaults.preimage, ...overrides.preimage }, theme: { ...defaults.theme, ...overrides.theme }, + locale: { ...defaults.locale, ...overrides.locale }, chain: { ...defaults.chain, ...overrides.chain }, + ...(overrides.hop + ? { hop: { ...unavailableHopProvider, ...overrides.hop } } + : {}), + ...(overrides.nativeChatFiles + ? { nativeChatFiles: { ...unavailableNativeChatFilesHost, ...overrides.nativeChatFiles } } + : {}), // Chat is an optional capability: only fixtures that ask for it get the // group, so the default fixture is a host that does not serve chat. ...(overrides.chat @@ -111,6 +120,17 @@ export function makeHostCallbacks( }, } : {}), + // An unavailable authenticated search must not look like an empty result. + ...(overrides.identityBackend + ? { + identityBackend: { + identityUsernameCandidates: async (): Promise => { + throw new Error("identity backend unavailable"); + }, + ...overrides.identityBackend, + }, + } + : {}), }; } diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 50008608b..60e2a7db5 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -49,6 +49,7 @@ export interface WorkerHostRuntime extends PermissionAuthorizationRuntime { productRuntime( product: unknown, coreCallbacks: unknown, + platformCallbacks?: unknown, ): WorkerProductRuntime; disconnectSession(): Promise; sessionChatIdentityKey(): Uint8Array | undefined; diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 806259052..fa5b4c1d1 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -22,7 +22,10 @@ import { ProductRendererRenderRequest as ProductRendererRenderRequestCodec, RendererNode as RendererNodeCodec, } from "@parity/truapi"; -import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; +import { + NativeChatPickedFile, + PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec, +} from "../generated/host-callbacks.js"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; import type { @@ -33,9 +36,11 @@ import type { SubscriptionName, WorkerToMain, } from "../worker-protocol.js"; -import { bytesToHex } from "@parity/truapi/scale"; +import { MAX_JSON_RPC_CONNECTIONS } from "../worker-protocol.js"; +import { bytesToHex, Vector } from "@parity/truapi/scale"; import { startRawSubscription } from "../generated/worker-callbacks.js"; import { errorMessage, toError } from "../error.js"; +import { createBrowserNativeChatFilesHost } from "./native-chat-files.js"; export type WebWorkerHostConfig = Omit< ProductRuntimeConfig, @@ -44,6 +49,8 @@ export type WebWorkerHostConfig = Omit< export type WebWorkerSigningHostConfig = WebWorkerHostConfig & { /** Bare dotNS network suffix (`dot`, `paseo`, or `testnet`). */ networkSuffix: string; + /** Trusted u32 asset instance, required for instance-scoped Coinage runtimes. */ + coinageInstanceId?: number; }; export interface WorkerPairingHostRuntime { @@ -60,7 +67,7 @@ export interface WorkerPairingHostRuntime { createProvider(product: { productId: string; executionKind?: ProductExecutionKind; - }): Promise; + }, callbacks?: WebWorkerHostCallbacks): Promise; disconnectSession(): Promise; cancelPairing(): void; notifySessionStoreChanged(): void; @@ -168,9 +175,15 @@ interface RenderEntry { onError: (error: Error) => void; } +interface RpcConnectionEntry { + connection: ChainConnection | null; + closed: boolean; +} + interface RuntimeState { worker: Worker; rawCallbacks: RawCallbacks; + coreCallbacks: Map; cores: Map; pendingCores: Map< number, @@ -181,7 +194,9 @@ interface RuntimeState { } >; subscriptionDisposers: Map void>; - chainConnections: Map; + chainConnections: Map; + chatFileExports: Set; + disposeNativeChatFiles: () => void; pendingDisconnects: Map< number, { resolve: () => void; reject: (error: Error) => void } @@ -409,17 +424,46 @@ interface TrUApiDevConsole { getLogLevel(): LogLevel | null; } +const NATIVE_CHAT_FILE_CALLBACKS: Partial> = { + pickChatFiles: true, + readChatFile: true, + releaseChatFile: true, + beginChatFileExport: true, + writeChatFileExport: true, + finishChatFileExport: true, + cancelChatFileExport: true, +}; + +/** Reclaim only undelivered selections; delivered sources belong to durable Host state. */ +async function discardChatFileCallback(state: RuntimeState, name: CallbackName, value: unknown): Promise { + try { + if (name === "pickChatFiles" && value instanceof Uint8Array) { + await Promise.all(Vector(NativeChatPickedFile).dec(value).map( + (file) => state.rawCallbacks.releaseChatFile(file.sourceId), + )); + } else if (name === "beginChatFileExport" && typeof value === "string") { + state.chatFileExports.delete(value); + await state.rawCallbacks.cancelChatFileExport(value); + } + } catch { + // A closed/failed backing store is unavailable; never log private handles or payloads. + } +} + function handleCallbackRequest( state: RuntimeState, msg: { requestId: number; + coreId?: number; name: CallbackName; args: readonly unknown[]; }, ): void { - const fn = Object.hasOwn(state.rawCallbacks, msg.name) + if (state.disposed) return; + const callbacks = msg.coreId === undefined ? state.rawCallbacks : state.coreCallbacks.get(msg.coreId); + const fn = callbacks && Object.hasOwn(callbacks, msg.name) ? ( - state.rawCallbacks as unknown as Record< + callbacks as unknown as Record< string, (...args: readonly unknown[]) => unknown > @@ -435,23 +479,56 @@ function handleCallbackRequest( return; } Promise.resolve() - .then(() => fn(...msg.args)) + .then(() => { + if (state.disposed) throw new Error("Host runtime is unavailable"); + if (msg.coreId !== undefined && !state.coreCallbacks.has(msg.coreId)) throw new Error("Product callbacks are unavailable"); + return fn(...msg.args); + }) .then( - (value) => { - state.worker.postMessage({ - kind: "callbackResponse", - requestId: msg.requestId, - ok: true, - value, - } satisfies MainToWorker); + async (value) => { + if (state.disposed) { + await discardChatFileCallback(state, msg.name, value); + return; + } + if (msg.name === "beginChatFileExport" && typeof value === "string") { + state.chatFileExports.add(value); + } else if (msg.name === "finishChatFileExport" || msg.name === "cancelChatFileExport") { + state.chatFileExports.delete(msg.args[0] as string); + } + try { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + } satisfies MainToWorker); + } catch { + await discardChatFileCallback(state, msg.name, value); + if (state.disposed) return; + try { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: "Host callback result could not be serialized", + } satisfies MainToWorker); + } catch { + teardown(state, new Error("Host callback transport is unavailable"), true); + } + } }, (err) => { - state.worker.postMessage({ - kind: "callbackResponse", - requestId: msg.requestId, - ok: false, - error: errorMessage(err), - } satisfies MainToWorker); + if (state.disposed) return; + try { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: NATIVE_CHAT_FILE_CALLBACKS[msg.name] ? "Native Chat file operation failed" : errorMessage(err), + } satisfies MainToWorker); + } catch { + teardown(state, new Error("Host callback transport is unavailable"), true); + } }, ); } @@ -460,6 +537,7 @@ function handleSubscriptionStart( state: RuntimeState, msg: { subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | null; }, @@ -482,15 +560,17 @@ function handleSubscriptionStart( }; let dispose: (() => void) | void = undefined; try { + const callbacks = msg.coreId === undefined ? state.rawCallbacks : state.coreCallbacks.get(msg.coreId); + if (!callbacks) throw new Error("Product callbacks are unavailable"); dispose = startRawSubscription( - state.rawCallbacks, + callbacks, msg.name, msg.payload, sendItem, sendError, ); } catch (err) { - console.error(`[truapi worker] ${msg.name} threw on start:`, err); + sendError({ reason: errorMessage(err) }); return; } if (typeof dispose === "function") { @@ -514,41 +594,73 @@ function handleSubscriptionStop( async function handleChainConnectStart( state: RuntimeState, - msg: { connId: number; genesisHash: string }, + msg: Extract, ): Promise { - const chainConnect = state.rawCallbacks.chainConnect; + if (state.disposed) return; + if ( + state.chainConnections.has(msg.connId) || + state.chainConnections.size >= MAX_JSON_RPC_CONNECTIONS + ) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: "JSON-RPC connection limit reached or duplicate connection id", + } satisfies MainToWorker); + return; + } + const entry: RpcConnectionEntry = { connection: null, closed: false }; + state.chainConnections.set(msg.connId, entry); const onResponse = (json: string): void => { - if (state.disposed) return; + if (state.disposed || entry.closed) return; state.worker.postMessage({ kind: "chainResponse", connId: msg.connId, json, } satisfies MainToWorker); }; + const onClosed = (): void => { + if (state.disposed || entry.closed) return; + handleChainClose(state, msg); + state.worker.postMessage({ + kind: "chainClosed", + connId: msg.connId, + } satisfies MainToWorker); + }; try { - const conn = await chainConnect(msg.genesisHash, onResponse); - if (!conn) { - state.worker.postMessage({ - kind: "chainConnectAck", - connId: msg.connId, - ok: false, - error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, - } satisfies MainToWorker); + const conn = await (msg.kind === "hopConnectStart" + ? state.rawCallbacks.hopConnect(msg.genesisHash, msg.endpoint, onResponse, onClosed) + : state.rawCallbacks.chainConnect(msg.genesisHash, onResponse, onClosed)); + if (state.disposed || entry.closed) { + state.chainConnections.delete(msg.connId); + conn?.close(); return; } - state.chainConnections.set(msg.connId, conn); + if (!conn) throw new Error(`${msg.kind} returned no connection`); + entry.connection = conn; state.worker.postMessage({ kind: "chainConnectAck", connId: msg.connId, ok: true, } satisfies MainToWorker); } catch (err) { - state.worker.postMessage({ - kind: "chainConnectAck", - connId: msg.connId, - ok: false, - error: errorMessage(err), - } satisfies MainToWorker); + state.chainConnections.delete(msg.connId); + const report = !state.disposed && !entry.closed; + entry.closed = true; + try { + entry.connection?.close(); + } catch { + console.warn("[truapi worker] JSON-RPC close failed"); + } finally { + if (report) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: msg.kind === "hopConnectStart" ? "HOP connection unavailable" : errorMessage(err), + } satisfies MainToWorker); + } + } } } @@ -556,26 +668,38 @@ function handleChainSend( state: RuntimeState, msg: { connId: number; request: string }, ): void { - const conn = state.chainConnections.get(msg.connId); - if (!conn) return; + const entry = state.chainConnections.get(msg.connId); + if (!entry?.connection || entry.closed) return; try { if (debugLoggingEnabled(state)) { - console.debug("[truapi worker] chainSend", msg.connId, msg.request); + console.debug("[truapi worker] chainSend", msg.connId); + } + entry.connection.send(msg.request); + } catch { + console.warn("[truapi worker] JSON-RPC send failed"); + if (!entry.closed) { + handleChainClose(state, msg); + if (!state.disposed) { + state.worker.postMessage({ + kind: "chainClosed", + connId: msg.connId, + } satisfies MainToWorker); + } } - conn.send(msg.request); - } catch (err) { - console.warn("[truapi worker] chain send threw:", err); } } function handleChainClose(state: RuntimeState, msg: { connId: number }): void { - const conn = state.chainConnections.get(msg.connId); - if (!conn) return; + const entry = state.chainConnections.get(msg.connId); + if (!entry || entry.closed) return; + entry.closed = true; + // Keep a closed opening entry counted until its late handle can be closed. + if (!entry.connection) return; state.chainConnections.delete(msg.connId); try { - conn.close(); - } catch (err) { - console.warn("[truapi worker] chain close threw:", err); + entry.connection.close(); + } catch { + console.warn("[truapi worker] JSON-RPC close failed"); } } @@ -818,6 +942,7 @@ function teardown(state: RuntimeState, error: Error, fault: boolean): void { closeCoreState(core, error); } state.cores.clear(); + state.coreCallbacks.clear(); for (const fn of state.subscriptionDisposers.values()) { try { fn(); @@ -826,14 +951,20 @@ function teardown(state: RuntimeState, error: Error, fault: boolean): void { } } state.subscriptionDisposers.clear(); - for (const conn of state.chainConnections.values()) { + for (const entry of state.chainConnections.values()) { + entry.closed = true; try { - conn.close(); + entry.connection?.close(); } catch { // ignore during teardown } } state.chainConnections.clear(); + for (const id of state.chatFileExports) { + void state.rawCallbacks.cancelChatFileExport(id).catch(() => {}); + } + state.chatFileExports.clear(); + state.disposeNativeChatFiles(); // A worker nothing can call any more is not wanted. for (const productId of [...state.wantedWorkers]) { handleWorkerDemandChanged(state, productId, false); @@ -897,16 +1028,23 @@ function createWebWorkerHostRuntime( host: WebWorkerHostCallbacks, options: CreateWebWorkerHostRuntimeOptions, ): Promise { - const callbacks = createWasmRawCallbacks(host); + const browserFiles = host.nativeChatFiles ? undefined : createBrowserNativeChatFilesHost(); + const callbacks = createWasmRawCallbacks({ + ...host, + nativeChatFiles: host.nativeChatFiles ?? browserFiles!, + }); return new Promise((resolve, reject) => { const state: RuntimeState = { worker, rawCallbacks: callbacks, + coreCallbacks: new Map(), cores: new Map(), pendingCores: new Map(), subscriptionDisposers: new Map(), chainConnections: new Map(), + chatFileExports: new Set(), + disposeNativeChatFiles: () => browserFiles?.dispose(), pendingDisconnects: new Map(), pendingSessionActivations: new Map(), pendingLocalIdentities: new Map(), @@ -1068,8 +1206,9 @@ function createWebWorkerHostRuntime( handleSubscriptionStop(state, msg); break; case "chainConnectStart": + case "hopConnectStart": if (debugLoggingEnabled(state)) { - console.debug("[truapi worker] chainConnectStart", msg.connId); + console.debug("[truapi worker]", msg.kind, msg.connId); } void handleChainConnectStart(state, msg); break; @@ -1124,6 +1263,7 @@ function createWebWorkerHostRuntime( chat: host.chat !== undefined, permissionStatus: host.permissionStatus !== undefined, pocket: host.pocket !== undefined, + identityBackend: host.identityBackend !== undefined, }, debuggerUrl: debuggerEnablement.url, } satisfies MainToWorker); @@ -1191,6 +1331,7 @@ function handleCoreError( const pending = state.pendingCores.get(coreId); if (!pending) return; state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); pending.reject(new Error(error)); } @@ -1205,6 +1346,7 @@ function handleFrameError( const failure = new Error(`worker frame error: ${error}`); closeCoreState(core, failure); state.cores.delete(coreId); + state.coreCallbacks.delete(coreId); // Renders left registered would never settle: the worker cancels them with // the core, so nothing further arrives to complete the sink. failRendersForCore(state, coreId, failure); @@ -1223,7 +1365,7 @@ function buildRuntime( ): WorkerPairingHostRuntime & WorkerSigningHostRuntime { const runtime: WorkerPairingHostRuntime & WorkerSigningHostRuntime = { coreWireSchemaHash: state.coreWireSchemaHash, - createProvider(product): Promise { + createProvider(product, callbacks): Promise { if (state.disposed) { return Promise.reject( state.closedError ?? new Error("runtime disposed"), @@ -1231,6 +1373,7 @@ function buildRuntime( } return new Promise((resolve, reject) => { const coreId = ++state.nextCoreId; + if (callbacks) state.coreCallbacks.set(coreId, createWasmRawCallbacks(callbacks)); state.pendingCores.set(coreId, { productId: product.productId, resolve, @@ -1241,9 +1384,18 @@ function buildRuntime( kind: "createCore", coreId, product, + ...(callbacks === undefined ? {} : { + capabilities: { + chat: callbacks.chat !== undefined, + permissionStatus: callbacks.permissionStatus !== undefined, + pocket: callbacks.pocket !== undefined, + identityBackend: callbacks.identityBackend !== undefined, + }, + }), } satisfies MainToWorker); } catch (err) { state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); reject(err instanceof Error ? err : new Error(String(err))); } }); @@ -1624,7 +1776,11 @@ function buildProvider( ); }, setPermissionAuthorizationStatus(request, status) { - if (core.disposed) return Promise.resolve(); + if (core.disposed) { + return Promise.reject( + core.closedError ?? new Error("product connection is closed"), + ); + } return runtime.setPermissionAuthorizationStatus( core.productId, request, @@ -1694,6 +1850,7 @@ function buildProvider( if (core.disposed) return; closeCoreState(core, new Error("provider disposed")); state.cores.delete(core.coreId); + state.coreCallbacks.delete(core.coreId); // Renders left registered would never settle: the worker cancels them // with the core, so nothing further arrives to complete the sink. failRendersForCore(state, core.coreId, new Error("provider disposed")); diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts index 0e54729e2..6796b1809 100644 --- a/js/packages/truapi-host/src/web/index.ts +++ b/js/packages/truapi-host/src/web/index.ts @@ -17,3 +17,5 @@ export type { LocalIdentity, LocalIdentityProgress, } from "../worker-protocol.js"; +export { createBrowserNativeChatFilesHost } from "./native-chat-files.js"; +export type { BrowserNativeChatFilesHost, BrowserNativeChatFileSourceStore } from "./native-chat-files.js"; diff --git a/js/packages/truapi-host/src/web/native-chat-files.ts b/js/packages/truapi-host/src/web/native-chat-files.ts new file mode 100644 index 000000000..2e6ea8dff --- /dev/null +++ b/js/packages/truapi-host/src/web/native-chat-files.ts @@ -0,0 +1,422 @@ +import { bytesToHex } from "@parity/truapi/scale"; +import type { + NativeChatFileExportRequest, + NativeChatFilePickRequest, + NativeChatFilesHost, + NativeChatPickedFile, +} from "../generated/host-callbacks.js"; +import { inspectNativeChatFileMetadata, nativeChatExportFilename } from "./native-chat-media.js"; + +const MAX_FILE_SIZE = 0xffff_ffff; +const MAX_READ_SIZE = 2_000_000; +const DATABASE_NAME = "truapi-native-chat-files"; +const SOURCE_STORE = "sources"; + +type FileContext = NativeChatFilePickRequest | NativeChatFileExportRequest; +type SavePicker = (options: { suggestedName: string }) => Promise; +type SourceRecord = { blob: Blob }; +type ExportRecord = { + writer: FileSystemWritableFileStream; + size: number; + written: number; + queue: Promise; + removePartial: () => Promise; + presentCompleted?: () => Promise; +}; + +/** Immutable host-private sources; writes resolve only after durable commit. */ +export interface BrowserNativeChatFileSourceStore { + putSources(sources: readonly { sourceId: string; blob: Blob }[]): Promise; + readSource(sourceId: string): Promise; + releaseSource(sourceId: string): Promise; +} + +export interface BrowserNativeChatFilesHost extends NativeChatFilesHost { + /** Close host UI and cancel partial exports, never release durable sources. */ + dispose(): void; +} + +function checkedSize(size: number): void { + if (!Number.isInteger(size) || size < 0 || size > MAX_FILE_SIZE) { + throw new Error("Chat file size exceeds the supported range"); + } +} + +function safeLabel(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ").slice(0, 200); +} + +/** Trusted main-window custody. No filename, path or source handle crosses into a Guest. */ +export function createBrowserNativeChatFilesHost(sourceStore?: BrowserNativeChatFileSourceStore): BrowserNativeChatFilesHost { + let disposed = false; + let database: Promise | undefined; + const dialogs = new Set<() => void>(); + const exports = new Map(); + const mediaAbort = new AbortController(); + + function available(): void { + if (disposed) throw new Error("Native Chat files are unavailable"); + } + + function openDatabase(cleanup = false): Promise { + if (!cleanup) available(); + if (!database) { + database = new Promise((resolve, reject) => { + if (!globalThis.indexedDB) { + reject(new Error("Durable Chat file storage is unavailable")); + return; + } + const request = indexedDB.open(DATABASE_NAME, 1); + let blocked = false; + request.onupgradeneeded = () => request.result.createObjectStore(SOURCE_STORE); + request.onerror = () => reject(new Error("Durable Chat file storage is unavailable")); + request.onblocked = () => { + blocked = true; + reject(new Error("Durable Chat file storage is blocked")); + }; + request.onsuccess = () => { + const db = request.result; + if (blocked || (disposed && !cleanup)) { + db.close(); + reject(new Error("Native Chat files are unavailable")); + return; + } + db.onversionchange = () => { db.close(); database = undefined; }; + resolve(db); + }; + }).catch((error: unknown) => { + database = undefined; + throw error; + }); + } + return database; + } + + async function store(mode: IDBTransactionMode, action: (store: IDBObjectStore) => IDBRequest, cleanup = false): Promise { + const db = await openDatabase(cleanup); + if (!cleanup) available(); + return new Promise((resolve, reject) => { + // Resolve only on commit, not request success: returned sources must already be durable. + const transaction = db.transaction(SOURCE_STORE, mode, { durability: "strict" }); + const request = action(transaction.objectStore(SOURCE_STORE)); + transaction.oncomplete = () => { + if (disposed) { db.close(); database = undefined; } + resolve(request.result); + }; + transaction.onerror = transaction.onabort = () => reject(new Error("Chat file storage failed")); + }); + } + + function prompt( + title: string, + context: FileContext, + actionLabel: string, + configure: (content: HTMLElement) => () => T | Promise, + ): Promise { + available(); + if (typeof document === "undefined" || !document.body || window.top !== window) { + return Promise.reject(new Error("Trusted Chat file presentation is unavailable")); + } + return new Promise((resolve, reject) => { + const dialog = document.createElement("dialog"); + const heading = document.createElement("h2"); + heading.textContent = title; + const summary = document.createElement("p"); + summary.textContent = `Product: ${safeLabel(context.productId)}\nPeer: ${safeLabel(context.peerUsername ?? "Chat contact")}\nIdentity: ${bytesToHex(context.peerIdentity)}`; + summary.style.whiteSpace = "pre-wrap"; + summary.style.overflowWrap = "anywhere"; + const content = document.createElement("div"); + const accept = document.createElement("button"); + accept.type = "button"; + accept.textContent = actionLabel; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.textContent = "Cancel"; + dialog.setAttribute("aria-label", title); + dialog.style.maxWidth = "min(36rem, 90vw)"; + dialog.append(heading, summary, content, accept, cancel); + let settled = false; + let busy = false; + const close = () => { + dialogs.delete(abort); + dialog.close(); + dialog.remove(); + }; + const abort = () => { + if (settled) return; + close(); + // A native save picker cannot be aborted. Let its result reach the caller, + // which rechecks disposal and aborts the newly-created writable handle. + if (busy && disposed) return; + settled = true; + resolve(undefined); + }; + dialogs.add(abort); + cancel.onclick = abort; + dialog.oncancel = (event) => { event.preventDefault(); if (!busy) abort(); }; + try { + const run = configure(content); + accept.onclick = () => { + if (busy || settled) return; + busy = true; + accept.disabled = cancel.disabled = true; + // Invoke synchronously in this real click's user activation (FSA requires it). + let result: T | Promise; + try { result = run(); } catch (error) { result = Promise.reject(error); } + Promise.resolve(result).then((value) => { + if (!settled) { + settled = true; + close(); + resolve(value); + } + }, (error: unknown) => { + if (settled) return; + busy = false; + if (error instanceof DOMException && error.name === "AbortError") { abort(); return; } + settled = true; + close(); + reject(new Error("Chat file selection or export failed")); + }); + }; + document.body.append(dialog); + dialog.showModal(); + } catch { + settled = true; + close(); + reject(new Error("Trusted Chat file presentation is unavailable")); + } + }); + } + + async function abortExport(id: string, entry: ExportRecord): Promise { + exports.delete(id); + try { await entry.writer.abort(); } catch { /* The writer may already be closed. */ } + await entry.removePartial(); + } + + function withExport(id: string, action: (entry: ExportRecord) => Promise): Promise { + const entry = exports.get(id); + if (!entry) return Promise.reject(new Error("Chat file export is unavailable")); + const operation = entry.queue.then(async () => { + if (exports.get(id) !== entry) throw new Error("Chat file export is unavailable"); + await action(entry); + }); + entry.queue = operation.catch(() => {}); + return operation; + } + + const host: BrowserNativeChatFilesHost = { + async pickChatFiles(request) { + checkedSize(request.maxFiles); + if (request.maxFiles === 0) throw new Error("Chat file selection is unavailable"); + // Do not ask for files if durable custody cannot be established. + if (!sourceStore) await openDatabase(); + const files = await prompt("Send Chat attachments", request, "Attach files", (content) => { + const label = document.createElement("label"); + label.textContent = `Choose up to ${request.maxFiles} files. The Host keeps a private copy until the transfer is released.`; + const input = document.createElement("input"); + input.type = "file"; + input.multiple = request.maxFiles > 1; + label.append(input); + content.append(label); + return () => { + const selected = Array.from(input.files ?? []); + if (selected.length > request.maxFiles) throw new Error("Too many Chat attachments"); + for (const file of selected) checkedSize(file.size); + return selected; + }; + }); + if (!files?.length) return []; + available(); + const picked: NativeChatPickedFile[] = files.map((file) => ({ + sourceId: crypto.randomUUID(), + metadata: { + // Initial safe metadata is refined only from the committed immutable Blob. + mimeType: "application/octet-stream", + sizeBytes: file.size, + kind: { tag: "File" }, + }, + })); + if (sourceStore) { + await sourceStore.putSources(files.map((file, index) => ({ + sourceId: picked[index]!.sourceId, + blob: file.slice(0, file.size, "application/octet-stream"), + }))); + } else { + const db = await openDatabase(); + available(); + await new Promise((resolve, reject) => { + const transaction = db.transaction(SOURCE_STORE, "readwrite", { durability: "strict" }); + transaction.oncomplete = () => resolve(); + transaction.onerror = transaction.onabort = () => reject(new Error("Chat file snapshot failed")); + const sources = transaction.objectStore(SOURCE_STORE); + try { + files.forEach((file, index) => { + // Snapshot source bytes, never source names or paths. + sources.add({ blob: file.slice(0, file.size, "application/octet-stream") } satisfies SourceRecord, picked[index]!.sourceId); + }); + } catch { + transaction.abort(); + } + }); + } + try { + // Inspect one bounded header/probe at a time, from the durable snapshot + // rather than the original File, which may since have changed on disk. + for (const file of picked) { + if (disposed) break; + const record = sourceStore + ? { blob: await sourceStore.readSource(file.sourceId) } + : await store("readonly", (sources) => sources.get(file.sourceId)); + if (!record || !(record.blob instanceof Blob)) throw new Error("Chat file snapshot is unavailable"); + file.metadata = await inspectNativeChatFileMetadata(record.blob, mediaAbort.signal); + } + if (!disposed) return picked; + } catch { + await Promise.all(picked.map((file) => host.releaseChatFile(file.sourceId))); + if (!disposed) throw new Error("Chat file snapshot inspection failed"); + return []; + } + await Promise.all(picked.map((file) => host.releaseChatFile(file.sourceId))); + return []; + }, + + async readChatFile(sourceId, offset, length) { + checkedSize(length); + if (length > MAX_READ_SIZE || offset < 0n || offset > BigInt(MAX_FILE_SIZE)) { + throw new Error("Chat file read is out of bounds"); + } + const record = sourceStore + ? { blob: await sourceStore.readSource(sourceId) } + : await store("readonly", (sources) => sources.get(sourceId)); + if (!record || !(record.blob instanceof Blob)) throw new Error("Chat file source is unavailable"); + checkedSize(record.blob.size); + if (offset + BigInt(length) > BigInt(record.blob.size)) throw new Error("Chat file read is out of bounds"); + const start = Number(offset); + const bytes = new Uint8Array(await record.blob.slice(start, start + length).arrayBuffer()); + if (bytes.byteLength !== length) throw new Error("Chat file snapshot read failed"); + return bytes; + }, + + async releaseChatFile(sourceId) { + // Worker teardown may release a selection that committed after its consumer disappeared. + if (sourceStore) { + await sourceStore.releaseSource(sourceId); + return; + } + await store("readwrite", (sources) => sources.delete(sourceId), true); + }, + + async beginChatFileExport(request) { + available(); + checkedSize(request.metadata.sizeBytes); + const id = crypto.randomUUID(); + const filename = nativeChatExportFilename(request.metadata); + const entry = await prompt("Save Chat attachment", request, "Choose destination", (content) => { + const description = document.createElement("p"); + description.textContent = `${request.metadata.sizeBytes} bytes. Saved as a download, never opened or executed automatically.`; + content.append(description); + return async () => { + const picker = (window as Window & { showSaveFilePicker?: SavePicker }).showSaveFilePicker; + let writer: FileSystemWritableFileStream; + let removePartial = async () => {}; + let presentCompleted: (() => Promise) | undefined; + if (picker) { + const handle = await picker.call(window, { suggestedName: filename }); + writer = await handle.createWritable(); + } else { + if (!navigator.storage?.getDirectory) throw new Error("Streaming Chat file export is unavailable"); + const root = await navigator.storage.getDirectory(); + const directory = await root.getDirectoryHandle("truapi-chat-exports", { create: true }); + const handle = await directory.getFileHandle(id, { create: true }); + removePartial = () => directory.removeEntry(id); + try { writer = await handle.createWritable(); } catch (error) { await removePartial(); throw error; } + presentCompleted = async () => { + // getFile supplies a disk-backed snapshot; never concatenate chunks in JS memory. + const file = await handle.getFile(); + const url = URL.createObjectURL(file.slice(0, file.size, "application/octet-stream")); + let downloaded = false; + try { + await prompt("Chat attachment ready", request, "Done", (body) => { + const link = document.createElement("a"); + link.textContent = "Download attachment"; + link.href = url; + link.download = filename; + link.onclick = () => { downloaded = true; }; + body.append(link); + return () => undefined; + }); + } finally { + URL.revokeObjectURL(url); + // Keep an undownloaded completed copy; cancellation must not delete it. + if (downloaded) await removePartial(); + } + }; + } + const result: ExportRecord = { writer, size: request.metadata.sizeBytes, written: 0, queue: Promise.resolve(), removePartial, presentCompleted }; + if (disposed) { + try { await writer.abort(); } finally { await removePartial(); } + return undefined; + } + return result; + }; + }); + if (!entry) return undefined; + if (disposed) { await abortExport(id, entry); return undefined; } + exports.set(id, entry); + return id; + }, + + async writeChatFileExport(exportId, offset, data) { + available(); + await withExport(exportId, async (entry) => { + if (data.byteLength > MAX_READ_SIZE || offset !== BigInt(entry.written) || BigInt(data.byteLength) + offset > BigInt(entry.size)) { + throw new Error("Chat file export write is out of bounds or not contiguous"); + } + try { + await entry.writer.write(data as FileSystemWriteChunkType); + entry.written += data.byteLength; + } catch { + await abortExport(exportId, entry); + throw new Error("Chat file export write failed"); + } + }); + }, + + async finishChatFileExport(exportId) { + available(); + await withExport(exportId, async (entry) => { + if (entry.written !== entry.size) throw new Error("Chat file export is incomplete"); + try { await entry.writer.close(); } catch { + await abortExport(exportId, entry); + throw new Error("Chat file export could not be saved"); + } + // Commit precedes presentation: late cancellation cannot delete a completed user export. + exports.delete(exportId); + if (entry.presentCompleted && !disposed) await entry.presentCompleted(); + }); + }, + + async cancelChatFileExport(exportId) { + const entry = exports.get(exportId); + if (!entry) return; + const operation = entry.queue.then(async () => { + if (exports.get(exportId) === entry) await abortExport(exportId, entry); + }); + entry.queue = operation.catch(() => {}); + await operation; + }, + + dispose() { + if (disposed) return; + disposed = true; + mediaAbort.abort(); + for (const abort of dialogs) abort(); + for (const id of exports.keys()) void host.cancelChatFileExport(id).catch(() => {}); + const opening = database; + database = undefined; + void opening?.then((db) => db.close(), () => {}); + }, + }; + return host; +} diff --git a/js/packages/truapi-host/src/web/native-chat-media.test.ts b/js/packages/truapi-host/src/web/native-chat-media.test.ts new file mode 100644 index 000000000..5ca00cd5b --- /dev/null +++ b/js/packages/truapi-host/src/web/native-chat-media.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; +import { settle } from "../test-support.js"; +import { inspectNativeChatFileMetadata, nativeChatExportFilename } from "./native-chat-media.js"; + +// Header fixtures exercise metadata parsing, not an image renderer/decoder. +const png = new Uint8Array(33); +png.set([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); +new DataView(png.buffer).setUint32(16, 640); +new DataView(png.buffer).setUint32(20, 480); +png.set([8, 6, 0, 0, 0], 24); +const gif = new Uint8Array([71, 73, 70, 56, 57, 97, 64, 1, 240, 0, 0, 0, 0]); +// The APP1 payload deliberately contains a fake SOF; it must be skipped by length. +const jpeg = new Uint8Array([ + 0xff, 0xd8, 0xff, 0xe1, 0, 13, 0xff, 0xc0, 0, 11, 8, 0, 1, 0, 1, 1, 1, + 0xff, 0xc2, 0, 11, 8, 1, 44, 2, 88, 1, 1, 0x11, 0, +]); + +function webp(chunk: string, payload: number[]): Uint8Array { + const bytes = new Uint8Array(20 + payload.length + (payload.length & 1)); + const view = new DataView(bytes.buffer); + bytes.set(new TextEncoder().encode("RIFF")); + view.setUint32(4, bytes.length - 8, true); + bytes.set(new TextEncoder().encode(`WEBP${chunk}`), 8); + view.setUint32(16, payload.length, true); + bytes.set(payload, 20); + return bytes; +} + +const mp4 = new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 0, 0, 105, 115, 111, 109, 109, 112, 52, 50]); +const webm = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x87, 0x42, 0x82, 0x84, 119, 101, 98, 109, 0x18, 0x53, 0x80, 0x67, 0xff]); + +class MetadataVideo { + src = ""; + preload = ""; + autoplay = false; + muted = false; + playsInline = false; + duration = 12.9; + videoWidth = 640; + videoHeight = 480; + onloadedmetadata: (() => void) | null = null; + onerror: (() => void) | null = null; + removeAttribute(name: string) { if (name === "src") this.src = ""; } + load() {} + play(): never { throw new Error("Metadata inspection must never play media"); } +} + +async function withVideoDocument(run: (videos: MetadataVideo[]) => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(globalThis, "document"); + const videos: MetadataVideo[] = []; + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + createElement(name: string) { + if (name !== "video") throw new Error("Only detached video metadata inspection is allowed"); + const video = new MetadataVideo(); + videos.push(video); + return video; + }, + }, + }); + try { await run(videos); } finally { + if (original) Object.defineProperty(globalThis, "document", original); + else Reflect.deleteProperty(globalThis, "document"); + } +} + +describe("native Chat immutable media metadata", () => { + for (const fixture of [ + { label: "PNG", bytes: png, mime: "image/png", width: 640, height: 480, extension: "png" }, + { label: "GIF", bytes: gif, mime: "image/gif", width: 320, height: 240, extension: "gif" }, + { label: "JPEG with APP1", bytes: jpeg, mime: "image/jpeg", width: 600, height: 300, extension: "jpg" }, + { label: "extended WebP", bytes: webp("VP8X", [0, 0, 0, 0, 0x3f, 1, 0, 0xef, 0, 0]), mime: "image/webp", width: 320, height: 240, extension: "webp" }, + { label: "lossy WebP", bytes: webp("VP8 ", [0, 0, 0, 0x9d, 1, 0x2a, 0x40, 1, 0xf0, 0]), mime: "image/webp", width: 320, height: 240, extension: "webp" }, + { label: "lossless WebP", bytes: webp("VP8L", [0x2f, 0x3f, 0xc1, 0x3b, 0]), mime: "image/webp", width: 320, height: 240, extension: "webp" }, + ]) { + it(`extracts ${fixture.label} dimensions from bytes, ignoring the MIME label`, async () => { + const metadata = await inspectNativeChatFileMetadata(new Blob([fixture.bytes], { type: "text/html" })); + expect(metadata).toEqual({ + mimeType: fixture.mime, + sizeBytes: fixture.bytes.length, + kind: { tag: "Image", value: { width: fixture.width, height: fixture.height, thumbnail: undefined } }, + }); + expect(nativeChatExportFilename(metadata)).toBe(`chat-attachment.${fixture.extension}`); + for (let length = 0; length < fixture.bytes.length; length++) { + const truncated = await inspectNativeChatFileMetadata(new Blob([fixture.bytes.slice(0, length)])); + expect(truncated.kind.tag).toBe("File"); + } + }); + } + + it("does not load active content or MIME-spoofed files into a media element", async () => { + await withVideoDocument(async (videos) => { + for (const content of ["", ""]) { + const metadata = await inspectNativeChatFileMetadata(new Blob([content], { type: "video/mp4" })); + expect(metadata.kind.tag).toBe("File"); + expect(metadata.mimeType).toBe("application/octet-stream"); + expect(nativeChatExportFilename(metadata)).toBe("chat-attachment.bin"); + } + expect(videos).toEqual([]); + }); + }); + + it("falls back for a JPEG whose dimensions are beyond the bounded header", async () => { + const bytes = new Uint8Array(65_552); + bytes.set([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff]); + bytes.set([0xff, 0xc0, 0, 11, 8, 0, 10, 0, 10, 1, 1, 0x11, 0], 65_539); + expect((await inspectNativeChatFileMetadata(new Blob([bytes]))).kind.tag).toBe("File"); + }); + + for (const fixture of [{ bytes: mp4, mime: "video/mp4", extension: "mp4" }, { bytes: webm, mime: "video/webm", extension: "webm" }]) { + it(`probes whitelisted ${fixture.mime} bytes without playback and revokes its URL`, async () => { + await withVideoDocument(async (videos) => { + const pending = inspectNativeChatFileMetadata(new Blob([fixture.bytes], { type: "text/html" })); + await settle(); + expect(videos).toHaveLength(1); + const video = videos[0]!; + const url = video.src; + expect((await fetch(url)).ok).toBe(true); + expect(video.autoplay).toBe(false); + video.onloadedmetadata!(); + const metadata = await pending; + expect(metadata).toEqual({ mimeType: fixture.mime, sizeBytes: fixture.bytes.length, kind: { tag: "Video", value: { durationSeconds: 12, thumbnail: undefined } } }); + expect(nativeChatExportFilename(metadata)).toBe(`chat-attachment.${fixture.extension}`); + await expect(fetch(url)).rejects.toThrow(); + }); + }); + } + + it("aborts a pending video probe, releases its URL and ignores a late event", async () => { + await withVideoDocument(async (videos) => { + const abort = new AbortController(); + const pending = inspectNativeChatFileMetadata(new Blob([mp4]), abort.signal); + await settle(); + const video = videos[0]!; + const url = video.src; + const late = video.onloadedmetadata!; + abort.abort(); + expect((await pending).kind.tag).toBe("File"); + late(); + await expect(fetch(url)).rejects.toThrow(); + }); + }); + + it("rejects nonfinite duration and audio-only MP4 as video attachments", async () => { + await withVideoDocument(async (videos) => { + const invalidDuration = inspectNativeChatFileMetadata(new Blob([mp4])); + await settle(); + videos[0]!.duration = Infinity; + videos[0]!.onloadedmetadata!(); + expect((await invalidDuration).kind.tag).toBe("File"); + const audio = inspectNativeChatFileMetadata(new Blob([mp4])); + await settle(); + videos[1]!.videoWidth = 0; + videos[1]!.onloadedmetadata!(); + expect((await audio).kind.tag).toBe("File"); + }); + }); + + it("never promotes active MIME types or inconsistent kinds into executable extensions", () => { + const image = { tag: "Image" as const, value: { width: 1, height: 1, thumbnail: undefined } }; + for (const mimeType of ["image/svg+xml", "text/html", "application/javascript", "__proto__", "image/png/../../x.html"]) { + expect(nativeChatExportFilename({ mimeType, sizeBytes: 1, kind: image })).toBe("chat-attachment.bin"); + } + expect(nativeChatExportFilename({ mimeType: "image/png", sizeBytes: 1, kind: { tag: "File" } })).toBe("chat-attachment.bin"); + expect(nativeChatExportFilename({ mimeType: "image/png", sizeBytes: 1, kind: { tag: "Image", value: { width: 0, height: 1, thumbnail: undefined } } })).toBe("chat-attachment.bin"); + }); +}); diff --git a/js/packages/truapi-host/src/web/native-chat-media.ts b/js/packages/truapi-host/src/web/native-chat-media.ts new file mode 100644 index 000000000..8d5326a74 --- /dev/null +++ b/js/packages/truapi-host/src/web/native-chat-media.ts @@ -0,0 +1,219 @@ +import type { NativeChatPickedFile } from "../generated/host-callbacks.js"; + +type AttachmentMetadata = NativeChatPickedFile["metadata"]; +type ImageHeader = { mimeType: string; width: number; height: number }; +const HEADER_LIMIT = 65_536; +const VIDEO_TIMEOUT_MS = 5_000; +const U32_MAX = 0xffff_ffff; +const MP4_BRANDS = ["isom", "iso2", "iso3", "iso4", "iso5", "iso6", "mp41", "mp42", "avc1", "M4V ", "M4VH", "M4VP"]; +const PNG_DEPTHS: Readonly> = { + 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], +}; + +function matches(bytes: Uint8Array, offset: number, signature: string): boolean { + if (offset + signature.length > bytes.length) return false; + for (let i = 0; i < signature.length; i++) { + if (bytes[offset + i] !== signature.charCodeAt(i)) return false; + } + return true; +} + +/** Only inspect bounded headers; never render images, XML, SVG or HTML. */ +function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefined { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length >= 33 && matches(bytes, 0, "\x89PNG\r\n\x1a\n") && view.getUint32(8) === 13 && matches(bytes, 12, "IHDR")) { + const width = view.getUint32(16); + const height = view.getUint32(20); + if (width > 0 && height > 0 && width <= 0x7fff_ffff && height <= 0x7fff_ffff && PNG_DEPTHS[bytes[25]!]?.includes(bytes[24]!) && bytes[26] === 0 && bytes[27] === 0 && bytes[28]! <= 1) { + return { mimeType: "image/png", width, height }; + } + return undefined; + } + if (bytes.length >= 13 && (matches(bytes, 0, "GIF87a") || matches(bytes, 0, "GIF89a"))) { + const width = view.getUint16(6, true); + const height = view.getUint16(8, true); + if (width && height) return { mimeType: "image/gif", width, height }; + return undefined; + } + if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) { + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset++] !== 0xff) return undefined; + while (offset < bytes.length && bytes[offset] === 0xff) offset++; + const marker = bytes[offset++]; + // Dimensions must precede compressed scan data; never search arbitrarily inside it. + if (marker === undefined || marker === 0xda || marker === 0xd9 || marker === 0x00) return undefined; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (offset + 2 > bytes.length) return undefined; + const length = view.getUint16(offset); + if (length < 2 || offset + length > bytes.length) return undefined; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if (length < 8) return undefined; + const height = view.getUint16(offset + 3); + const width = view.getUint16(offset + 5); + const components = bytes[offset + 7]!; + if (width && height && components > 0 && length === 8 + 3 * components) { + return { mimeType: "image/jpeg", width, height }; + } + return undefined; + } + offset += length; + } + return undefined; + } + if (bytes.length >= 30 && matches(bytes, 0, "RIFF") && matches(bytes, 8, "WEBP") && view.getUint32(4, true) + 8 === fileSize) { + const chunkSize = view.getUint32(16, true); + if (20 + chunkSize + (chunkSize & 1) > fileSize) return undefined; + if (matches(bytes, 12, "VP8X") && chunkSize === 10) { + const width = 1 + bytes[24]! + (bytes[25]! << 8) + (bytes[26]! << 16); + const height = 1 + bytes[27]! + (bytes[28]! << 8) + (bytes[29]! << 16); + return { mimeType: "image/webp", width, height }; + } + if (matches(bytes, 12, "VP8 ") && chunkSize >= 10 && (bytes[20]! & 1) === 0 && matches(bytes, 23, "\x9d\x01\x2a")) { + const width = view.getUint16(26, true) & 0x3fff; + const height = view.getUint16(28, true) & 0x3fff; + if (width && height) return { mimeType: "image/webp", width, height }; + } + } + if (bytes.length >= 25 && matches(bytes, 0, "RIFF") && matches(bytes, 8, "WEBP") && matches(bytes, 12, "VP8L") && view.getUint32(4, true) + 8 === fileSize) { + const chunkSize = view.getUint32(16, true); + if (chunkSize >= 5 && 20 + chunkSize + (chunkSize & 1) <= fileSize && bytes[20] === 0x2f && (bytes[24]! >> 5) === 0) { + const width = 1 + bytes[21]! + ((bytes[22]! & 0x3f) << 8); + const height = 1 + (bytes[22]! >> 6) + (bytes[23]! << 2) + ((bytes[24]! & 0x0f) << 10); + return { mimeType: "image/webp", width, height }; + } + } + return undefined; +} + +/** Decode only the EBML header's bounded integer fields, not its media payload. */ +function ebmlInteger(bytes: Uint8Array, offset: number, id: boolean): { value: number; next: number } | undefined { + const first = bytes[offset]; + if (first === undefined || first === 0) return undefined; + let marker = 0x80; + let length = 1; + while ((first & marker) === 0) { marker >>= 1; length++; } + if (length > (id ? 4 : 8) || offset + length > bytes.length) return undefined; + let value = id ? first : first & (marker - 1); + for (let i = 1; i < length; i++) value = value * 256 + bytes[offset + i]!; + // Unknown-sized elements are not valid inside an EBML header. + if (!Number.isSafeInteger(value) || (!id && value === 2 ** (7 * length) - 1)) return undefined; + return { value, next: offset + length }; +} + +function videoMime(bytes: Uint8Array, fileSize: number): string | undefined { + if (bytes.length >= 16 && matches(bytes, 4, "ftyp")) { + const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0); + if (size < 16 || size > bytes.length || size > fileSize || size % 4 !== 0) return undefined; + if (matches(bytes, 8, "qt ")) return "video/quicktime"; + for (const brand of MP4_BRANDS) { + if (matches(bytes, 8, brand)) return "video/mp4"; + } + return undefined; + } + if (!matches(bytes, 0, "\x1a\x45\xdf\xa3")) return undefined; + const header = ebmlInteger(bytes, 4, false); + if (!header || header.value > bytes.length - header.next) return undefined; + const end = header.next + header.value; + if (!matches(bytes, end, "\x18\x53\x80\x67")) return undefined; + let offset = header.next; + let webm = false; + while (offset < end) { + const id = ebmlInteger(bytes, offset, true); + if (!id || id.next > end) return undefined; + const size = ebmlInteger(bytes, id.next, false); + if (!size || size.next > end || size.value > end - size.next) return undefined; + if (id.value === 0x4282) { + if (webm || size.value !== 4 || !matches(bytes, size.next, "webm")) return undefined; + webm = true; + } + offset = size.next + size.value; + } + return webm ? "video/webm" : undefined; +} + +/** Derive metadata from the durable Blob's bytes, not the original name or File.type. */ +export async function inspectNativeChatFileMetadata(blob: Blob, signal?: AbortSignal): Promise { + if (!Number.isInteger(blob.size) || blob.size < 0 || blob.size > U32_MAX) throw new Error("Chat file size exceeds the supported range"); + const fallback: AttachmentMetadata = { mimeType: "application/octet-stream", sizeBytes: blob.size, kind: { tag: "File" } }; + if (signal?.aborted) return fallback; + const bytes = new Uint8Array(await blob.slice(0, HEADER_LIMIT).arrayBuffer()); + if (signal?.aborted) return fallback; + const image = imageHeader(bytes, blob.size); + if (image) { + return { mimeType: image.mimeType, sizeBytes: blob.size, kind: { tag: "Image", value: { width: image.width, height: image.height, thumbnail: undefined } } }; + } + const mimeType = videoMime(bytes, blob.size); + if (!mimeType || typeof document === "undefined") return fallback; + // Only a whitelisted media container reaches the browser decoder. The element + // is detached, muted, metadata-only and never played or presented to a Guest. + let video: HTMLVideoElement; + let url: string; + try { + video = document.createElement("video"); + video.preload = "metadata"; + video.autoplay = false; + video.muted = true; + video.playsInline = true; + url = URL.createObjectURL(blob.slice(0, blob.size, mimeType)); + } catch { + return fallback; + } + return new Promise((resolve) => { + let settled = false; + const finish = (metadata: AttachmentMetadata) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + video.onloadedmetadata = video.onerror = null; + try { + video.removeAttribute("src"); + video.load(); + } catch { + // Cleanup failure must not retain the URL or strand the Host selection. + } finally { + URL.revokeObjectURL(url); + resolve(metadata); + } + }; + const abort = () => finish(fallback); + const timer = setTimeout(abort, VIDEO_TIMEOUT_MS); + signal?.addEventListener("abort", abort, { once: true }); + video.onerror = abort; + video.onloadedmetadata = () => { + const duration = video.duration; + if (!Number.isFinite(duration) || duration < 0 || duration > U32_MAX || video.videoWidth <= 0 || video.videoHeight <= 0) { + finish(fallback); + } else { + finish({ mimeType, sizeBytes: blob.size, kind: { tag: "Video", value: { durationSeconds: Math.floor(duration), thumbnail: undefined } } }); + } + }; + if (signal?.aborted) { abort(); return; } + try { video.src = url; video.load(); } catch { abort(); } + }); +} + +const IMAGE_EXTENSIONS: Readonly> = { + "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", +}; +const VIDEO_EXTENSIONS: Readonly> = { + "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", +}; + +/** Fixed safe basename and a kind-consistent media whitelist, never a supplied path. */ +export function nativeChatExportFilename(metadata: AttachmentMetadata): string { + let extension = "bin"; + if (metadata.kind.tag === "Image") { + const { width, height } = metadata.kind.value; + if (Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 && width <= U32_MAX && height <= U32_MAX && Object.hasOwn(IMAGE_EXTENSIONS, metadata.mimeType)) { + extension = IMAGE_EXTENSIONS[metadata.mimeType]!; + } + } else if (metadata.kind.tag === "Video") { + const { durationSeconds } = metadata.kind.value; + if (Number.isInteger(durationSeconds) && durationSeconds >= 0 && durationSeconds <= U32_MAX && Object.hasOwn(VIDEO_EXTENSIONS, metadata.mimeType)) { + extension = VIDEO_EXTENSIONS[metadata.mimeType]!; + } + } + return `chat-attachment.${extension}`; +} diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 44e10dde9..a340a31c6 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -15,17 +15,31 @@ import type { } from "@parity/truapi"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; -import { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; +import { createWorkerRawCallbacks } from "../generated/worker-callbacks.js"; +import type { + OptionalCapabilities, + WorkerCallbackBridge, +} from "../generated/worker-callbacks.js"; +import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; +import { + AuthState, + CoreStorageKey, + NativeChatFileExportRequest, + NativeChatFilePickRequest, +} from "../generated/host-callbacks.js"; import type { AuthState as AuthStateValue, PreimageHost, + NativeChatPickedFile, } from "../generated/host-callbacks.js"; import type { + PlatformJsonRpcConnection, ProductRuntimeConfig, TrUApiProductProvider, WorkerDemandChange, } from "../runtime.js"; import { makeHostCallbacks, settle } from "../test-support.js"; +import { MAX_JSON_RPC_CONNECTIONS } from "../worker-protocol.js"; import { createWebWorkerPairingHostRuntime, createWebWorkerSigningHostRuntime, @@ -267,7 +281,12 @@ describe("createWebWorkerPairingHostRuntime", () => { logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), runtimeKind: "pairing", - capabilities: { chat: false, permissionStatus: false, pocket: false }, + capabilities: { + chat: false, + permissionStatus: false, + pocket: false, + identityBackend: false, + }, debuggerUrl: null, }); @@ -439,6 +458,7 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: true, permissionStatus: false, pocket: false, + identityBackend: false, }); }); @@ -460,9 +480,68 @@ describe("createWebWorkerPairingHostRuntime", () => { chat: false, permissionStatus: false, pocket: true, + identityBackend: false, }); }); + it("preserves optional authenticated identity search through the worker boundary", async () => { + const worker = new FakeWorker(); + const account = new Uint8Array(32).fill(0x42); + const genesis = new Uint8Array(32).fill(0x77); + const runtimePromise = createWebWorkerSigningHostRuntime( + asWorker(worker), + makeHostCallbacks({ + identityBackend: { + identityUsernameCandidates: async (username, peopleGenesis) => { + if (username !== "alice" || bytesToHex(peopleGenesis) !== bytesToHex(genesis)) { + throw new Error("authenticated search unavailable"); + } + return [account]; + }, + }, + }), + { + hostConfig: { + ...hostConfigFromRuntimeConfig(runtimeConfig()), + networkSuffix: "paseo", + }, + }, + ); + worker.emit({ kind: "loaded" }); + const capabilities = lastMessageOfKind(worker, "init").capabilities as OptionalCapabilities; + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + let requestId = 0; + const bridge = { + async callbackRequest(name, args) { + const id = ++requestId; + worker.emit({ kind: "callbackRequest", requestId: id, name, args }); + await settle(); + const response = worker.messages.find( + (message) => message.kind === "callbackResponse" && message.requestId === id, + ); + if (!response) throw new Error("missing callback response"); + if (!response.ok) throw new Error(String(response.error)); + return response.value; + }, + } satisfies Pick; + const callbacks = createWorkerRawCallbacks( + bridge as WorkerCallbackBridge, + capabilities, + ) as unknown as RawCallbacks; + + try { + expect(await callbacks.identityUsernameCandidates!("alice", genesis)).toEqual( + new Uint8Array([4, ...account]), + ); + await expect( + callbacks.identityUsernameCandidates!("unavailable", genesis), + ).rejects.toThrow("authenticated search unavailable"); + } finally { + runtime.dispose(); + } + }); + it("creates multiple product cores on one worker runtime", async () => { const worker = new FakeWorker(); const config = runtimeConfig(); @@ -798,6 +877,19 @@ describe("createWebWorkerPairingHostRuntime", () => { ); }); + it("rejects permission changes after the product connection closes", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + provider.dispose(); + + await expect( + provider.setPermissionAuthorizationStatus( + { tag: "Device", value: "Camera" }, + "Denied", + ), + ).rejects.toThrow(); + }); + it("forwards session activation calls and resolves their responses", async () => { const worker = new FakeWorker(); const runtime = await readyRuntime(worker); @@ -1038,6 +1130,261 @@ describe("createWebWorkerPairingHostRuntime", () => { runtime.dispose(); }); + it("keeps delivered file sources durable but releases a selection lost during teardown", async () => { + const worker = new FakeWorker(); + const late = Promise.withResolvers(); + const owned = new Set(["delivered", "undelivered"]); + let calls = 0; + const metadata = { mimeType: "application/octet-stream", sizeBytes: 1, kind: { tag: "File" as const } }; + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + nativeChatFiles: { + pickChatFiles: async () => ++calls === 1 ? [{ sourceId: "delivered", metadata }] : late.promise, + releaseChatFile: async (id) => { owned.delete(id); }, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + const request = NativeChatFilePickRequest.enc({ + productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined, maxFiles: 1, + }); + worker.emit({ kind: "callbackRequest", requestId: 1, name: "pickChatFiles", args: [request] }); + await settle(); + worker.emit({ kind: "callbackRequest", requestId: 2, name: "pickChatFiles", args: [request] }); + await settle(); + runtime.dispose(); + late.resolve([{ sourceId: "undelivered", metadata }]); + await settle(); + expect([...owned]).toEqual(["delivered"]); + expect(worker.messages.filter((message) => message.kind === "callbackResponse").map((message) => message.requestId)).toEqual([1]); + }); + + it("cancels active and late file exports after a worker fault, not completed exports", async () => { + const worker = new FakeWorker(); + const late = Promise.withResolvers(); + const cancelled: string[] = []; + let calls = 0; + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + nativeChatFiles: { + beginChatFileExport: async () => ["completed", "active"][calls++] ?? late.promise, + finishChatFileExport: async () => {}, + cancelChatFileExport: async (id) => { cancelled.push(id); }, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + await runtimePromise; + const request = NativeChatFileExportRequest.enc({ + productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined, + metadata: { mimeType: "application/octet-stream", sizeBytes: 0, kind: { tag: "File" } }, + }); + worker.emit({ kind: "callbackRequest", requestId: 1, name: "beginChatFileExport", args: [request] }); + await settle(); + worker.emit({ kind: "callbackRequest", requestId: 2, name: "finishChatFileExport", args: ["completed"] }); + await settle(); + worker.emit({ kind: "callbackRequest", requestId: 3, name: "beginChatFileExport", args: [request] }); + await settle(); + worker.emit({ kind: "callbackRequest", requestId: 4, name: "beginChatFileExport", args: [request] }); + await settle(); + worker.emitError("worker stopped"); + late.resolve("late"); + await settle(); + expect(cancelled.sort()).toEqual(["active", "late"]); + expect(worker.messages.filter((message) => message.kind === "callbackResponse").map((message) => message.requestId)).toEqual([1, 2, 3]); + }); + + it("preserves bigint file offsets and never returns backend private error details", async () => { + const worker = new FakeWorker(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + nativeChatFiles: { + readChatFile: async (_id, offset) => { + if (offset === 0xffff_ffff_ffff_ffffn) return new Uint8Array([0xa5]); + throw new Error("private-source-and-path"); + }, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + worker.emit({ + kind: "callbackRequest", requestId: 1, name: "readChatFile", + args: ["private-source-and-path", 0xffff_ffff_ffff_ffffn, 1], + }); + await settle(); + expect(lastMessageOfKind(worker, "callbackResponse")).toEqual({ + kind: "callbackResponse", requestId: 1, ok: true, value: new Uint8Array([0xa5]), + }); + worker.emit({ + kind: "callbackRequest", requestId: 2, name: "readChatFile", + args: ["private-source-and-path", 0n, 1], + }); + await settle(); + expect(lastMessageOfKind(worker, "callbackResponse")).toEqual({ + kind: "callbackResponse", requestId: 2, ok: false, error: "Native Chat file operation failed", + }); + runtime.dispose(); + }); + + for (const teardown of ["dispose", "fault", "close"] as const) { + it(`closes a HOP handle that opens after ${teardown}`, async () => { + const worker = new FakeWorker(); + const opening = Promise.withResolvers(); + const endpoint = "wss://hop.example/rpc"; + let closes = 0; + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + hop: { + allowedHopEndpoints: async () => [endpoint], + connectHop: () => opening.promise, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + worker.emit({ kind: "hopConnectStart", connId: 1, genesisHash: "0xab", endpoint }); + await settle(); + if (teardown === "dispose") runtime.dispose(); + else if (teardown === "fault") worker.emitError("worker stopped"); + else worker.emit({ kind: "chainClose", connId: 1 }); + const response = Promise.withResolvers>(); + opening.resolve({ + send() {}, + responses: () => ({ + [Symbol.asyncIterator]: () => ({ next: () => response.promise }), + }), + close() { + closes += 1; + response.resolve({ done: true, value: undefined }); + }, + }); + await settle(); + expect(closes).toBe(1); + expect(worker.messages.filter((message) => + message.kind === "chainConnectAck" || message.kind === "chainResponse" + )).toEqual([]); + runtime.dispose(); + expect(closes).toBe(1); + }); + } + + it("pumps HOP responses and releases the connection on remote closure", async () => { + const worker = new FakeWorker(); + const response = Promise.withResolvers>(); + const endpoint = "wss://hop.example/rpc"; + const sent: string[] = []; + let closes = 0; + let delivered = false; + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + hop: { + allowedHopEndpoints: async () => [endpoint], + async connectHop() { + return { + send: (request) => sent.push(request), + responses: () => ({ + [Symbol.asyncIterator]: () => ({ + async next(): Promise> { + if (delivered) return { done: true, value: undefined }; + delivered = true; + return response.promise; + }, + }), + }), + close() { closes += 1; }, + }; + }, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + worker.emit({ kind: "hopConnectStart", connId: 7, genesisHash: "0xab", endpoint }); + await settle(); + expect(lastMessageOfKind(worker, "chainConnectAck")).toEqual({ + kind: "chainConnectAck", connId: 7, ok: true, + }); + worker.emit({ kind: "chainSend", connId: 7, request: '{"id":1}' }); + response.resolve({ done: false, value: '{"id":1,"result":"ok"}' }); + await settle(); + expect(lastMessageOfKind(worker, "chainResponse")).toEqual({ + kind: "chainResponse", connId: 7, json: '{"id":1,"result":"ok"}', + }); + expect(lastMessageOfKind(worker, "chainClosed")).toEqual({ + kind: "chainClosed", connId: 7, + }); + worker.emit({ kind: "chainSend", connId: 7, request: "late" }); + worker.emit({ kind: "chainClose", connId: 7 }); + runtime.dispose(); + expect(sent).toEqual(['{"id":1}']); + expect(closes).toBe(1); + }); + + it("bounds outstanding HOP opens even when cancelled before completion", async () => { + const worker = new FakeWorker(); + const opening = Promise.withResolvers(); + const endpoint = "wss://hop.example/rpc"; + let dials = 0; + let closes = 0; + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks({ + hop: { + allowedHopEndpoints: async () => [endpoint], + connectHop() { dials += 1; return opening.promise; }, + }, + }), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + for (let connId = 1; connId <= MAX_JSON_RPC_CONNECTIONS; connId += 1) { + worker.emit({ kind: "hopConnectStart", connId, genesisHash: "0xab", endpoint }); + worker.emit({ kind: "chainClose", connId }); + } + worker.emit({ + kind: "hopConnectStart", connId: MAX_JSON_RPC_CONNECTIONS + 1, + genesisHash: "0xab", endpoint, + }); + await settle(); + expect(dials).toBe(MAX_JSON_RPC_CONNECTIONS); + expect(lastMessageOfKind(worker, "chainConnectAck")).toMatchObject({ + connId: MAX_JSON_RPC_CONNECTIONS + 1, ok: false, + }); + opening.resolve({ + send() {}, + async *responses() {}, + close() { closes += 1; }, + }); + await settle(); + expect(closes).toBe(MAX_JSON_RPC_CONNECTIONS); + worker.emit({ + kind: "hopConnectStart", connId: MAX_JSON_RPC_CONNECTIONS + 2, + genesisHash: "0xab", endpoint, + }); + await settle(); + expect(dials).toBe(MAX_JSON_RPC_CONNECTIONS + 1); + runtime.dispose(); + }); + it("worker fault terminates the worker and runs the full teardown", async () => { const worker = new FakeWorker(); let subscriptionDisposes = 0; diff --git a/js/packages/truapi-host/src/worker-callbacks.test.ts b/js/packages/truapi-host/src/worker-callbacks.test.ts index 1cb1c25c3..eba3e4245 100644 --- a/js/packages/truapi-host/src/worker-callbacks.test.ts +++ b/js/packages/truapi-host/src/worker-callbacks.test.ts @@ -27,11 +27,21 @@ function stubBridge() { return () => {}; }, chainConnect: async () => null, + hopConnect: async () => null, }, }; } describe("worker raw callbacks", () => { + it("leaves username search unavailable when the host omits its capability", () => { + const { bridge } = stubBridge(); + const callbacks = createWorkerRawCallbacks( + bridge as unknown as Parameters[0], + ); + + expect(callbacks.identityUsernameCandidates).toBeUndefined(); + }); + it("omits the chat proxies when no chat capability is reported", () => { const { bridge } = stubBridge(); diff --git a/js/packages/truapi-host/src/worker-dispatch.test.ts b/js/packages/truapi-host/src/worker-dispatch.test.ts index 3b5ea55c8..4db8b52fd 100644 --- a/js/packages/truapi-host/src/worker-dispatch.test.ts +++ b/js/packages/truapi-host/src/worker-dispatch.test.ts @@ -58,28 +58,22 @@ describe("worker dispatch guards", () => { expect(messages).toEqual([]); }); - it("closes a chain connection when its WASM listener throws", () => { + it("closes a failed JSON-RPC listener without exposing its private response", () => { const messages: WorkerToMain[] = []; const listeners = new Map void>([ [ 11, () => { - throw new Error("panic"); + throw new Error("private-hop-ticket"); }, ], ]); - expect(() => - dispatchChainResponse(11, "{}", listeners, (msg) => messages.push(msg)), - ).not.toThrow(); + dispatchChainResponse(11, "{}", listeners, (msg) => messages.push(msg)); expect(listeners.has(11)).toBe(false); - expect(messages).toEqual([ - { kind: "chainClose", connId: 11 }, - { - kind: "disposeError", - error: "chain connection 11 callback failed: panic", - }, - ]); + expect(messages[0]).toEqual({ kind: "chainClose", connId: 11 }); + expect(messages[1]?.kind).toBe("disposeError"); + expect(JSON.stringify(messages)).not.toContain("private-hop-ticket"); }); }); diff --git a/js/packages/truapi-host/src/worker-dispatch.ts b/js/packages/truapi-host/src/worker-dispatch.ts index 8ac1396f9..a136ce698 100644 --- a/js/packages/truapi-host/src/worker-dispatch.ts +++ b/js/packages/truapi-host/src/worker-dispatch.ts @@ -63,9 +63,13 @@ export function dispatchChainResponse( if (!listener) return; try { listener(json); - } catch (err) { + } catch { listeners.delete(connId); postToMain({ kind: "chainClose", connId }); - reportDispatchFailure(postToMain, `chain connection ${connId}`, err); + // Response-handler errors may contain private HOP data. + postToMain({ + kind: "disposeError", + error: `JSON-RPC connection ${connId} callback failed`, + }); } } diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 698b2c71b..519353671 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -44,6 +44,9 @@ export type { SubscriptionName, } from "./generated/worker-callbacks.js"; +/** Shared cap includes connections still opening or closing during an open. */ +export const MAX_JSON_RPC_CONNECTIONS = 64; + /** * Positional arguments for a callback. The wasm core calls each callback * at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple. @@ -83,7 +86,7 @@ export type MainToWorker = // frames to it. Null in production, so the host tap stays inert. debuggerUrl: string | null; } - | { kind: "createCore"; coreId: number; product: unknown } + | { kind: "createCore"; coreId: number; product: unknown; capabilities?: OptionalCapabilities } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } | { kind: "frame"; coreId: number; bytes: Uint8Array } @@ -169,6 +172,7 @@ export type MainToWorker = | { kind: "chainConnectAck"; connId: number; ok: true } | { kind: "chainConnectAck"; connId: number; ok: false; error: string } | { kind: "chainResponse"; connId: number; json: string } + | { kind: "chainClosed"; connId: number } | { kind: "dispose" }; /** @@ -327,16 +331,24 @@ export type WorkerToMain = | { kind: "callbackRequest"; requestId: number; + coreId?: number; name: CallbackName; args: CallbackArgs; } | { kind: "subscriptionStart"; subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | null; } | { kind: "subscriptionStop"; subId: number } | { kind: "chainConnectStart"; connId: number; genesisHash: string } + | { + kind: "hopConnectStart"; + connId: number; + genesisHash: string; + endpoint: string; + } | { kind: "chainSend"; connId: number; request: string } | { kind: "chainClose"; connId: number }; diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index a3ad227e1..8af72ef28 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -9,6 +9,7 @@ import type { SubscriptionName, WorkerToMain, } from "./worker-protocol.js"; +import { MAX_JSON_RPC_CONNECTIONS } from "./worker-protocol.js"; import type { GenericError } from "@parity/truapi"; import { TRUAPI_CODEC_VERSION } from "@parity/truapi"; import { @@ -79,18 +80,27 @@ let nextConnId = 0; type ChainConnectAck = { ok: true } | { ok: false; error: string }; const chainConnectAcks = new Map void>(); const chainResponseListeners = new Map void>(); +const chainCloseListeners = new Map void) | undefined>(); +let connectionsDisposed = false; function callbackRequest( name: CallbackName, args: readonly unknown[], + coreId?: number, ): Promise { + if (connectionsDisposed) return Promise.reject(new Error("Host runtime is unavailable")); return new Promise((resolve, reject) => { const requestId = ++nextRequestId; pendingCallbacks.set(requestId, (r) => { if (r.ok) resolve(r.value); else reject(new Error(r.error)); }); - postToMain({ kind: "callbackRequest", requestId, name, args }); + try { + postToMain({ kind: "callbackRequest", requestId, name, args, ...(coreId === undefined ? {} : { coreId }) }); + } catch { + pendingCallbacks.delete(requestId); + reject(new Error("Host callback transport is unavailable")); + } }); } @@ -99,15 +109,26 @@ function startSubscription( payload: Uint8Array | null, sendItem: (value: T) => void, sendError: (error: GenericError) => void, + coreId?: number, ): () => void { + if (connectionsDisposed) { + sendError({ reason: "Host runtime is unavailable" }); + return () => {}; + } const subId = ++nextSubId; subscriptionListeners.set(subId, { sendItem: sendItem as (value: unknown) => void, sendError: (error) => sendError({ reason: error }), }); - postToMain({ kind: "subscriptionStart", subId, name, payload }); - return () => { + try { + postToMain({ kind: "subscriptionStart", subId, name, payload, ...(coreId === undefined ? {} : { coreId }) }); + } catch { subscriptionListeners.delete(subId); + sendError({ reason: "Host subscription transport is unavailable" }); + return () => {}; + } + return () => { + if (!subscriptionListeners.delete(subId)) return; postToMain({ kind: "subscriptionStop", subId }); }; } @@ -117,68 +138,101 @@ interface WorkerChainConnection { close(): void; } -/** - * Worker-side half of the host chain-connect bridge. - * - * The Rust core runs in this worker but owns no socket. When it needs chain - * access (chainHead v1 for dotNS identity on Asset Hub / statement-store SSO) it - * calls this; the actual transport lives on the host main thread and is reached - * over postMessage. The data crossing here is JSON-RPC strings, not SCALE: only - * the product<->core wire is SCALE. - * - * per-tab / sandboxed core-owned (this Web Worker) host-owned (main thread) - * +-------------------+ SCALE +--------------------------+ +--------------------------------+ - * | Product (iframe) |<------->| truapi-server WASM core | | host.connect() (ChainProvider) | - * | speaks TrUAPI | frames | chainHead v1, SSO, | | host-owned JSON-RPC transport | - * | never sees chains | | dotNS identity (AH) | | remote RPC, native client, ... | - * +-------------------+ +--------------------------+ +--------------------------------+ - * | ^ JSON-RPC strings (not SCALE) ^ | - * chainConnect() | | onResponse(json) connect | | responses() - * (this fn) v | | v - * worker-runtime.ts <======== postMessage ========> create-worker-host-runtime.ts - * chainConnectStart / chainSend / chainClose --> handleChainConnect* -> host.connect() - * chainConnectAck / chainResponse <-- (pumped from connection.responses()) - * - * Allocates a `connId`, posts `chainConnectStart`, and resolves a - * `{ send, close }` handle once the main thread acks. `send` posts `chainSend`, - * `close` posts `chainClose`, and every `chainResponse` for this `connId` is - * delivered to `onResponse`. - */ +/** Chain and HOP share ownership and JSON-RPC pumping, not dial authority. */ function chainConnect( genesisHash: string, onResponse: (json: string) => void, + onClosed?: () => void, +): Promise { + return connectRpc({ kind: "chainConnectStart", genesisHash }, onResponse, onClosed); +} + +function hopConnect( + genesisHash: string, + endpoint: string, + onResponse: (json: string) => void, + onClosed?: () => void, ): Promise { + return connectRpc({ kind: "hopConnectStart", genesisHash, endpoint }, onResponse, onClosed); +} + +function closeRpcConnection(connId: number, notify = true): void { + const ack = chainConnectAcks.get(connId); + const onClosed = chainCloseListeners.get(connId); + chainConnectAcks.delete(connId); + chainResponseListeners.delete(connId); + chainCloseListeners.delete(connId); + ack?.({ ok: false, error: "JSON-RPC connection closed before opening" }); + if (notify) { + try { + onClosed?.(); + } catch { + postToMain({ kind: "disposeError", error: "JSON-RPC close callback failed" }); + } + } +} + +function connectRpc( + start: + | { kind: "chainConnectStart"; genesisHash: string } + | { kind: "hopConnectStart"; genesisHash: string; endpoint: string }, + onResponse: (json: string) => void, + onClosed?: () => void, +): Promise { + if (connectionsDisposed || chainCloseListeners.size >= MAX_JSON_RPC_CONNECTIONS) { + return Promise.reject(new Error("JSON-RPC connections unavailable or limit reached")); + } const connId = ++nextConnId; - return new Promise((resolve, reject) => { - chainConnectAcks.set(connId, (ack) => { - if (!ack.ok) { - chainResponseListeners.delete(connId); - reject(new Error(ack.error)); - return; - } - resolve({ - send(request: string) { - postToMain({ kind: "chainSend", connId, request }); - }, - close() { - chainResponseListeners.delete(connId); - postToMain({ kind: "chainClose", connId }); - }, - }); + const { promise, resolve, reject } = Promise.withResolvers(); + chainConnectAcks.set(connId, (ack) => { + if (!ack.ok) { + chainResponseListeners.delete(connId); + chainCloseListeners.delete(connId); + reject(new Error(ack.error)); + return; + } + resolve({ + send(request: string) { + if (!chainCloseListeners.has(connId)) { + throw new Error("JSON-RPC connection is closed"); + } + postToMain({ kind: "chainSend", connId, request }); + }, + close() { + if (!chainCloseListeners.has(connId)) return; + closeRpcConnection(connId, false); + postToMain({ kind: "chainClose", connId }); + }, }); - chainResponseListeners.set(connId, onResponse); - postToMain({ kind: "chainConnectStart", connId, genesisHash }); }); + chainCloseListeners.set(connId, onClosed); + chainResponseListeners.set(connId, (json) => { + try { + onResponse(json); + } catch (err) { + closeRpcConnection(connId); + throw err; + } + }); + try { + postToMain({ ...start, connId }); + } catch (err) { + closeRpcConnection(connId, false); + reject(err); + } + return promise; } /** Build the host-level callback object passed to the WASM runtime. */ -function buildRawCallbacks(capabilities: OptionalCapabilities) { +function buildRawCallbacks(capabilities: OptionalCapabilities, coreId?: number) { return { ...createWorkerRawCallbacks( { - callbackRequest, - startSubscription, + callbackRequest: (name, args) => callbackRequest(name, args, coreId), + startSubscription: (name, payload, sendItem, sendError) => + startSubscription(name, payload, sendItem, sendError, coreId), chainConnect, + hopConnect, }, capabilities, ), @@ -772,6 +826,7 @@ ctx.addEventListener("message", (ev: MessageEvent) => { const core = runtime.productRuntime( msg.product, buildCoreCallbacks(msg.coreId), + msg.capabilities === undefined ? undefined : buildRawCallbacks(msg.capabilities, msg.coreId), ); cores.set(msg.coreId, core); postToMain({ kind: "coreReady", coreId: msg.coreId }); @@ -946,6 +1001,8 @@ ctx.addEventListener("message", (ev: MessageEvent) => { if (cb) { chainConnectAcks.delete(msg.connId); cb(msg.ok ? { ok: true } : { ok: false, error: msg.error }); + } else if (msg.ok) { + postToMain({ kind: "chainClose", connId: msg.connId }); } break; } @@ -958,6 +1015,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { ); break; } + case "chainClosed": + closeRpcConnection(msg.connId); + break; case "publishChatAction": handlePublishAction( CHAT_ACTION_ENTRY_POINT, @@ -1001,6 +1061,15 @@ ctx.addEventListener("message", (ev: MessageEvent) => { const disposing = runtime; runtime = null; identityAbort?.abort(new Error("runtime disposed")); + connectionsDisposed = true; + for (const settle of pendingCallbacks.values()) { + settle({ ok: false, error: "Host runtime is unavailable" }); + } + pendingCallbacks.clear(); + for (const connId of chainCloseListeners.keys()) { + closeRpcConnection(connId); + postToMain({ kind: "chainClose", connId }); + } void (async () => { try { if (disposing && isSigningRuntime(disposing)) diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 96ffd9740..6300a146e 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, jest } from "bun:test"; import { createTransport, RequestTimeoutError } from "./client.js"; import * as S from "./scale.js"; import { str, type CallErrorValue } from "./scale.js"; -import { createClient, SubscriptionError } from "./generated/client.js"; +import { createClient, SubscriptionError, TRUAPI_CODEC_VERSION } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; import { @@ -253,26 +253,6 @@ describe("generated client transport", () => { expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); }); - it("uses the transport codec version for generated handshake calls", () => { - const fixture = providerFixture(); - const transport = createTransport(fixture.provider); - const client = createClient(transport); - - void client.system.handshake(); - - const expectedPayload = T.VersionedHostHandshakeRequest.enc({ - tag: "V1", - value: { codecVersion: 2 }, - }); - const expectedFrame = new Uint8Array(str.enc("p:1").length + 3 + expectedPayload.length); - expectedFrame.set(str.enc("p:1"), 0); - expectedFrame[str.enc("p:1").length] = 1; // system trait - expectedFrame[str.enc("p:1").length + 1] = 0; // handshake - expectedFrame[str.enc("p:1").length + 2] = MESSAGE_TYPE_REQUEST; - expectedFrame.set(expectedPayload, str.enc("p:1").length + 3); - - expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); - }); it("resolves a request from its versioned response envelope", async () => { const fixture = providerFixture(); @@ -618,28 +598,6 @@ describe("generated client transport", () => { expect(subscriptionFixture.sent).toHaveLength(2); }); - it("logs a protocol violation for a known pair's out-of-range message type", () => { - const fixture = providerFixture(); - createTransport(fixture.provider); - - const warnings: unknown[][] = []; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(args); - }; - try { - fixture.receive(wireFrame("unrelated:1", W.LOCAL_STORAGE_READ, 99)); - } finally { - console.warn = originalWarn; - } - - expect(fixture.sent).toHaveLength(0); - expect( - warnings.some((args) => - String(args[0]).includes("unexpected messageType 99"), - ), - ).toBe(true); - }); it("auto-responds to an inbound handshake with the versioned-result shape", () => { const fixture = providerFixture(); @@ -647,7 +605,7 @@ describe("generated client transport", () => { const requestPayload = T.VersionedHostHandshakeRequest.enc({ tag: "V1", - value: { codecVersion: 2 }, + value: { codecVersion: TRUAPI_CODEC_VERSION }, }); const requestFrame = wireFrame( "h:1", diff --git a/package-lock.json b/package-lock.json index 7027e885b..beb4d87f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "js/packages/truapi-host": { "name": "@parity/truapi-host", "version": "0.17.0", - "license": "MIT", + "license": "MIT AND AGPL-3.0-only", "dependencies": { "@parity/truapi": "^0.17.0" }, diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index afc66d96b..580816278 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c587cc31e1b00844"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0931d05042135353"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -239,7 +239,7 @@ impl AccountProductDeviceChat { required_execution: None, wire: MethodWire::Request(MethodIds { trait_id: 2, - method_id: 11, + method_id: 12, }), }; } diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs index 44080579d..a669d8115 100644 --- a/rust/crates/truapi-client/src/lib.rs +++ b/rust/crates/truapi-client/src/lib.rs @@ -476,6 +476,64 @@ mod tests { ); } + #[test] + fn native_chat_uses_method_twelve_with_v1_envelopes() { + use truapi::{v02 as dto, versioned::account}; + + let request = account::HostProductDeviceChatRequest::V1( + dto::HostProductDeviceChatRequest::Initialize, + ); + assert_eq!( + encode_request::("p:1", &request), + [12, b'p', b':', b'1', 2, 12, 0, 0, 0], + ); + + let response = + account::HostProductDeviceChatResponse::V1(dto::HostProductDeviceChatResponse { + device: dto::HostNativeChatDevice { + identity_account_id: [1; 32], + identity_chat_public_key: [2; 32], + product_account: v01::ProductAccountId { + dot_ns_identifier: "chat.dot".into(), + derivation_index: v01::DerivationIndex::Index(0), + }, + account_id: [3; 32], + chat_public_key: [4; 32], + }, + peers: vec![], + invitations: vec![], + messages: vec![], + acknowledgments: vec![], + payments: vec![], + rich_messages: vec![], + }); + let mut frame = vec![12, b'p', b':', b'1', 2, 12, 1, 0]; + response.encode_to(&mut frame); + assert_eq!(frame[8], 0, "response envelope is V1"); + assert_eq!( + decode_response::(&frame), + Ok(Decoded { + request_id: "p:1".into(), + value: Ok(response), + }), + ); + + frame[5] = 11; + assert_eq!( + decode_response::(&frame), + Err(DecodeError::UnexpectedMethod { + expected: MethodIds { + trait_id: 2, + method_id: 12, + }, + actual: MethodIds { + trait_id: 2, + method_id: 11, + }, + }), + ); + } + #[test] fn worker_serves_unified_renderer() { use truapi::versioned::renderer::{ diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index b908cc083..dd8fc35ce 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -45,6 +45,10 @@ struct Cli { client_version: Option, /// Wire codec version for generated handshake calls. + /// + /// Defaults to the Host's authoritative `truapi::WIRE_CODEC_VERSION`. + /// Override only for intentional historical generation; normal client and + /// Host generation must use the same constant. #[arg(long, default_value_t = truapi::WIRE_CODEC_VERSION)] codec_version: u8, diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs index 559576cfc..33edb8f5a 100644 --- a/rust/crates/truapi-codegen/src/platform_callbacks.rs +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -5,6 +5,19 @@ use std::collections::BTreeSet; use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; use crate::rustdoc::{TypeDef, TypeDefKind, TypeRef, VariantFields}; +/// Compound callback results with inline SCALE codecs shared by both bridges. +/// Byte vectors themselves remain unencoded byte payloads. +pub(crate) fn is_scale_vector_result(ty: &TypeRef) -> bool { + matches!( + ty, + TypeRef::Vec(inner) + if matches!(inner.as_ref(), TypeRef::Array(element, _) + if matches!(element.as_ref(), TypeRef::Primitive(name) if name == "u8")) + || matches!(inner.as_ref(), TypeRef::Primitive(name) if name == "str") + || matches!(inner.as_ref(), TypeRef::Named { args, .. } if args.is_empty()) + ) +} + /// Traits the platform surface actually composes: the super trait's /// constituents when one exists, otherwise every collected trait. pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { @@ -63,6 +76,11 @@ pub(crate) fn raw_callback_wire_name( method: &PlatformMethod, platform_trait_names: &BTreeSet, ) -> String { + // The public Rust method spells out its capability; the established raw + // connection bridge uses the same namespace-first shape as chainConnect. + if trait_def.name == "HopProvider" && method.name == "connect_hop" { + return "hopConnect".to_string(); + } let raw = raw_callback_name(method); if trait_object_return_name(method, platform_trait_names).is_some() { return format!( diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 491e612ea..1772975ec 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -6,9 +6,9 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; use crate::platform_callbacks::{ - callback_namespace, collect_local_bridge_payload_types, composed_traits, optional_trait_names, - platform_trait_names, raw_callback_field_name, raw_callback_name, raw_callback_wire_name, - snake_case, stream_item, trait_object_return_name, + callback_namespace, collect_local_bridge_payload_types, composed_traits, + is_scale_vector_result, optional_trait_names, platform_trait_names, raw_callback_field_name, + raw_callback_name, raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, }; use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; @@ -40,7 +40,7 @@ pub fn generate_wasm_bridge( use super::{{ WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, missing_callback, parse_optional_bytes_item, + invoke_optional_bytes_return, invoke_optional_string_return, invoke_unit, missing_callback, parse_optional_bytes_item, }}; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -327,6 +327,11 @@ fn emit_result_method( &bridge_call("invoke_bytes_return", &method.name, &args, &[]), &map_err, ) + } else if matches!(ok, TypeRef::Option(inner) if is_string(inner)) { + await_chain( + &bridge_call("invoke_optional_string_return", &method.name, &args, &[]), + &map_err, + ) } else if is_optional_bytes(ok) { await_chain( &bridge_call( @@ -340,7 +345,7 @@ fn emit_result_method( ), &map_err, ) - } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) || is_scale_vector_result(ok) { formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? } else { bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); @@ -610,6 +615,7 @@ fn numeric_js_arg(name: &str, primitive: &str) -> Result { "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { Ok(format!("JsValue::from_f64(f64::from({name}))")) } + "u64" | "i64" => Ok(format!("js_sys::BigInt::from({name}).into()")), "bool" => Ok(format!("JsValue::from_bool({name})")), other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), } diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 9f05b8d2b..ffb03973d 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -19,9 +19,10 @@ use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; use crate::platform_callbacks::{ - callback_namespace, collect_local_bridge_payload_types, composed_traits, optional_trait_names, - platform_trait_names, raw_callback_adapter_name, raw_callback_name, raw_callback_type_name, - raw_callback_wire_name, stream_item, to_camel_case, trait_object_return_name, + callback_namespace, collect_local_bridge_payload_types, composed_traits, + is_scale_vector_result, optional_trait_names, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, }; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -201,6 +202,19 @@ fn emit_wasm_adapter( let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); let mut runtime_types: BTreeSet = BTreeSet::new(); let mut support_imports: BTreeSet = BTreeSet::new(); + let mut result_codecs = Vec::new(); + if traits + .iter() + .any(|trait_def| trait_def.name == "HopProvider") + { + support_imports.insert("unavailableHopProvider".to_string()); + } + if traits + .iter() + .any(|trait_def| trait_def.name == "NativeChatFilesHost") + { + support_imports.insert("unavailableNativeChatFilesHost".to_string()); + } for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -219,6 +233,13 @@ fn emit_wasm_adapter( } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + if is_scale_vector_result(ok) { + result_codecs.push(format!( + "const {}ResultCodec = {};", + raw_callback_name(method), + local_codec_expr(ok)?, + )); + } collect_codec_imports(ok, codec_types, &mut imports); collect_local_codec_names( ok, @@ -257,6 +278,9 @@ fn emit_wasm_adapter( "#, ) .unwrap(); + if !result_codecs.is_empty() { + out.push_str("import * as S from \"@parity/truapi/scale\";\n"); + } emit_import_block(&mut out, false, "@parity/truapi", &imports); emit_import_block(&mut out, true, "@parity/truapi", &extra_types); emit_import_block( @@ -280,6 +304,12 @@ fn emit_wasm_adapter( if !runtime_types.is_empty() || !support_imports.is_empty() { out.push('\n'); } + for codec in &result_codecs { + writeln!(out, "{codec}").unwrap(); + } + if !result_codecs.is_empty() { + out.push('\n'); + } let optional_traits = optional_trait_names(definition); out.push_str(&emit_raw_callbacks( &traits, @@ -305,6 +335,20 @@ fn emit_wasm_adapter( let namespace = callback_namespace(name); writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(); } + // HOP remains a required Rust capability. Older JS embeddings get its + // explicit unavailable implementation, not a phantom working transport. + if traits + .iter() + .any(|trait_def| trait_def.name == "HopProvider") + { + out.push_str(" const hop = callbacks.hop ?? unavailableHopProvider;\n"); + } + if traits + .iter() + .any(|trait_def| trait_def.name == "NativeChatFilesHost") + { + out.push_str(" const nativeChatFiles = callbacks.nativeChatFiles ?? unavailableNativeChatFilesHost;\n"); + } out.push_str(" return {\n"); for trait_def in &traits { let optional = optional_traits.contains(&trait_def.name); @@ -938,6 +982,7 @@ fn raw_primitive_ts(p: &str) -> String { match p { "bool" => "boolean".to_string(), "str" => "string".to_string(), + "u64" | "i64" | "u128" | "i128" => "bigint".to_string(), _ => "number".to_string(), } } @@ -987,8 +1032,8 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } /// The call argument expression for one Rust param. Codec types arrive as -/// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and -/// are widened to `bigint`; everything else passes through. Arrow parameter +/// `Uint8Array` and are decoded; wide integers cross as lossless JS `bigint`; +/// everything else passes through. Arrow parameter /// types are left to contextual inference from `RawCallbacks`, so only the /// argument expression varies. fn adapter_arg( @@ -1003,9 +1048,6 @@ fn adapter_arg( { format!("{ty}.dec({name})") } - TypeRef::Primitive(p) if matches!(p.as_str(), "u64" | "u128" | "i64" | "i128") => { - format!("BigInt({name})") - } _ => name, } } @@ -1036,17 +1078,27 @@ fn emit_adapter_entry( let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); let namespace = callback_namespace(&trait_def.name); // Optional capabilities are hoisted into a local binding by the caller. - let host_method = if optional { - format!("{namespace}.{raw}") + let host_method = if optional + || matches!( + trait_def.name.as_str(), + "HopProvider" | "NativeChatFilesHost" + ) { + format!("{namespace}.{}", raw_callback_name(method)) } else { - format!("callbacks.{namespace}.{raw}") + format!("callbacks.{namespace}.{}", raw_callback_name(method)) }; if trait_object_return_name(method, platform_trait_names).is_some() { let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); - return Ok(format!( - "{raw}: {adapter}(callbacks.{}),", - callback_namespace(&trait_def.name) - )); + let host = if optional + || matches!( + trait_def.name.as_str(), + "HopProvider" | "NativeChatFilesHost" + ) { + namespace + } else { + format!("callbacks.{namespace}") + }; + return Ok(format!("{raw}: {adapter}({host}),")); } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { @@ -1087,8 +1139,14 @@ fn validate_adapter_codec_boundary( match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + // Vector results have an inline SCALE codec. Validate their + // element as a direct codec rather than a nested container. + let value = match ok { + TypeRef::Vec(inner) if is_scale_vector_result(ok) => inner.as_ref(), + other => other, + }; validate_adapter_codec_boundary_type( - ok, + value, codec_types, local_codec_types, "return value", @@ -1125,11 +1183,9 @@ fn validate_adapter_codec_boundary_type( Ok(()) } -/// The WASM adapter only knows how to translate a direct codec payload: -/// `Codec` maps to raw `Uint8Array` and the adapter emits `Codec.dec/enc`. -/// Containers such as `Vec`, `Option`, or `(Codec, ...)` would -/// still be declared as raw bytes at the WASM boundary, but no generated code -/// knows how to encode or decode the container. Reject those shapes at codegen. +/// Named codec payload parameters must cross directly. Vector results use an +/// emitted inline SCALE codec and validate their elements separately; other +/// containers of named codecs remain unsupported. fn contains_non_direct_codec_type( ty: &TypeRef, codec_types: &BTreeSet, @@ -1186,6 +1242,9 @@ fn adapter_unary_impl( { format!("{ty}.enc(await {call})") } + ty if is_scale_vector_result(ty) => { + format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) + } _ => format!("await {call}"), }; Ok(format!("async ({params}) => {body}")) @@ -1603,8 +1662,11 @@ fn emit_host_callback_composites( } // A host may leave out an optional capability entirely; the core then // answers the matching product calls with `Unsupported`. + // Required Rust capabilities with explicit unavailable embedding backends. let mark = |trait_name: &String| { - if optional_traits.contains(trait_name) { + if optional_traits.contains(trait_name) + || matches!(trait_name.as_str(), "HopProvider" | "NativeChatFilesHost") + { "?" } else { "" @@ -1897,49 +1959,23 @@ mod tests { } } - fn assert_rejects_compound_codec(method: PlatformMethod, expected: &str) { + fn assert_rejects_compound_codec(method: PlatformMethod) { let definition = platform_with_method(method); - let err = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()) - .expect_err("compound codec boundary should fail codegen") - .to_string(); - assert!( - err.contains("unsupported compound codec type"), - "unexpected error: {err}" - ); - assert!(err.contains(expected), "unexpected error: {err}"); + assert!(emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).is_err()); } #[test] - fn wasm_adapter_rejects_compound_codec_return_shapes() { + fn wasm_adapter_rejects_unsupported_compound_codec_return_shapes() { let codec = named("HostFeatureSupportedResponse"); - assert_rejects_compound_codec( - method_with_return(TypeRef::Vec(Box::new(codec.clone()))), - "return value", - ); - assert_rejects_compound_codec( - method_with_return(TypeRef::Option(Box::new(codec.clone()))), - "return value", - ); - assert_rejects_compound_codec( - method_with_return(TypeRef::Tuple(vec![codec])), - "return value", - ); + assert_rejects_compound_codec(method_with_return(TypeRef::Option(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_return(TypeRef::Tuple(vec![codec]))); } #[test] fn wasm_adapter_rejects_compound_codec_param_shapes() { let codec = named("HostFeatureSupportedRequest"); - assert_rejects_compound_codec( - method_with_param(TypeRef::Vec(Box::new(codec.clone()))), - "parameter `request`", - ); - assert_rejects_compound_codec( - method_with_param(TypeRef::Option(Box::new(codec.clone()))), - "parameter `request`", - ); - assert_rejects_compound_codec( - method_with_param(TypeRef::Tuple(vec![codec])), - "parameter `request`", - ); + assert_rejects_compound_codec(method_with_param(TypeRef::Vec(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_param(TypeRef::Option(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_param(TypeRef::Tuple(vec![codec]))); } } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index b130e641d..c1a62a79d 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -5,6 +5,7 @@ // platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, // primitives and byte blobs pass through unchanged. +import * as S from "@parity/truapi/scale"; import { HostChatCreateRoomRequest, HostChatCreateRoomResponse, @@ -32,13 +33,26 @@ import { CoreStorageKey, DevicePermissionStatus, HostChainSet, + NativeChatFileExportRequest, + NativeChatFilePickRequest, + NativeChatPickedFile, ProductContext, UserConfirmationReview, } from "./host-callbacks.js"; import type { RequiredHostCallbacks } from "./host-callbacks.js"; -import type { ChainConnect } from "../runtime.js"; -import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; +import type { ChainConnect, HopConnect } from "../runtime.js"; +import { + chainConnectAdapter, + driveResultStream, + hopConnectAdapter, + unavailableHopProvider, + unavailableNativeChatFilesHost, +} from "../adapter-support.js"; + +const allowedHopEndpointsResultCodec = S.Vector(S.str); +const identityUsernameCandidatesResultCodec = S.Vector(S.Bytes(32)); +const pickChatFilesResultCodec = S.Vector(NativeChatPickedFile); /** * Byte-oriented callback surface the WASM core invokes. Members of an @@ -70,10 +84,31 @@ export interface RawCallbacks { clearCoreStorage(key: Uint8Array): Promise; featureSupported(request: Uint8Array): Promise; supportedChains(): Promise; + allowedHopEndpoints(bulletinGenesisHash: Uint8Array): Promise; + hopConnect: HopConnect; + identityUsernameCandidates?( + username: string, + peopleChainGenesisHash: Uint8Array, + ): Promise; subscribeLocale( sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void, ): (() => void) | void; + pickChatFiles(request: Uint8Array): Promise; + readChatFile( + sourceId: string, + offset: bigint, + length: number, + ): Promise; + releaseChatFile(sourceId: string): Promise; + beginChatFileExport(request: Uint8Array): Promise; + writeChatFileExport( + exportId: string, + offset: bigint, + data: Uint8Array, + ): Promise; + finishChatFileExport(exportId: string): Promise; + cancelChatFileExport(exportId: string): Promise; navigateTo(url: string): Promise; pushNotification(notification: Uint8Array): Promise; cancelNotification(id: NotificationId): Promise; @@ -106,8 +141,12 @@ export function createWasmRawCallbacks( callbacks: RequiredHostCallbacks, ): RawCallbacks { const chat = callbacks.chat; + const identityBackend = callbacks.identityBackend; const permissionStatus = callbacks.permissionStatus; const pocket = callbacks.pocket; + const hop = callbacks.hop ?? unavailableHopProvider; + const nativeChatFiles = + callbacks.nativeChatFiles ?? unavailableNativeChatFilesHost; return { authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), @@ -160,12 +199,51 @@ export function createWasmRawCallbacks( ), supportedChains: async () => HostChainSet.enc(await callbacks.features.supportedChains()), + allowedHopEndpoints: async (bulletinGenesisHash) => + allowedHopEndpointsResultCodec.enc( + await hop.allowedHopEndpoints(bulletinGenesisHash), + ), + hopConnect: hopConnectAdapter(hop), + ...(identityBackend + ? { + identityUsernameCandidates: async ( + username, + peopleChainGenesisHash, + ) => + identityUsernameCandidatesResultCodec.enc( + await identityBackend.identityUsernameCandidates( + username, + peopleChainGenesisHash, + ), + ), + } + : {}), subscribeLocale: (sendItem, sendError) => driveResultStream( callbacks.locale.subscribeLocale(), (item) => sendItem(HostLocaleSubscribeItem.enc(item)), sendError, ), + pickChatFiles: async (request) => + pickChatFilesResultCodec.enc( + await nativeChatFiles.pickChatFiles( + NativeChatFilePickRequest.dec(request), + ), + ), + readChatFile: async (sourceId, offset, length) => + await nativeChatFiles.readChatFile(sourceId, offset, length), + releaseChatFile: async (sourceId) => + await nativeChatFiles.releaseChatFile(sourceId), + beginChatFileExport: async (request) => + await nativeChatFiles.beginChatFileExport( + NativeChatFileExportRequest.dec(request), + ), + writeChatFileExport: async (exportId, offset, data) => + await nativeChatFiles.writeChatFileExport(exportId, offset, data), + finishChatFileExport: async (exportId) => + await nativeChatFiles.finishChatFileExport(exportId), + cancelChatFileExport: async (exportId) => + await nativeChatFiles.cancelChatFileExport(exportId), navigateTo: async (url) => await callbacks.navigation.navigateTo(url), pushNotification: async (notification) => HostPushNotificationResponse.enc( diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 9137d8c50..09a571f5e 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -13,6 +13,7 @@ import { DerivationIndex, HostAccountSignVrfRequest, HostDevicePermissionRequest, + HostNativeChatAttachmentMetadata, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, @@ -214,7 +215,52 @@ export type CoreStorageKey = * core honours it for a bounded lifetime, which is what makes a revoked * trust grant eventually take effect. */ - | { tag: "ProductManifest"; value: { productId: string } }; + | { tag: "ProductManifest"; value: { productId: string } } + /** + * Encrypted main-purse inventory, operation journal, and claim recovery state. + * + * This slot is wallet-owned, not product-owned, and must survive clearing a + * product's data. Writes replace the entire value atomically. + */ + | { + tag: "MainPurseCoinage"; + value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; + } + /** + * Encrypted Host-owned native Chat device, peer roster, and migration state. + */ + | { + tag: "NativeChatDevice"; + value: { + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; + productId: string; + }; + } + /** + * One encrypted, bounded native Chat attachment cache chunk. + * Values use the Chat state's wallet-bound authenticated encryption. + */ + | { + tag: "NativeChatFileChunk"; + value: { + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; + productId: string; + attachmentId: Uint8Array; + chunkIndex: number; + }; + } + /** + * Previously initialized Chat products to restore after this wallet unlocks. + * + * This is a host-private wallet-owned index, not a permission grant. The + * core rechecks each product's current grants before restoring reception. + */ + | { + tag: "NativeChatProducts"; + value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; + }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -315,6 +361,119 @@ export interface IdentityDisclosureReview { */ export type LoginFailureKind = "NoFreeAllowanceSlots" | "Other"; +/** + * Exact Host-resolved payment reviewed before debiting the user's main purse. + * + * This review never grants a reusable spending permission. Chat authority and + * automatic product signing do not authorize it. + */ +export interface MainPurseChatPaymentReview { + /** + * Authenticated product requesting this payment. + */ + callingProductId: string; + + /** + * Recipient identity authenticated by the Host's native Chat session. + */ + recipientIdentity: Uint8Array; + + /** + * Host-resolved username for that identity, never a product display label. + */ + recipientUsername?: string; + + /** + * Exact recipient amount in cents of the selected Coinage asset. + */ + amountCents: bigint; + + /** + * Maximum main-purse debit, including any approved fee, in the same cents. + */ + maxDebitCents: bigint; + + /** + * Genesis hash of the Host-selected Coinage chain. + */ + genesisHash: Uint8Array; + + /** + * Trusted Coinage asset instance; `undefined` denotes a legacy single-asset runtime. + */ + coinageInstanceId?: number; + + /** + * Immutable, wallet-scoped payment operation being authorized. + */ + operationId: Uint8Array; +} + +/** + * Trusted context for exporting a verified native Chat attachment. + */ +export interface NativeChatFileExportRequest { + /** + * Authenticated product requesting presentation. + */ + productId: string; + + /** + * Host-authenticated peer identity. + */ + peerIdentity: Uint8Array; + + /** + * Host-resolved peer name, if available. + */ + peerUsername?: string; + + /** + * Verified attachment metadata, including the exact export size. + */ + metadata: HostNativeChatAttachmentMetadata; +} + +/** + * Trusted native Chat selection context; never passed to a product. + */ +export interface NativeChatFilePickRequest { + /** + * Authenticated product requesting selection. + */ + productId: string; + + /** + * Host-authenticated recipient identity. + */ + peerIdentity: Uint8Array; + + /** + * Host-resolved recipient name, if available. + */ + peerUsername?: string; + + /** + * Maximum number of files the Host can accept. + */ + maxFiles: number; +} + +/** + * Immutable Host-owned source and metadata derived from its actual bytes. + */ +export interface NativeChatPickedFile { + /** + * Opaque private handle surviving restart until explicitly released. + */ + sourceId: string; + + /** + * Actual source size and native media metadata. + */ + metadata: HostNativeChatAttachmentMetadata; +} + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -601,7 +760,11 @@ export type UserConfirmationReview = /** * Allow a product to bind and use wallet-held Chat identity authority. */ - | { tag: "ChatAuthority"; value: ChatAuthorityReview }; + | { tag: "ChatAuthority"; value: ChatAuthorityReview } + /** + * Confirm this exact main-purse payment; never eligible for auto-approval. + */ + | { tag: "MainPurseChatPayment"; value: MainPurseChatPaymentReview }; /** * Review shown before a product asks to access another product account. @@ -701,6 +864,36 @@ export const CoreStorageKey: S.Codec = S.lazy( ProductManifest: S.Struct({ productId: S.str }) as S.Codec<{ productId: string; }>, + MainPurseCoinage: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, + NativeChatDevice: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + productId: S.str, + }) as S.Codec<{ + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; + productId: string; + }>, + NativeChatFileChunk: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + productId: S.str, + attachmentId: S.Bytes(32), + chunkIndex: S.u32, + }) as S.Codec<{ + rootPublicKey: Uint8Array; + genesisHash: Uint8Array; + productId: string; + attachmentId: Uint8Array; + chunkIndex: number; + }>, + NativeChatProducts: S.Struct({ + rootPublicKey: S.Bytes(32), + genesisHash: S.Bytes(32), + }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, }), ); @@ -781,6 +974,66 @@ export const LoginFailureKind: S.Codec = S.lazy( (): S.Codec => S.Status("NoFreeAllowanceSlots", "Other"), ); +/** + * Exact Host-resolved payment reviewed before debiting the user's main purse. + * + * This review never grants a reusable spending permission. Chat authority and + * automatic product signing do not authorize it. + */ +export const MainPurseChatPaymentReview: S.Codec = + S.lazy( + (): S.Codec => + S.Struct({ + callingProductId: S.str, + recipientIdentity: S.Bytes(32), + recipientUsername: S.Option(S.str), + amountCents: S.u64, + maxDebitCents: S.u64, + genesisHash: S.Bytes(32), + coinageInstanceId: S.Option(S.u32), + operationId: S.Bytes(32), + }) as S.Codec, + ); + +/** + * Trusted context for exporting a verified native Chat attachment. + */ +export const NativeChatFileExportRequest: S.Codec = + S.lazy( + (): S.Codec => + S.Struct({ + productId: S.str, + peerIdentity: S.Bytes(32), + peerUsername: S.Option(S.str), + metadata: HostNativeChatAttachmentMetadata, + }) as S.Codec, + ); + +/** + * Trusted native Chat selection context; never passed to a product. + */ +export const NativeChatFilePickRequest: S.Codec = + S.lazy( + (): S.Codec => + S.Struct({ + productId: S.str, + peerIdentity: S.Bytes(32), + peerUsername: S.Option(S.str), + maxFiles: S.u32, + }) as S.Codec, + ); + +/** + * Immutable Host-owned source and metadata derived from its actual bytes. + */ +export const NativeChatPickedFile: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + sourceId: S.str, + metadata: HostNativeChatAttachmentMetadata, + }) as S.Codec, +); + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -967,6 +1220,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( SignVrf: SignVrfReview, ProductSubtree: ProductSubtreeReview, ChatAuthority: ChatAuthorityReview, + MainPurseChatPayment: MainPurseChatPaymentReview, }), ); @@ -1167,10 +1421,10 @@ export interface CoreAdmin { * they accumulate for the life of the install. * * `describe_core_storage_key` names the product owning a slot: - * `CoreStorageKeyDescription::product_id` is `Some` exactly for the - * product-indexed variants, which are `PermissionAuthorization`, - * `AutoSigningKey`, and `ProductSubtree`. Keying host storage by that value - * makes the sweep a prefix delete rather than a scan. + * `CoreStorageKeyDescription::product_id` is `Some` for product-indexed + * slots, including the encrypted attachment chunk cache. Wallet-owned state + * remains outside that sweep. Keying host storage by this metadata makes + * product removal a prefix delete rather than a scan. */ export interface CoreStorage { /** @@ -1209,7 +1463,48 @@ export interface Features { } /** - * A live JSON-RPC connection to a chain. + * Host-private HOP transport for the configured Bulletin chain. + * + * Endpoints are trusted host configuration, never product-supplied dialing + * instructions. Implementations must re-read their allowlist and enforce exact + * URL membership before opening a connection. An unconfigured host is + * explicitly unavailable; it must not fall back to a chain RPC endpoint. + */ +export interface HopProvider { + /** + * Current exact WSS endpoints from the host's trusted Bulletin registry. + */ + allowedHopEndpoints?(bulletinGenesisHash: Uint8Array): Promise>; + + /** + * Open one private HOP connection, sharing the JSON-RPC lifecycle only. + * The caller closes the returned lease when the private operation ends. + */ + connectHop?( + bulletinGenesisHash: Uint8Array, + endpoint: string, + ): Promise; +} + +/** + * Optional host-authenticated username candidate source. The host owns backend + * configuration and credentials. Candidates are never ownership assertions: + * the core checks finalized dotNS ownership and the canonical People Chat key. + */ +export interface IdentityBackendHost { + /** + * Return exact-name candidates on the specified People network. Reject + * unavailable configuration/authentication and incomplete search results. + * Neither a guest URL nor a backend display label crosses this boundary. + */ + identityUsernameCandidates( + username: string, + peopleChainGenesisHash: Uint8Array, + ): Promise>; +} + +/** + * A live JSON-RPC connection to a host-selected service. */ export interface JsonRpcConnection { /** @@ -1243,6 +1538,66 @@ export interface LocaleHost { >; } +/** + * Host-private native Chat selection, immutable custody and safe export. + * + * Handles, file bytes and destinations must never be exposed to products. + * Optional embedders without a backend must fail explicitly as unavailable. + */ +export interface NativeChatFilesHost { + /** + * Present trusted selection and durably snapshot the selected files. + * An empty result denotes user cancellation, not an unavailable backend. + */ + pickChatFiles( + request: NativeChatFilePickRequest, + ): Promise>; + + /** + * Read exactly `length` bytes from an immutable source. Reject lengths + * above 2,000,000 and checked ranges extending beyond its actual u32 size. + */ + readChatFile( + sourceId: string, + offset: bigint, + length: number, + ): Promise; + + /** + * Release durable source custody. Repeated release is harmless. + */ + releaseChatFile(sourceId: string): Promise; + + /** + * Present trusted export consent and create a private partial output. + * ``undefined`` denotes user cancellation. Never overwrite without consent. + */ + beginChatFileExport( + request: NativeChatFileExportRequest, + ): Promise; + + /** + * Append a bounded chunk at the exact current offset; never allow holes, + * rewrites, or bytes beyond the declared export size. + */ + writeChatFileExport( + exportId: string, + offset: bigint, + data: Uint8Array, + ): Promise; + + /** + * Publish only an exact-size completed output through safe native UI. + * Never automatically execute HTML, SVG, scripts, or other active files. + */ + finishChatFileExport(exportId: string): Promise; + + /** + * Idempotently discard a partial output, never a completed user export. + */ + cancelChatFileExport(exportId: string): Promise; +} + /** * Open URLs in the system browser. Input is already trimmed, categorized, * and (where needed) normalized by the core; the host implementation only @@ -1443,12 +1798,15 @@ export interface HostCallbacks { productStorage: ProductStorage; coreStorage: CoreStorage; chain: ChainProvider; + hop?: HopProvider; + nativeChatFiles?: NativeChatFilesHost; auth: AuthPresenter; userConfirmation: UserConfirmation; theme: ThemeHost; locale: LocaleHost; preimage: PreimageHost; chat?: ChatPlatform; + identityBackend?: IdentityBackendHost; permissionStatus?: PermissionStatusHost; pocket?: PocketPlatform; } @@ -1461,12 +1819,15 @@ export interface RequiredHostCallbacks { productStorage: Required; coreStorage: Required; chain: Required; + hop?: Required; + nativeChatFiles?: Required; auth: Required; userConfirmation: Required; theme: Required; locale: Required; preimage: Required; chat?: Required; + identityBackend?: Required; permissionStatus?: Required; pocket?: Required; } diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 2568e8f90..fd51dbb76 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -13,7 +13,8 @@ use wasm_bindgen::JsValue; use super::{ WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, missing_callback, parse_optional_bytes_item, + invoke_optional_bytes_return, invoke_optional_string_return, invoke_unit, missing_callback, + parse_optional_bytes_item, }; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -37,7 +38,17 @@ pub(super) struct JsBridge { pub(super) clear_core_storage: Function, pub(super) feature_supported: Function, pub(super) supported_chains: Function, + pub(super) allowed_hop_endpoints: Function, + pub(super) hop_connect: Function, + pub(super) identity_username_candidates: Function, pub(super) subscribe_locale: Function, + pub(super) pick_chat_files: Function, + pub(super) read_chat_file: Function, + pub(super) release_chat_file: Function, + pub(super) begin_chat_file_export: Function, + pub(super) write_chat_file_export: Function, + pub(super) finish_chat_file_export: Function, + pub(super) cancel_chat_file_export: Function, pub(super) navigate_to: Function, pub(super) push_notification: Function, pub(super) cancel_notification: Function, @@ -53,6 +64,7 @@ pub(super) struct JsBridge { pub(super) subscribe_theme: Function, pub(super) confirm_user_action: Function, pub(super) chat_present: bool, + pub(super) identity_backend_present: bool, pub(super) permission_status_present: bool, pub(super) pocket_present: bool, } @@ -75,7 +87,21 @@ impl JsBridge { clear_core_storage: get_function(callbacks, "clearCoreStorage")?, feature_supported: get_function(callbacks, "featureSupported")?, supported_chains: get_function(callbacks, "supportedChains")?, + allowed_hop_endpoints: get_function(callbacks, "allowedHopEndpoints")?, + hop_connect: get_function(callbacks, "hopConnect")?, + identity_username_candidates: get_optional_function( + callbacks, + "identityUsernameCandidates", + )? + .unwrap_or_else(|| missing_callback("identityUsernameCandidates")), subscribe_locale: get_function(callbacks, "subscribeLocale")?, + pick_chat_files: get_function(callbacks, "pickChatFiles")?, + read_chat_file: get_function(callbacks, "readChatFile")?, + release_chat_file: get_function(callbacks, "releaseChatFile")?, + begin_chat_file_export: get_function(callbacks, "beginChatFileExport")?, + write_chat_file_export: get_function(callbacks, "writeChatFileExport")?, + finish_chat_file_export: get_function(callbacks, "finishChatFileExport")?, + cancel_chat_file_export: get_function(callbacks, "cancelChatFileExport")?, navigate_to: get_function(callbacks, "navigateTo")?, push_notification: get_function(callbacks, "pushNotification")?, cancel_notification: get_function(callbacks, "cancelNotification")?, @@ -97,6 +123,11 @@ impl JsBridge { && get_optional_function(callbacks, "registerChatBot")?.is_some() && get_optional_function(callbacks, "postChatMessage")?.is_some() && get_optional_function(callbacks, "subscribeChatRooms")?.is_some(), + identity_backend_present: get_optional_function( + callbacks, + "identityUsernameCandidates", + )? + .is_some(), permission_status_present: get_optional_function(callbacks, "devicePermissionStatus")? .is_some(), pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() @@ -109,6 +140,11 @@ impl JsBridge { self.chat_present } + /// Whether the host supplied every `identity_backend` callback. + pub(super) fn has_identity_backend(&self) -> bool { + self.identity_backend_present + } + /// Whether the host supplied every `permission_status` callback. pub(super) fn has_permission_status(&self) -> bool { self.permission_status_present @@ -283,6 +319,27 @@ impl truapi_platform::Features for WasmPlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::IdentityBackendHost for WasmPlatform { + async fn identity_username_candidates( + &self, + username: String, + people_chain_genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let bytes = invoke_bytes_return( + &self.bridge.identity_username_candidates, + vec![ + JsValue::from_str(&username), + Uint8Array::from(people_chain_genesis_hash.as_slice()).into(), + ], + ) + .await + .map_err(generic)?; + decode_bytes::>(bytes, "identityUsernameCandidates response did not decode") + .map_err(generic) + } +} + impl truapi_platform::LocaleHost for WasmPlatform { fn subscribe_locale( &self, @@ -295,6 +352,101 @@ impl truapi_platform::LocaleHost for WasmPlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::NativeChatFilesHost for WasmPlatform { + async fn pick_chat_files( + &self, + request: truapi_platform::NativeChatFilePickRequest, + ) -> Result, v01::GenericError> { + let bytes = invoke_bytes_return( + &self.bridge.pick_chat_files, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::>( + bytes, + "pickChatFiles response did not decode", + ) + .map_err(generic) + } + + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, v01::GenericError> { + invoke_bytes_return( + &self.bridge.read_chat_file, + vec![ + JsValue::from_str(&source_id), + js_sys::BigInt::from(offset).into(), + JsValue::from_f64(f64::from(length)), + ], + ) + .await + .map_err(generic) + } + + async fn release_chat_file(&self, source_id: String) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.release_chat_file, + vec![JsValue::from_str(&source_id)], + ) + .await + .map_err(generic) + } + + async fn begin_chat_file_export( + &self, + request: truapi_platform::NativeChatFileExportRequest, + ) -> Result, v01::GenericError> { + invoke_optional_string_return( + &self.bridge.begin_chat_file_export, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } + + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_chat_file_export, + vec![ + JsValue::from_str(&export_id), + js_sys::BigInt::from(offset).into(), + Uint8Array::from(data.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.finish_chat_file_export, + vec![JsValue::from_str(&export_id)], + ) + .await + .map_err(generic) + } + + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_chat_file_export, + vec![JsValue::from_str(&export_id)], + ) + .await + .map_err(generic) + } +} + #[truapi_platform::async_trait] impl truapi_platform::Navigation for WasmPlatform { async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 09178a8ea..95ab083ca 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -8,7 +8,7 @@ import type { RawCallbacks } from "./host-callbacks-adapter.js"; import type { GenericError } from "@parity/truapi"; -import type { ChainConnect } from "../runtime.js"; +import type { ChainConnect, HopConnect } from "../runtime.js"; export const CALLBACK_NAMES = [ "authStateChanged", @@ -20,6 +20,15 @@ export const CALLBACK_NAMES = [ "clearCoreStorage", "featureSupported", "supportedChains", + "allowedHopEndpoints", + "identityUsernameCandidates", + "pickChatFiles", + "readChatFile", + "releaseChatFile", + "beginChatFileExport", + "writeChatFileExport", + "finishChatFileExport", + "cancelChatFileExport", "navigateTo", "pushNotification", "cancelNotification", @@ -55,6 +64,7 @@ export interface WorkerCallbackBridge { sendError: (error: GenericError) => void, ): () => void; chainConnect: ChainConnect; + hopConnect: HopConnect; } function rawCallbacks( @@ -68,6 +78,14 @@ function rawCallbacks( | "clearCoreStorage" | "featureSupported" | "supportedChains" + | "allowedHopEndpoints" + | "pickChatFiles" + | "readChatFile" + | "releaseChatFile" + | "beginChatFileExport" + | "writeChatFileExport" + | "finishChatFileExport" + | "cancelChatFileExport" | "navigateTo" | "pushNotification" | "cancelNotification" @@ -102,6 +120,42 @@ function rawCallbacks( bridge.callbackRequest("supportedChains", []) as ReturnType< Required["supportedChains"] >, + allowedHopEndpoints: (bulletinGenesisHash) => + bridge.callbackRequest("allowedHopEndpoints", [ + bulletinGenesisHash, + ]) as ReturnType["allowedHopEndpoints"]>, + pickChatFiles: (request) => + bridge.callbackRequest("pickChatFiles", [request]) as ReturnType< + Required["pickChatFiles"] + >, + readChatFile: (sourceId, offset, length) => + bridge.callbackRequest("readChatFile", [ + sourceId, + offset, + length, + ]) as ReturnType["readChatFile"]>, + releaseChatFile: (sourceId) => + bridge.callbackRequest("releaseChatFile", [sourceId]) as ReturnType< + Required["releaseChatFile"] + >, + beginChatFileExport: (request) => + bridge.callbackRequest("beginChatFileExport", [request]) as ReturnType< + Required["beginChatFileExport"] + >, + writeChatFileExport: (exportId, offset, data) => + bridge.callbackRequest("writeChatFileExport", [ + exportId, + offset, + data, + ]) as ReturnType["writeChatFileExport"]>, + finishChatFileExport: (exportId) => + bridge.callbackRequest("finishChatFileExport", [exportId]) as ReturnType< + Required["finishChatFileExport"] + >, + cancelChatFileExport: (exportId) => + bridge.callbackRequest("cancelChatFileExport", [exportId]) as ReturnType< + Required["cancelChatFileExport"] + >, navigateTo: (url) => bridge.callbackRequest("navigateTo", [url]) as ReturnType< Required["navigateTo"] @@ -193,6 +247,18 @@ function chatRawCallbacks( }; } +function identityBackendRawCallbacks( + bridge: WorkerCallbackBridge, +): Required> { + return { + identityUsernameCandidates: (username, peopleChainGenesisHash) => + bridge.callbackRequest("identityUsernameCandidates", [ + username, + peopleChainGenesisHash, + ]) as ReturnType["identityUsernameCandidates"]>, + }; +} + function permissionStatusRawCallbacks( bridge: WorkerCallbackBridge, ): Required> { @@ -232,6 +298,8 @@ export interface OptionalCapabilities { /** Whether the host serves this capability. */ chat?: boolean; /** Whether the host serves this capability. */ + identityBackend?: boolean; + /** Whether the host serves this capability. */ permissionStatus?: boolean; /** Whether the host serves this capability. */ pocket?: boolean; @@ -245,8 +313,11 @@ export function createWorkerRawCallbacks( ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), chainConnect: bridge.chainConnect, + hopConnect: bridge.hopConnect, }; if (capabilities.chat) Object.assign(callbacks, chatRawCallbacks(bridge)); + if (capabilities.identityBackend) + Object.assign(callbacks, identityBackendRawCallbacks(bridge)); if (capabilities.permissionStatus) Object.assign(callbacks, permissionStatusRawCallbacks(bridge)); if (capabilities.pocket) Object.assign(callbacks, pocketRawCallbacks(bridge)); diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index de9fdae0e..654fe2337 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -18,41 +18,6 @@ fn nightly_toolchain() -> String { std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| "nightly".to_string()) } -fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { - let marker = format!("export const {const_name} = ["); - let start = src - .find(&marker) - .unwrap_or_else(|| panic!("missing {const_name}")); - let rest = &src[start + marker.len()..]; - let end = rest - .find("] as const") - .unwrap_or_else(|| panic!("unterminated {const_name}")); - let body = &rest[..end]; - let mut strings = Vec::new(); - let mut chars = body.chars(); - while let Some(ch) = chars.next() { - if ch != '"' { - continue; - } - let mut value = String::new(); - let mut escaped = false; - for ch in chars.by_ref() { - if escaped { - value.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - break; - } else { - value.push(ch); - } - } - strings.push(value); - } - strings -} - /// Path to `truapi`'s rustdoc JSON, building it on first use. fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { produce_rustdoc_json_for_package(workspace_root, "truapi") @@ -360,24 +325,4 @@ fn golden_host_callbacks_ts() { dump.display() ); } - - assert!( - !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), - "worker callback generation should not expose an optional callback manifest" - ); - let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); - generated_names.extend(quoted_strings_in_const_array( - &worker_actual, - "SUBSCRIPTION_NAMES", - )); - for name in generated_names { - // Callbacks of an optional capability bind through the optional getter; - // either way the bridge must name every callback the worker proxies. - assert!( - wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")) - || wasm_bridge_actual - .contains(&format!("get_optional_function(callbacks, \"{name}\")?")), - "generated wasm bridge must bind worker callback `{name}`" - ); - } } diff --git a/rust/crates/truapi-coinage/Cargo.toml b/rust/crates/truapi-coinage/Cargo.toml new file mode 100644 index 000000000..7f3f8334f --- /dev/null +++ b/rust/crates/truapi-coinage/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "truapi-coinage" +version = "0.1.0" +edition = "2024" +publish = false +license = "AGPL-3.0-only" +description = "Portable Host-owned Coinage selection, send, claim and durable recovery engine" +repository = "https://github.com/paritytech/host-rust-core" + +[package.metadata.provenance] +source = "https://github.com/paritytech/brevity-dozer" +revision = "d504259b60b88ca42f70a8378186a714887ef19f" +paths = ["core/crates/brevity-coinage", "core/crates/brevity-chain/src/pallets/members.rs", "core/crates/brevity-chain/src/tx_extensions.rs"] +license-notice = "NOTICE" + +[dependencies] +async-trait = "0.1" +blake2 = "0.10" +blake2b_simd = "1" +futures = "0.3" +futures-timer = "3" +getrandom = { version = "0.2", features = ["js"] } +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +parking_lot = "0.12" +rand = "0.8" +rand_chacha = "0.3" +schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +serde_json = "1" +substrate-bip39 = { version = "0.6", default-features = false } +tokio = { version = "1", default-features = false, features = ["sync"] } +tracing = "0.1" +web-time = "1" +zeroize = { version = "1", features = ["zeroize_derive"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +futures-timer = { version = "3", features = ["wasm-bindgen"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +subxt-signer = { version = "0.44.3", default-features = false, features = ["std", "sr25519"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/rust/crates/truapi-coinage/LICENSE b/rust/crates/truapi-coinage/LICENSE new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-coinage/LICENSE @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-coinage/LICENSE-MIT b/rust/crates/truapi-coinage/LICENSE-MIT new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/rust/crates/truapi-coinage/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/crates/truapi-coinage/NOTICE b/rust/crates/truapi-coinage/NOTICE new file mode 100644 index 000000000..b1fc0e013 --- /dev/null +++ b/rust/crates/truapi-coinage/NOTICE @@ -0,0 +1,42 @@ +truapi-coinage — AGPL-3.0-only + +This crate is a modified extraction of Coinage domain code from +https://github.com/paritytech/brevity-dozer +revision d504259b60b88ca42f70a8378186a714887ef19f: + core/crates/brevity-coinage/src/ + core/crates/brevity-chain/src/pallets/members.rs + core/crates/brevity-chain/src/tx_extensions.rs +Copyright the Brevity contributors; the upstream repository identifies its +license as GNU Affero General Public License version 3. + +Modified 2026-09-20: separated database, RPC, app diagnostics and native +cryptographic adapters; injected Host execution and portable timers; retained +native Coinage denomination/send/claim/recovery algorithms and wire layouts. +The extraction retains AGPL-3.0-only licensing. It is not relicensed MIT. +The complete GNU Affero General Public License is in LICENSE. No warranty. + +The sr25519 root entropy and hard-junction derivation in src/keys.rs is +adapted from the MIT-licensed host-rust-core source: + rust/crates/truapi-server/src/host_logic/product_account.rs + revision 0926e881d4520ca73c2de82d7ecd91819c3e645f +Copyright (c) 2026 Parity Technologies. +Its complete retained MIT notice is in LICENSE-MIT; the combined crate remains +AGPL-3.0-only. + +Modified 2026-09-20: deliberately replaced the extracted legacy Coinage +derivation with the current hosts/ios/Packages/Coinage main-purse layout: + coins: //coinage//4294967295//0/ (soft final junction) + vouchers: //coinage-ring-vrf//4294967295//0// (all hard junctions). +The fixed purse is u32::MAX and page is zero. Coin roots retain Substrate +BIP-39 expansion; vouchers fold keyed BLAKE2b-256 directly over root entropy. +The reference factories are Sources/KeypairFactory/CoinKeypairFactory.swift +and VoucherKeypairFactory.swift; root expansion follows the current iOS +KeyDerivation package. This is an intentional derivation adaptation, not +compatibility with legacy persisted coin/voucher indices. Host storage must +isolate derivation layouts. Product-account and Chat transport keys remain +independent. + +This library does not provide product permission or a secret-bearing guest +API. Repositories and submission/proof adapters must be owned by the Host's +signing authority. A distributor/operator must satisfy the applicable AGPL +Corresponding Source and network-interaction obligations. diff --git a/rust/crates/truapi-coinage/src/allocator.rs b/rust/crates/truapi-coinage/src/allocator.rs new file mode 100644 index 000000000..991017688 --- /dev/null +++ b/rust/crates/truapi-coinage/src/allocator.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Coin and voucher allocation: draws the next derivation index from the +//! [`CoinageIndexStore`] — the atomic read-increment-write counter — and +//! materializes the local record. A fresh voucher's `ready_at` is +//! `allocated_at + delay`, where the delay is drawn uniformly from +//! `0..=MAX_VOUCHER_WAIT_TIME` (6 h) to decorrelate onboarding batches; its +//! privacy level starts Degraded until the recycler ring proves large enough. + +use std::sync::Arc; + +use crate::clock::Clock; +use crate::constants::MAX_VOUCHER_WAIT_TIME; +use crate::index_store::{CoinageIndexStore, IndexKind}; +use crate::model::{ + Coin, CoinState, Voucher, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState, +}; + +/// The voucher readiness-delay source. Injected so tests pin it; the +/// production impl draws uniform jitter. +pub trait VoucherDelayProvider: Send + Sync { + /// A delay in `0..=MAX_VOUCHER_WAIT_TIME` milliseconds. + fn ready_delay_ms(&self) -> i64; +} + +/// Production jitter from the OS entropy-backed hasher seed. Not +/// cryptographic — the delay only needs to be unpredictable enough to +/// decorrelate onboarding batches, matching the iOS +/// `TimeInterval.random(in: 0...maxVoucherWaitTime)`. +pub struct SystemJitterDelayProvider; + +impl VoucherDelayProvider for SystemJitterDelayProvider { + fn ready_delay_ms(&self) -> i64 { + use std::hash::{BuildHasher, Hasher, RandomState}; + let raw = RandomState::new().build_hasher().finish(); + let span = MAX_VOUCHER_WAIT_TIME.as_millis() as u64 + 1; + (raw % span) as i64 + } +} + +/// Fixed delay for tests. +pub struct FixedDelayProvider(pub i64); + +impl VoucherDelayProvider for FixedDelayProvider { + fn ready_delay_ms(&self) -> i64 { + self.0 + } +} + +pub struct CoinAllocator { + index_store: Arc, +} + +impl CoinAllocator { + pub fn new(index_store: Arc) -> Self { + Self { index_store } + } + + pub async fn allocate(&self, exponent: i16) -> Result { + let derivation_index = self.index_store.get_next_index(IndexKind::Coin).await?; + Ok(Coin { + exponent, + derivation_index, + age: None, + state: CoinState::Available, + }) + } +} + +/// Allocates fresh vouchers: next index, `ready_at = now + jitter`, +/// `Unlocated` remote state and `Degraded` privacy until a later scan +/// reconciles the voucher's on-chain position. +pub struct VoucherAllocator { + index_store: Arc, + delay: Arc, + clock: Arc, +} + +impl VoucherAllocator { + pub fn new( + index_store: Arc, + delay: Arc, + clock: Arc, + ) -> Self { + Self { + index_store, + delay, + clock, + } + } + + pub async fn allocate(&self, exponent: i16) -> Result { + let derivation_index = self.index_store.get_next_index(IndexKind::Voucher).await?; + let allocated_at_ms = self.clock.now_ms(); + Ok(Voucher { + exponent, + derivation_index, + allocated_at_ms, + ready_at_ms: allocated_at_ms.saturating_add(self.delay.ready_delay_ms()), + remote_state: VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Degraded, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::FixedClock; + use crate::index_store::InMemoryCoinageIndexStore; + + #[tokio::test] + async fn coin_allocation_draws_sequential_indices() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let allocator = CoinAllocator::new(Arc::clone(&store) as Arc<_>); + let first = allocator.allocate(3).await.unwrap(); + let second = allocator.allocate(0).await.unwrap(); + assert_eq!( + (first.derivation_index, second.derivation_index), + (0, 1), + "COINB-051 sequence" + ); + assert_eq!(first.exponent, 3); + assert_eq!(first.age, None, "age unknown until first sync"); + assert_eq!(first.state, CoinState::Available); + } + + #[tokio::test] + async fn voucher_allocation_applies_the_readiness_jitter() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let allocator = VoucherAllocator::new( + Arc::clone(&store) as Arc<_>, + Arc::new(FixedDelayProvider(5_000)), + Arc::new(FixedClock(1_000)), + ); + let voucher = allocator.allocate(2).await.unwrap(); + assert_eq!(voucher.derivation_index, 0); + assert_eq!(voucher.allocated_at_ms, 1_000); + assert_eq!(voucher.ready_at_ms, 6_000, "allocated_at + delay"); + assert_eq!(voucher.remote_state, VoucherRemoteState::Unlocated); + assert_eq!(voucher.local_state, VoucherLocalState::Available); + assert_eq!( + voucher.privacy, + VoucherPrivacyLevel::Degraded, + "degraded until the ring proves large enough" + ); + } + + /// Coin and voucher counters never share an index space. + #[tokio::test] + async fn allocators_use_independent_counters() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let coins = CoinAllocator::new(Arc::clone(&store) as Arc<_>); + let vouchers = VoucherAllocator::new( + Arc::clone(&store) as Arc<_>, + Arc::new(FixedDelayProvider(0)), + Arc::new(FixedClock(0)), + ); + coins.allocate(0).await.unwrap(); + coins.allocate(0).await.unwrap(); + assert_eq!(vouchers.allocate(0).await.unwrap().derivation_index, 0); + } + + #[test] + fn system_jitter_stays_inside_the_wait_window() { + let provider = SystemJitterDelayProvider; + for _ in 0..64 { + let delay = provider.ready_delay_ms(); + assert!(delay >= 0); + assert!(delay <= MAX_VOUCHER_WAIT_TIME.as_millis() as i64); + } + } +} diff --git a/rust/crates/truapi-coinage/src/balance.rs b/rust/crates/truapi-coinage/src/balance.rs new file mode 100644 index 000000000..b25336590 --- /dev/null +++ b/rust/crates/truapi-coinage/src/balance.rs @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::denomination::DenominationBreakdownContext; +use crate::model::{ + Coin, CoinState, EffectivePrivacy, Voucher, VoucherLocalState, VoucherPrivacyLevel, +}; + +/// The three balance buckets, in planks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BalanceBuckets { + pub full_privacy_planks: u128, + pub degraded_planks: u128, + pub locked_planks: u128, +} + +impl BalanceBuckets { + pub fn total_planks(&self) -> u128 { + self.full_privacy_planks + .saturating_add(self.degraded_planks) + .saturating_add(self.locked_planks) + } +} + +pub fn compute_balance( + coins: &[Coin], + vouchers: &[Voucher], + context: &DenominationBreakdownContext, + now_ms: i64, +) -> BalanceBuckets { + let mut buckets = BalanceBuckets::default(); + for coin in coins { + let value = context.value_in_planks(coin.exponent); + match coin.state { + CoinState::Available if coin.is_expiring_soon() => { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } + CoinState::Available => { + buckets.full_privacy_planks = buckets.full_privacy_planks.saturating_add(value); + } + CoinState::Recycling => { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } + CoinState::PendingTransfer | CoinState::Spent => {} + } + } + for voucher in vouchers { + if voucher.local_state != VoucherLocalState::Available { + continue; + } + let value = context.value_in_planks(voucher.exponent); + if !voucher.remote_state.is_in_recycler() { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } else { + match voucher.effective_privacy(now_ms) { + EffectivePrivacy::Full => { + buckets.full_privacy_planks = buckets.full_privacy_planks.saturating_add(value); + } + EffectivePrivacy::Degraded => { + buckets.degraded_planks = buckets.degraded_planks.saturating_add(value); + } + } + } + } + buckets +} + +pub fn next_unlock_at_ms(vouchers: &[Voucher], now_ms: i64) -> Option { + vouchers + .iter() + .filter(|v| { + v.privacy == VoucherPrivacyLevel::Full + && v.local_state == VoucherLocalState::Available + && v.remote_state.is_in_recycler() + && v.ready_at_ms > now_ms + }) + .map(|v| v.ready_at_ms) + .min() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::VoucherRemoteState; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn coin(index: u32, exponent: i16, age: Option, state: CoinState) -> Coin { + Coin { + exponent, + derivation_index: index, + age, + state, + } + } + + fn voucher( + index: u32, + exponent: i16, + remote: VoucherRemoteState, + local: VoucherLocalState, + privacy: VoucherPrivacyLevel, + ready_at_ms: i64, + ) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms, + remote_state: remote, + local_state: local, + privacy, + } + } + + const NOW: i64 = 10_000; + const IN_RECYCLER: VoucherRemoteState = VoucherRemoteState::InRecycler { recycler_index: 0 }; + + #[test] + fn decomposition_with_known_coin_and_voucher_sets() { + let coins = [ + coin(1, 0, Some(3), CoinState::Available), // 10 → full + coin(2, 1, None, CoinState::Available), // 20 → full (unsynced age) + coin(3, 2, Some(14), CoinState::Available), // 40 → locked (expiring) + coin(4, 3, Some(2), CoinState::Recycling), // 80 → locked + coin(5, 4, Some(2), CoinState::PendingTransfer), // reserved → nowhere + coin(6, 4, Some(2), CoinState::Spent), // gone → nowhere + ]; + let vouchers = [ + // 10 → full (ready, full privacy, in recycler). + voucher( + 10, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW, + ), + // 20 → degraded (full privacy but not ready yet). + voucher( + 11, + 1, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 1, + ), + // 40 → degraded (degraded at onboarding). + voucher( + 12, + 2, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Degraded, + 0, + ), + // 80 → locked (not in a recycler yet). + voucher( + 13, + 3, + VoucherRemoteState::Onboarding, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + 0, + ), + // reserved → nowhere. + voucher( + 14, + 4, + IN_RECYCLER, + VoucherLocalState::PendingTransfer, + VoucherPrivacyLevel::Full, + 0, + ), + ]; + let buckets = compute_balance(&coins, &vouchers, &ctx(), NOW); + assert_eq!(buckets.full_privacy_planks, 10 + 20 + 10); + assert_eq!(buckets.degraded_planks, 20 + 40); + assert_eq!(buckets.locked_planks, 40 + 80 + 80); + assert_eq!(buckets.total_planks(), 300); + } + + #[test] + fn empty_wallet_is_all_zero() { + assert_eq!( + compute_balance(&[], &[], &ctx(), NOW), + BalanceBuckets::default() + ); + } + + #[test] + fn next_unlock_picks_the_earliest_upgradeable_voucher() { + let vouchers = [ + voucher( + 1, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 500, + ), + voucher( + 2, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 100, + ), + // Already ready → no timer needed for it. + voucher( + 3, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW, + ), + // Degraded privacy never upgrades → ignored. + voucher( + 4, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Degraded, + NOW + 50, + ), + // Not in a recycler → ignored. + voucher( + 5, + 0, + VoucherRemoteState::Unlocated, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 10, + ), + ]; + assert_eq!(next_unlock_at_ms(&vouchers, NOW), Some(NOW + 100)); + assert_eq!(next_unlock_at_ms(&vouchers[2..], NOW), None); + } +} diff --git a/rust/crates/truapi-coinage/src/claim.rs b/rust/crates/truapi-coinage/src/claim.rs new file mode 100644 index 000000000..ac562f82e --- /dev/null +++ b/rust/crates/truapi-coinage/src/claim.rs @@ -0,0 +1,752 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use tokio::sync::watch; +use tracing::warn; + +use crate::claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::denomination::DenominationBreakdownContext; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimStatus { + /// Waiting for the send to appear on-chain. + Detecting, + /// Coins confirmed on-chain, awaiting claim. + Sent, + /// Claim extrinsic in flight. + Claiming, + Finished { + claimed_amount: u128, + }, + Error, +} + +impl ClaimStatus { + pub fn is_terminal(&self) -> bool { + matches!(self, ClaimStatus::Finished { .. } | ClaimStatus::Error) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SendConfirmation { + OnChain, + AlreadyClaimed, +} + +#[async_trait] +pub trait TransferSendVerifying: Send + Sync { + /// Resolves when the memo's coins are on-chain; errors on timeout. + async fn await_send_on_chain( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String>; + + /// Resolves when the memo's coins have left the chain (claimed). + async fn await_claim_on_chain( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String>; + + async fn await_send_or_claimed( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result; +} + +#[async_trait] +pub trait ClaimExecutor: Send + Sync { + async fn claim( + &self, + memo_key: &[u8; 32], + message_id: &str, + ) -> Result, String>; +} + +pub fn claimed_amount_from_plan(plan: &ClaimPlan, context: &DenominationBreakdownContext) -> u128 { + if plan.entries.is_empty() { + return plan.total_value; + } + plan.entries.iter().fold(0u128, |acc, entry| { + acc.saturating_add(context.value_in_planks(entry.exponent)) + }) +} + +#[derive(Default)] +pub struct ClaimStatusStore { + subjects: Mutex>>, + terminal: Mutex>, +} + +impl ClaimStatusStore { + /// Emits the current status immediately (the watch receiver's seed + /// value), then streams updates for live claims. For a terminal + /// message the receiver completes right after that seed read. + /// Returns `None` for a message id that was never seen. + pub fn watch_status(&self, message_id: &str) -> Option> { + if let Some(sender) = self.subjects.lock().get(message_id) { + return Some(sender.subscribe()); + } + let terminal = self.terminal.lock(); + let status = terminal.get(message_id)?; + // One-shot: seed a fresh channel and drop the sender so + // `changed` completes immediately after the seed read. + let (tx, rx) = watch::channel(status.clone()); + drop(tx); + Some(rx) + } + + /// Current status snapshot. + pub fn status(&self, message_id: &str) -> Option { + if let Some(sender) = self.subjects.lock().get(message_id) { + return Some(sender.borrow().clone()); + } + self.terminal.lock().get(message_id).cloned() + } + + pub fn update_status(&self, message_id: &str, status: ClaimStatus) { + let mut subjects = self.subjects.lock(); + if status.is_terminal() { + if let Some(sender) = subjects.remove(message_id) { + let _ = sender.send(status.clone()); + // Sender drops here → subscriber streams close. + } + self.terminal.lock().insert(message_id.to_string(), status); + return; + } + match subjects.get(message_id) { + Some(sender) => { + let _ = sender.send(status); + } + None => { + let (sender, _) = watch::channel(status); + subjects.insert(message_id.to_string(), sender); + } + } + } +} + +pub fn restore_persisted_statuses(plans: &[ClaimPlan], store: &ClaimStatusStore) { + for plan in plans { + let Some(message_id) = &plan.message_id else { + continue; + }; + let status = match plan.status { + ClaimPlanStatus::Processing => ClaimStatus::Detecting, + ClaimPlanStatus::Detected => ClaimStatus::Sent, + ClaimPlanStatus::Finished => ClaimStatus::Finished { + claimed_amount: plan.claimed_amount.unwrap_or(plan.total_value), + }, + ClaimPlanStatus::Error => ClaimStatus::Error, + }; + store.update_status(message_id, status); + } +} + +/// Claim orchestration errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimError { + AlreadyClaiming, + Failed(String), +} + +impl std::fmt::Display for ClaimError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClaimError::AlreadyClaiming => write!(f, "already claiming this memo"), + ClaimError::Failed(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ClaimError {} + +/// One incoming `coinageSend` to claim. +#[derive(Debug, Clone)] +pub struct IncomingClaim { + pub memo_key: [u8; 32], + pub message_id: String, + pub total_value: u128, +} + +pub struct ClaimOrchestrator { + plans: Arc, + verifier: Arc, + executor: Arc, + statuses: Arc, + context: DenominationBreakdownContext, + claiming_memos: Mutex>, +} + +impl ClaimOrchestrator { + pub fn new( + plans: Arc, + verifier: Arc, + executor: Arc, + statuses: Arc, + context: DenominationBreakdownContext, + ) -> Self { + Self { + plans, + verifier, + executor, + statuses, + context, + claiming_memos: Mutex::new(HashSet::new()), + } + } + + pub async fn restore_persisted_statuses(&self) -> Result<(), String> { + let plans = self.plans.load_all().await?; + restore_persisted_statuses(&plans, &self.statuses); + Ok(()) + } + + /// Claims one incoming memo; resolves the claimed planks. + pub async fn claim_incoming(&self, incoming: IncomingClaim) -> Result { + { + let mut claiming = self.claiming_memos.lock(); + if !claiming.insert(incoming.memo_key) { + return Err(ClaimError::AlreadyClaiming); + } + } + let _guard = ClaimingGuard { + memo_key: incoming.memo_key, + claiming: &self.claiming_memos, + }; + + let outcome = self.claim_inner(&incoming).await; + if let Err(ClaimError::Failed(message)) = &outcome { + warn!(message, message_id = incoming.message_id, "claim failed"); + if let Err(error) = self + .plans + .update_status(&incoming.memo_key, ClaimPlanStatus::Error, None) + .await + { + warn!(error, "claim error status stamp failed"); + } + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Error); + } + outcome + } + + async fn claim_inner(&self, incoming: &IncomingClaim) -> Result { + let existing = self + .plans + .plan(&incoming.memo_key) + .await + .map_err(ClaimError::Failed)?; + + match &existing { + Some(plan) if plan.status == ClaimPlanStatus::Finished => { + let claimed = plan + .claimed_amount + .unwrap_or_else(|| claimed_amount_from_plan(plan, &self.context)); + self.statuses.update_status( + &incoming.message_id, + ClaimStatus::Finished { + claimed_amount: claimed, + }, + ); + return Ok(claimed); + } + Some(_) => {} + None => { + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Detecting); + self.verifier + .await_send_on_chain(&incoming.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await + .map_err(ClaimError::Failed)?; + self.plans + .save(&ClaimPlan { + memo_key: incoming.memo_key, + message_id: Some(incoming.message_id.clone()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: incoming.total_value, + }) + .await + .map_err(ClaimError::Failed)?; + } + } + + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Claiming); + let entries = self + .executor + .claim(&incoming.memo_key, &incoming.message_id) + .await + .map_err(ClaimError::Failed)?; + + let finished = ClaimPlan { + memo_key: incoming.memo_key, + message_id: Some(incoming.message_id.clone()), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Finished, + claimed_amount: None, + total_value: incoming.total_value, + }; + let claimed = claimed_amount_from_plan(&finished, &self.context); + self.plans + .update_status(&incoming.memo_key, ClaimPlanStatus::Finished, Some(claimed)) + .await + .map_err(ClaimError::Failed)?; + self.statuses.update_status( + &incoming.message_id, + ClaimStatus::Finished { + claimed_amount: claimed, + }, + ); + Ok(claimed) + } +} + +struct ClaimingGuard<'a> { + memo_key: [u8; 32], + claiming: &'a Mutex>, +} + +impl Drop for ClaimingGuard<'_> { + fn drop(&mut self) { + self.claiming.lock().remove(&self.memo_key); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn entry(exponent: i16) -> CodableClaimPlanEntry { + CodableClaimPlanEntry { + entry_index: 0, + exponent, + derivation_index: 1, + } + } + + fn plan(entries: Vec, status: ClaimPlanStatus) -> ClaimPlan { + ClaimPlan { + memo_key: [7; 32], + message_id: Some("m1".into()), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status, + claimed_amount: None, + total_value: 990, + } + } + + #[test] + fn claimed_amount_sums_entry_denominations() { + let plan = plan( + vec![entry(4), entry(2), entry(0)], + ClaimPlanStatus::Finished, + ); + assert_eq!(claimed_amount_from_plan(&plan, &ctx()), 210); + } + + #[test] + fn claimed_amount_falls_back_to_total_value() { + let plan = plan(Vec::new(), ClaimPlanStatus::Finished); + assert_eq!(claimed_amount_from_plan(&plan, &ctx()), 990); + } + + #[tokio::test] + async fn watch_emits_current_then_streams_updates() { + let store = ClaimStatusStore::default(); + store.update_status("m1", ClaimStatus::Detecting); + let mut rx = store.watch_status("m1").unwrap(); + assert_eq!( + *rx.borrow(), + ClaimStatus::Detecting, + "immediate current value" + ); + store.update_status("m1", ClaimStatus::Claiming); + rx.changed().await.unwrap(); + assert_eq!(*rx.borrow(), ClaimStatus::Claiming); + } + + #[tokio::test] + async fn terminal_status_closes_and_cleans_up() { + let store = ClaimStatusStore::default(); + store.update_status("m1", ClaimStatus::Claiming); + let mut live = store.watch_status("m1").unwrap(); + + store.update_status("m1", ClaimStatus::Finished { claimed_amount: 5 }); + live.changed().await.unwrap(); + assert_eq!(*live.borrow(), ClaimStatus::Finished { claimed_amount: 5 }); + assert!( + live.changed().await.is_err(), + "stream completes after terminal" + ); + assert!( + store.subjects.lock().is_empty(), + "subject removed after terminal send" + ); + + // Late watcher: value once, then complete. + let mut late = store.watch_status("m1").unwrap(); + assert_eq!(*late.borrow(), ClaimStatus::Finished { claimed_amount: 5 }); + assert!(late.changed().await.is_err()); + assert!(store.watch_status("unknown").is_none()); + } + + #[test] + fn restore_maps_plan_statuses_to_claim_statuses() { + let store = ClaimStatusStore::default(); + let mut processing = plan(Vec::new(), ClaimPlanStatus::Processing); + processing.message_id = Some("p".into()); + let mut detected = plan(Vec::new(), ClaimPlanStatus::Detected); + detected.message_id = Some("d".into()); + let mut finished = plan(Vec::new(), ClaimPlanStatus::Finished); + finished.message_id = Some("f".into()); + finished.claimed_amount = Some(123); + let mut finished_no_amount = plan(Vec::new(), ClaimPlanStatus::Finished); + finished_no_amount.message_id = Some("f2".into()); + let mut errored = plan(Vec::new(), ClaimPlanStatus::Error); + errored.message_id = Some("e".into()); + let mut no_message = plan(Vec::new(), ClaimPlanStatus::Processing); + no_message.message_id = None; + + restore_persisted_statuses( + &[ + processing, + detected, + finished, + finished_no_amount, + errored, + no_message, + ], + &store, + ); + assert_eq!(store.status("p"), Some(ClaimStatus::Detecting)); + assert_eq!(store.status("d"), Some(ClaimStatus::Sent)); + assert_eq!( + store.status("f"), + Some(ClaimStatus::Finished { + claimed_amount: 123 + }) + ); + assert_eq!( + store.status("f2"), + Some(ClaimStatus::Finished { + claimed_amount: 990 + }), + "missing claimed_amount falls back to total_value" + ); + assert_eq!(store.status("e"), Some(ClaimStatus::Error)); + } + + // — orchestrator plumbing — + + #[derive(Default)] + struct MemPlans { + plans: Mutex>, + saves: AtomicUsize, + } + + #[async_trait] + impl ClaimPlanStore for MemPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.saves.fetch_add(1, Ordering::SeqCst); + self.plans.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.plans.lock().get(memo_key).cloned()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.plans.lock().values().cloned().collect()) + } + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.plans.lock(); + let plan = plans.get_mut(memo_key).ok_or("plan not found")?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.plans.lock().remove(memo_key); + Ok(()) + } + } + + #[derive(Default)] + struct MockVerifier { + send_awaits: AtomicUsize, + } + + #[async_trait] + impl TransferSendVerifying for MockVerifier { + async fn await_send_on_chain( + &self, + _memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String> { + assert_eq!(block_timeout, 100, "COINM-024: 100-block timeout"); + self.send_awaits.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn await_claim_on_chain( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result<(), String> { + Ok(()) + } + async fn await_send_or_claimed( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result { + Ok(SendConfirmation::OnChain) + } + } + + struct MockExecutor { + plans: Arc, + saves_seen_at_claim: AtomicUsize, + calls: AtomicUsize, + outcome: Result, String>, + release: Option>, + } + + #[async_trait] + impl ClaimExecutor for MockExecutor { + async fn claim( + &self, + memo_key: &[u8; 32], + _message_id: &str, + ) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.saves_seen_at_claim + .store(self.plans.saves.load(Ordering::SeqCst), Ordering::SeqCst); + assert!( + self.plans.plan(memo_key).await.unwrap().is_some(), + "plan must be persisted before the claim executor runs (COINA-016)" + ); + if let Some(release) = &self.release { + let mut release = release.clone(); + while !*release.borrow() { + if release.changed().await.is_err() { + break; + } + } + } + self.outcome.clone() + } + } + + fn orchestrator( + plans: Arc, + verifier: Arc, + executor: Arc, + ) -> ClaimOrchestrator { + ClaimOrchestrator::new( + plans, + verifier, + executor, + Arc::new(ClaimStatusStore::default()), + ctx(), + ) + } + + fn incoming() -> IncomingClaim { + IncomingClaim { + memo_key: [7; 32], + message_id: "m1".into(), + total_value: 990, + } + } + + #[tokio::test] + async fn fresh_claim_saves_plan_before_submitting() { + let plans = Arc::new(MemPlans::default()); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(vec![entry(2), entry(0)]), // 40 + 10 + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::clone(&verifier), + Arc::clone(&executor), + ); + + let claimed = orchestrator.claim_incoming(incoming()).await.unwrap(); + assert_eq!(claimed, 50); + assert_eq!(verifier.send_awaits.load(Ordering::SeqCst), 1); + assert!( + executor.saves_seen_at_claim.load(Ordering::SeqCst) >= 1, + "save-before-submit (COINA-016)" + ); + let stored = plans.plan(&[7; 32]).await.unwrap().unwrap(); + assert_eq!(stored.status, ClaimPlanStatus::Finished); + assert_eq!(stored.claimed_amount, Some(50)); + assert!( + stored.entries.is_empty(), + "finish is the status-only path — entries_data untouched (COINM-006)" + ); + assert_eq!( + orchestrator.statuses.status("m1"), + Some(ClaimStatus::Finished { claimed_amount: 50 }) + ); + } + + #[tokio::test] + async fn existing_plan_short_circuits_the_send_await() { + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(vec![entry(0)]), + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::clone(&verifier), + Arc::clone(&executor), + ); + + let claimed = orchestrator.claim_incoming(incoming()).await.unwrap(); + assert_eq!(claimed, 10); + assert_eq!( + verifier.send_awaits.load(Ordering::SeqCst), + 0, + "crash-recovery safety: input coins may already be spent (COINM-022)" + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 1, "claim still runs"); + } + + #[tokio::test] + async fn finished_plan_is_reported_without_reclaiming() { + let plans = Arc::new(MemPlans::default()); + let mut finished = plan(Vec::new(), ClaimPlanStatus::Finished); + finished.claimed_amount = Some(321); + plans.save(&finished).await.unwrap(); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(Vec::new()), + release: None, + }); + let orchestrator = orchestrator(plans, verifier.clone(), Arc::clone(&executor)); + + assert_eq!(orchestrator.claim_incoming(incoming()).await.unwrap(), 321); + assert_eq!(verifier.send_awaits.load(Ordering::SeqCst), 0); + assert_eq!(executor.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn concurrent_claim_for_the_same_memo_is_rejected() { + let (release_tx, release_rx) = watch::channel(false); + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(Vec::new()), + release: Some(release_rx), + }); + let orchestrator = Arc::new(orchestrator( + plans, + Arc::new(MockVerifier::default()), + executor, + )); + + let first = tokio::spawn({ + let orchestrator = Arc::clone(&orchestrator); + async move { orchestrator.claim_incoming(incoming()).await } + }); + // Let the first claim reach the stalled executor. + for _ in 0..32 { + tokio::task::yield_now().await; + } + assert_eq!( + orchestrator.claim_incoming(incoming()).await, + Err(ClaimError::AlreadyClaiming) + ); + release_tx.send(true).unwrap(); + first.await.unwrap().unwrap(); + assert!(orchestrator.claim_incoming(incoming()).await.is_ok()); + } + + #[tokio::test] + async fn failed_claim_stamps_error() { + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Err("CoinPayment: Unavailable".into()), + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::new(MockVerifier::default()), + executor, + ); + + let outcome = orchestrator.claim_incoming(incoming()).await; + assert_eq!( + outcome, + Err(ClaimError::Failed("CoinPayment: Unavailable".into())) + ); + assert_eq!( + plans.plan(&[7; 32]).await.unwrap().unwrap().status, + ClaimPlanStatus::Error + ); + assert_eq!(orchestrator.statuses.status("m1"), Some(ClaimStatus::Error)); + } +} diff --git a/rust/crates/truapi-coinage/src/claim_plan.rs b/rust/crates/truapi-coinage/src/claim_plan.rs new file mode 100644 index 000000000..31c0c22be --- /dev/null +++ b/rust/crates/truapi-coinage/src/claim_plan.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode}; + +const CLAIM_PLAN_DATA_V1_MAGIC: &[u8; 4] = b"CPV1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct CodableClaimPlanEntry { + pub entry_index: i16, + /// The destination coin's denomination exponent. + pub exponent: i16, + /// The destination coin's derivation index. + pub derivation_index: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimPlanStatus { + Processing, + /// Coins confirmed on-chain (outgoing: awaiting recipient claim; + /// incoming: ready to submit the claim extrinsic). + Detected, + Finished, + Error, +} + +impl ClaimPlanStatus { + pub fn as_raw(self) -> i64 { + match self { + ClaimPlanStatus::Processing => 0, + ClaimPlanStatus::Detected => 1, + ClaimPlanStatus::Finished => 2, + ClaimPlanStatus::Error => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => ClaimPlanStatus::Processing, + 1 => ClaimPlanStatus::Detected, + 2 => ClaimPlanStatus::Finished, + 3 => ClaimPlanStatus::Error, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimPlan { + pub memo_key: [u8; 32], + /// The chat message carrying the memo, when known. + pub message_id: Option, + pub entries: Vec, + pub outgoing_public_keys: Vec<[u8; 32]>, + /// Best-effort finalized snapshot captured before durable chat + /// acceptance. Exact/pass-through coins may already be visible here, + /// which provides historical evidence for an ultra-fast recipient claim. + pub detection_anchor: Option<[u8; 32]>, + pub status: ClaimPlanStatus, + pub claimed_amount: Option, + pub total_value: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct ClaimPlanDataV1 { + entries: Vec, + outgoing_public_keys: Vec<[u8; 32]>, + detection_anchor: Option<[u8; 32]>, +} + +/// SCALE-encode the entries blob for `claim_plans.entries_data`. +pub fn encode_claim_plan_entries(entries: &[CodableClaimPlanEntry]) -> Vec { + entries.encode() +} + +/// Decode an `entries_data` blob; errors on malformed or trailing bytes. +pub fn decode_claim_plan_entries(bytes: &[u8]) -> Result, String> { + let mut input = bytes; + let entries = Vec::::decode(&mut input) + .map_err(|error| format!("claim plan entries: {error}"))?; + if !input.is_empty() { + return Err("claim plan entries: trailing bytes".into()); + } + Ok(entries) +} + +/// Versioned payload stored in the legacy `entries_data` column. Old rows +/// contained only `Vec` and remain readable; new rows +/// append the outgoing monitor's public-only evidence without a schema +/// migration or secret-bearing data. +pub fn encode_claim_plan_data(plan: &ClaimPlan) -> Vec { + let mut encoded = CLAIM_PLAN_DATA_V1_MAGIC.to_vec(); + encoded.extend( + ClaimPlanDataV1 { + entries: plan.entries.clone(), + outgoing_public_keys: plan.outgoing_public_keys.clone(), + detection_anchor: plan.detection_anchor, + } + .encode(), + ); + encoded +} + +/// Decoded `claim_plans.entries_data` payload: the plan entries, the +/// outgoing public keys, and the optional detection anchor (both absent on +/// pre-v1 blobs). +pub type DecodedClaimPlanData = (Vec, Vec<[u8; 32]>, Option<[u8; 32]>); + +pub fn decode_claim_plan_data(bytes: &[u8]) -> Result { + let Some(payload) = bytes.strip_prefix(CLAIM_PLAN_DATA_V1_MAGIC) else { + return decode_claim_plan_entries(bytes).map(|entries| (entries, Vec::new(), None)); + }; + let mut input = payload; + let decoded = ClaimPlanDataV1::decode(&mut input) + .map_err(|error| format!("claim plan data v1: {error}"))?; + if !input.is_empty() { + return Err("claim plan data v1: trailing bytes".into()); + } + Ok(( + decoded.entries, + decoded.outgoing_public_keys, + decoded.detection_anchor, + )) +} + +#[async_trait] +pub trait ClaimPlanStore: Send + Sync { + /// Full save (insert or replace, re-encoding entries). + async fn save(&self, plan: &ClaimPlan) -> Result<(), String>; + + /// Lookup by memo key. + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String>; + + async fn load_all(&self) -> Result, String>; + + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String>; + + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_scale_layout_is_pinned() { + let entry = CodableClaimPlanEntry { + entry_index: 1, + exponent: -2, + derivation_index: 0x0403_0201, + }; + // i16 LE ++ i16 LE ++ u32 LE = 8 bytes, fixed. + assert_eq!(entry.encode(), vec![1, 0, 0xFE, 0xFF, 1, 2, 3, 4]); + } + + #[test] + fn entries_blob_round_trips() { + let entries = vec![ + CodableClaimPlanEntry { + entry_index: 0, + exponent: 3, + derivation_index: 7, + }, + CodableClaimPlanEntry { + entry_index: 1, + exponent: -1, + derivation_index: 8, + }, + ]; + let blob = encode_claim_plan_entries(&entries); + assert_eq!(decode_claim_plan_entries(&blob).unwrap(), entries); + assert!( + decode_claim_plan_entries(&[]).is_err(), + "empty blob is malformed" + ); + let mut trailing = blob.clone(); + trailing.push(0); + assert!(decode_claim_plan_entries(&trailing).is_err()); + } + + #[test] + fn versioned_plan_data_round_trips_and_legacy_rows_remain_readable() { + let plan = ClaimPlan { + memo_key: [1; 32], + message_id: Some("message".into()), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 3, + derivation_index: 7, + }], + outgoing_public_keys: vec![[8; 32], [9; 32]], + detection_anchor: Some([10; 32]), + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: 80, + }; + assert_eq!( + decode_claim_plan_data(&encode_claim_plan_data(&plan)).unwrap(), + ( + plan.entries.clone(), + plan.outgoing_public_keys.clone(), + plan.detection_anchor + ) + ); + + let legacy = encode_claim_plan_entries(&plan.entries); + assert_eq!( + decode_claim_plan_data(&legacy).unwrap(), + (plan.entries, Vec::new(), None) + ); + } + + #[test] + fn status_raw_round_trips() { + for status in [ + ClaimPlanStatus::Processing, + ClaimPlanStatus::Detected, + ClaimPlanStatus::Finished, + ClaimPlanStatus::Error, + ] { + assert_eq!(ClaimPlanStatus::from_raw(status.as_raw()), Some(status)); + } + assert_eq!(ClaimPlanStatus::from_raw(4), None); + } +} diff --git a/rust/crates/truapi-coinage/src/clock.rs b/rust/crates/truapi-coinage/src/clock.rs new file mode 100644 index 000000000..7c9df7c73 --- /dev/null +++ b/rust/crates/truapi-coinage/src/clock.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! The injected wall clock (unix milliseconds, the crate-wide timestamp +//! convention) — services never read system time directly, so tests pin +//! `now` deterministically. + +/// Milliseconds since the unix epoch. +pub trait Clock: Send + Sync { + fn now_ms(&self) -> i64; +} + +/// Production clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> i64 { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} + +/// Fixed clock for tests. +pub struct FixedClock(pub i64); + +impl Clock for FixedClock { + fn now_ms(&self) -> i64 { + self.0 + } +} diff --git a/rust/crates/truapi-coinage/src/constants.rs b/rust/crates/truapi-coinage/src/constants.rs new file mode 100644 index 000000000..a11326cc3 --- /dev/null +++ b/rust/crates/truapi-coinage/src/constants.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::time::Duration; + +pub const CASH_ASSET_PRECISION: u8 = 6; + +/// The shipped runtime's `Coinage.UnderlyingAssetUnit`: raw CASH planks per +/// user-facing cent, `10^(precision - 2)`. Live chain metadata remains the +/// authority wherever it is read — this is the pre-live default for display +/// projections (a fresh recipient renders a received transfer in chat before +/// any wallet flow has fetched the live constants; a placeholder of `1` +/// there showed a 2-CASH transfer as "20,000"). +pub const CASH_PLANKS_PER_CENT: u128 = 10u128.pow((CASH_ASSET_PRECISION - 2) as u32); + +pub const COIN_MAX_AGE: i16 = 16; + +/// Coins with `age >= RECYCLE_AT_AGE` are excluded from selection so they can +/// be recycled before reaching `COIN_MAX_AGE`. Equals `COIN_MAX_AGE - 2`. +pub const RECYCLE_AT_AGE: i16 = 14; + +pub const MINIMUM_RING_SIZE: u32 = 10; + +pub const WAL_MORTALITY_BLOCKS: u64 = 300; + +/// Longest a voucher may sit waiting before onboarding is abandoned +/// (`maxVoucherWaitTime`, 6 hours). +pub const MAX_VOUCHER_WAIT_TIME: Duration = Duration::from_secs(6 * 60 * 60); + +pub const SEND_VERIFY_BLOCK_TIMEOUT: u32 = 100; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_constants_are_pinned() { + assert_eq!(COIN_MAX_AGE, 16); + assert_eq!(RECYCLE_AT_AGE, 14); + assert_eq!(COIN_MAX_AGE - 2, RECYCLE_AT_AGE); + assert_eq!(MINIMUM_RING_SIZE, 10); + assert_eq!(WAL_MORTALITY_BLOCKS, 300); + assert_eq!(MAX_VOUCHER_WAIT_TIME, Duration::from_secs(21_600)); + assert_eq!(SEND_VERIFY_BLOCK_TIMEOUT, 100); + assert_eq!(CASH_ASSET_PRECISION, 6); + assert_eq!(CASH_PLANKS_PER_CENT, 10_000); + } +} diff --git a/rust/crates/truapi-coinage/src/denomination.rs b/rust/crates/truapi-coinage/src/denomination.rs new file mode 100644 index 000000000..4c4cd6e8b --- /dev/null +++ b/rust/crates/truapi-coinage/src/denomination.rs @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +/// One power-of-two denomination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Denomination { + pub exponent: i16, +} + +/// Greedy decomposition outcome: the denominations that fit, plus the +/// remainder below the smallest denomination (zero when the amount is +/// exactly representable). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DenominationBreakdown { + pub denominations: Vec, + pub remainder: u128, +} + +impl DenominationBreakdown { + pub fn is_exact(&self) -> bool { + self.remainder == 0 + } +} + +/// The pallet-constant context driving all denomination math. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DenominationBreakdownContext { + /// `UnderlyingAssetUnit` pallet constant, in planks. + pub asset_unit: u128, + /// `MaximumExponent` pallet constant. + pub max_exponent: i16, + /// `MinimumExponent` pallet constant (may be negative). + pub min_exponent: i16, + pub precision: u8, +} + +impl DenominationBreakdownContext { + /// Converts the UI/protocol CASH balance unit (one cent) into the raw + /// planks consumed by Coinage selection and emitted in transfer memos. + /// `UnderlyingAssetUnit` is exactly that cent in the active asset. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + cents.checked_mul(self.asset_unit) + } + + /// Converts raw Coinage planks back into whole CASH cents. A remainder + /// is rejected rather than rounded across a payment confirmation edge. + pub fn cash_cents_from_planks(&self, planks: u128) -> Option { + (self.asset_unit != 0 && planks.is_multiple_of(self.asset_unit)) + .then(|| planks / self.asset_unit) + } + + /// Denomination value in planks: `unit << exponent` for non-negative + /// exponents, `unit >> -exponent` for negative ones. Each direction + /// saturates (`u128::MAX` or `0`) rather than panicking on an absurd + /// shift amount. + pub fn value_in_planks(&self, exponent: i16) -> u128 { + if exponent >= 0 { + self.asset_unit + .checked_shl(u32::from(exponent as u16)) + .unwrap_or(u128::MAX) + } else { + let shift = u32::from(exponent.unsigned_abs()); + if shift >= 128 { + 0 + } else { + self.asset_unit >> shift + } + } + } + + /// Greedy binary decomposition from `max_exponent` down to `min_exponent`, + /// taking as many of each denomination as fit; anything below the + /// smallest denomination is returned as `remainder`. + pub fn breakdown(&self, amount_planks: u128) -> DenominationBreakdown { + let mut remaining = amount_planks; + let mut denominations = Vec::new(); + let mut exponent = self.max_exponent; + while exponent >= self.min_exponent { + let value = self.value_in_planks(exponent); + if value > 0 { + while remaining >= value { + denominations.push(Denomination { exponent }); + remaining -= value; + } + } + exponent -= 1; + } + DenominationBreakdown { + denominations, + remainder: remaining, + } + } + + /// Total planks of a denomination list. + pub fn total_value(&self, denominations: &[Denomination]) -> u128 { + denominations.iter().fold(0u128, |acc, d| { + acc.saturating_add(self.value_in_planks(d.exponent)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unit 10, exponents 0..=4 → denominations 10, 20, 40, 80, 160. + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + #[test] + fn value_in_planks_shifts_both_directions() { + let ctx = ctx(); + assert_eq!(ctx.value_in_planks(0), 10); + assert_eq!(ctx.value_in_planks(3), 80); + let fractional = DenominationBreakdownContext { + asset_unit: 16, + max_exponent: 2, + min_exponent: -2, + precision: 0, + }; + assert_eq!(fractional.value_in_planks(-1), 8); + assert_eq!(fractional.value_in_planks(-2), 4); + } + + #[test] + fn cash_cents_and_asset_planks_have_an_explicit_exact_boundary() { + let context = DenominationBreakdownContext { + asset_unit: 10_000, + max_exponent: 16, + min_exponent: 0, + precision: 6, + }; + + for (cash, cents, planks) in [ + (1, 100, 1_000_000), + (10, 1_000, 10_000_000), + (100, 10_000, 100_000_000), + (200, 20_000, 200_000_000), + ] { + assert_eq!( + context.cash_cents_to_planks(cents), + Some(planks), + "{cash} CASH" + ); + assert_eq!( + context.cash_cents_from_planks(planks), + Some(cents), + "{cash} CASH" + ); + } + assert_eq!(context.cash_cents_from_planks(9_999), None); + assert_eq!(context.cash_cents_to_planks(u128::MAX), None); + } + + #[test] + fn breakdown_of_known_amounts_is_greedy_largest_first() { + let ctx = ctx(); + // 230 = 160 + 40 + 20 + 10. + let b = ctx.breakdown(230); + assert_eq!( + b.denominations, + [ + Denomination { exponent: 4 }, + Denomination { exponent: 2 }, + Denomination { exponent: 1 }, + Denomination { exponent: 0 }, + ] + ); + assert!(b.is_exact()); + // 320 = 160 + 160 (repeated denominations allowed). + let b = ctx.breakdown(320); + assert_eq!( + b.denominations, + [Denomination { exponent: 4 }, Denomination { exponent: 4 }] + ); + assert!(b.is_exact()); + } + + #[test] + fn breakdown_reports_sub_denomination_remainder() { + let ctx = ctx(); + let b = ctx.breakdown(235); + assert_eq!(b.remainder, 5, "5 planks sit below the 10-plank unit"); + assert!(!b.is_exact()); + assert_eq!(ctx.total_value(&b.denominations), 230); + } + + #[test] + fn breakdown_of_zero_is_empty_and_exact() { + let b = ctx().breakdown(0); + assert!(b.denominations.is_empty()); + assert!(b.is_exact()); + } + + #[test] + fn negative_exponents_extend_below_the_unit() { + let ctx = DenominationBreakdownContext { + asset_unit: 16, + max_exponent: 1, + min_exponent: -2, + precision: 0, + }; + // 28 = 16 + 8 + 4. + let b = ctx.breakdown(28); + assert_eq!( + b.denominations, + [ + Denomination { exponent: 0 }, + Denomination { exponent: -1 }, + Denomination { exponent: -2 }, + ] + ); + assert!(b.is_exact()); + } + + /// Breakdown reconstructs: `total_value(breakdown(x)) + remainder == x` + /// across a sweep — the invariant coin allocation relies on. + #[test] + fn breakdown_reconstructs_every_amount() { + let ctx = ctx(); + for amount in 0..2_000u128 { + let b = ctx.breakdown(amount); + assert_eq!(ctx.total_value(&b.denominations) + b.remainder, amount); + assert!(b.remainder < ctx.value_in_planks(ctx.min_exponent)); + } + } +} diff --git a/rust/crates/truapi-coinage/src/index_store.rs b/rust/crates/truapi-coinage/src/index_store.rs new file mode 100644 index 000000000..00ad14289 --- /dev/null +++ b/rust/crates/truapi-coinage/src/index_store.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parking_lot::Mutex; +use std::collections::HashMap; + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode}; + +pub const COIN_INDEX_KEY: &str = "coin-index"; + +pub const VOUCHER_INDEX_KEY: &str = "voucher-index"; + +/// Which counter a call addresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IndexKind { + Coin, + Voucher, +} + +impl IndexKind { + /// The platform storage key this counter lives under. + pub fn storage_key(self) -> &'static str { + match self { + IndexKind::Coin => COIN_INDEX_KEY, + IndexKind::Voucher => VOUCHER_INDEX_KEY, + } + } +} + +pub fn encode_index(index: u32) -> Vec { + index.encode() +} + +/// Decode a stored counter; `None` on malformed bytes. +pub fn decode_index(bytes: &[u8]) -> Option { + let mut input = bytes; + let value = u32::decode(&mut input).ok()?; + input.is_empty().then_some(value) +} + +/// Durable, crash-safe monotonic counters. `get_next_index` is the +/// atomic read-increment-write allocation primitive: callers +/// (`CoinAllocator`/`VoucherAllocator`) receive each index exactly once. +#[async_trait] +pub trait CoinageIndexStore: Send + Sync { + async fn get_next_index(&self, kind: IndexKind) -> Result; + + /// The current high-water mark; `None` on a fresh install. + async fn current_index(&self, kind: IndexKind) -> Result, String>; + + /// Overwrites the counter (used for backup-recovery horizon writes): + /// only ever move it forward — a lower value re-issues already-used + /// indices and corrupts key derivation. + async fn set_index(&self, kind: IndexKind, index: u32) -> Result<(), String>; +} + +/// Test/dev impl over a mutex-guarded map. Also the executable spec of +/// the counter contract for the platform impls. +#[derive(Default)] +pub struct InMemoryCoinageIndexStore { + counters: Mutex>, +} + +#[async_trait] +impl CoinageIndexStore for InMemoryCoinageIndexStore { + async fn get_next_index(&self, kind: IndexKind) -> Result { + let mut counters = self.counters.lock(); + let next = match counters.get(&kind) { + None => 0, + Some(current) => current + .checked_add(1) + .ok_or_else(|| "derivation index space exhausted".to_string())?, + }; + counters.insert(kind, next); + Ok(next) + } + + async fn current_index(&self, kind: IndexKind) -> Result, String> { + Ok(self.counters.lock().get(&kind).copied()) + } + + async fn set_index(&self, kind: IndexKind, index: u32) -> Result<(), String> { + self.counters.lock().insert(kind, index); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fresh_counter_starts_at_zero_then_increments() { + let store = InMemoryCoinageIndexStore::default(); + assert_eq!(store.current_index(IndexKind::Coin).await.unwrap(), None); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 0); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 1); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 2); + assert_eq!(store.current_index(IndexKind::Coin).await.unwrap(), Some(2)); + } + + #[tokio::test] + async fn coin_and_voucher_counters_are_independent() { + let store = InMemoryCoinageIndexStore::default(); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 0); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 1); + assert_eq!(store.get_next_index(IndexKind::Voucher).await.unwrap(), 0); + assert_eq!( + store.current_index(IndexKind::Voucher).await.unwrap(), + Some(0) + ); + } + + #[tokio::test] + async fn horizon_write_moves_the_counter() { + let store = InMemoryCoinageIndexStore::default(); + store.set_index(IndexKind::Voucher, 41).await.unwrap(); + assert_eq!(store.get_next_index(IndexKind::Voucher).await.unwrap(), 42); + } + + /// Concurrent allocators must never observe the same index — the + /// atomicity contract platform impls have to uphold. + #[tokio::test] + async fn concurrent_allocation_yields_unique_indices() { + use std::collections::HashSet; + use std::sync::Arc; + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let mut handles = Vec::new(); + for _ in 0..64 { + let store = Arc::clone(&store); + handles.push(tokio::spawn(async move { + store.get_next_index(IndexKind::Coin).await.unwrap() + })); + } + let mut seen = HashSet::new(); + for handle in handles { + assert!(seen.insert(handle.await.unwrap()), "index issued twice"); + } + assert_eq!(seen.len(), 64); + } + + #[test] + fn index_codec_is_scale_u32() { + assert_eq!(encode_index(7), 7u32.encode()); + assert_eq!(decode_index(&encode_index(0xDEAD_BEEF)), Some(0xDEAD_BEEF)); + assert_eq!(decode_index(&[1, 2, 3]), None, "short read is malformed"); + assert_eq!( + decode_index(&[1, 2, 3, 4, 5]), + None, + "trailing bytes are malformed" + ); + } + + #[test] + fn storage_keys_are_pinned() { + assert_eq!(IndexKind::Coin.storage_key(), "coin-index"); + assert_eq!(IndexKind::Voucher.storage_key(), "voucher-index"); + } +} diff --git a/rust/crates/truapi-coinage/src/keys.rs b/rust/crates/truapi-coinage/src/keys.rs new file mode 100644 index 000000000..a219db060 --- /dev/null +++ b/rust/crates/truapi-coinage/src/keys.rs @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::Encode; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// The current iOS main purse; persisted indices must belong to this layout. +pub const MAIN_PURSE: u32 = u32::MAX; +/// Coinage currently derives all main-purse keys on page zero. +pub const PAGE: u32 = 0; + +pub const RECYCLER_ALIAS_CONTEXT: &[u8; 32] = b"pop:polkadot.network/coinrecyclr"; + +/// Derives sr25519 coins at `//coinage//4294967295//0/`: three hard +/// parent junctions and a soft item junction. The cached parent is zeroized on drop. +/// Callers must not reuse indices persisted under a different purse layout. +pub struct CoinKeypairFactory { + parent: Result, +} + +impl Zeroize for CoinKeypairFactory { + fn zeroize(&mut self) { + // Dropping the cached Schnorrkel keypair zeroizes its secret. Leave no + // usable parent after explicit clearing, and do not allocate on drop. + self.parent = Err(String::new()); + } +} + +impl Drop for CoinKeypairFactory { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl ZeroizeOnDrop for CoinKeypairFactory {} + +impl CoinKeypairFactory { + pub fn new(entropy: &[u8]) -> Self { + let parent = derive_sr25519_hard_path( + entropy, + &[ + junction_chain_code("coinage"), + junction_chain_code(u64::from(MAIN_PURSE)), + junction_chain_code(u64::from(PAGE)), + ], + ) + .map_err(|error| format!("coin key derivation: {error}")); + Self { parent } + } + + /// The full keypair for one coin index. + pub fn keypair(&self, index: u32) -> Result { + use rand::SeedableRng; + use schnorrkel::derive::{ChainCode, Derivation}; + + // Recovery derives thousands of children; expand BIP-39 and the three + // hard parents once per factory, not once per scanned index. + let parent = self.parent.as_ref().map_err(Clone::clone)?; + // Only the HDKD auxiliary randomness is fixed. Schnorrkel mixes the + // parent secret and nonce into its witness RNG; the scalar/public key + // retain standard soft derivation. This makes the complete exported + // 64-byte secret stable so a durable handoff replays the same memo. + // Signing continues to use Schnorrkel's ordinary randomized path. + Ok(parent + .derived_key_simple_rng( + ChainCode(junction_chain_code(u64::from(index))), + [], + rand_chacha::ChaCha20Rng::from_seed([0; 32]), + ) + .0) + } + + /// The coin's on-chain identity (`CoinsByOwner` storage key part). + pub fn public_key(&self, index: u32) -> Result<[u8; 32], String> { + Ok(self.keypair(index)?.public.to_bytes()) + } + + /// The raw 64-byte expanded secret — the exact layout + /// `schnorrkel::SecretKey::from_bytes` reconstructs a signer from. + pub fn secret_bytes(&self, index: u32) -> Result<[u8; 64], String> { + Ok(self.keypair(index)?.secret.to_bytes()) + } +} + +/// A derived voucher seed (32 bytes). Secret material: the Bandersnatch +/// secret key is expanded from it. Zeroized on drop. +#[derive(Zeroize, ZeroizeOnDrop, PartialEq, Eq)] +pub struct VoucherSeed(pub [u8; 32]); + +impl std::fmt::Debug for VoucherSeed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print seed bytes. + f.write_str("VoucherSeed(..)") + } +} + +/// Derives vouchers at `//coinage-ring-vrf//4294967295//0//`. +/// All four junctions are hard and fold directly over root entropy, without +/// BIP-39 expansion. Callers must keep persisted indices scoped to this layout. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct VoucherKeypairFactory { + entropy: Vec, +} + +impl VoucherKeypairFactory { + pub fn new(entropy: &[u8]) -> Self { + Self { + entropy: entropy.to_vec(), + } + } + + /// The chained seed for one voucher index. + pub fn seed(&self, index: u32) -> VoucherSeed { + let mut seed = VoucherSeed(keyed_blake2b_256( + &self.entropy, + &junction_chain_code("coinage-ring-vrf"), + )); + for junction in [MAIN_PURSE, PAGE, index] { + let chain_code = junction_chain_code(u64::from(junction)); + seed.0 = keyed_blake2b_256(&seed.0, &chain_code); + } + seed + } + + /// The member key used by Coinage and Members storage. + pub fn public_key( + &self, + index: u32, + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 32], String> { + crypto.member_key(&self.seed(index)) + } + + /// Ownership signature binding the member key to an input coin. + pub fn proof_of_ownership( + &self, + index: u32, + message: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 64], String> { + crypto.sign(&self.seed(index), message) + } + + /// The context-specific recycler alias, independent of the implication. + pub fn alias( + &self, + index: u32, + context: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 32], String> { + crypto.alias(&self.seed(index), context) + } + + /// Transaction-bound Bandersnatch membership proof at a finalized ring. + pub fn ring_vrf_proof( + &self, + index: u32, + ring_exponent: u8, + ring_members: &[[u8; 32]], + context: &[u8], + message: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result, String> { + crypto.ring_vrf_proof( + &self.seed(index), + ring_exponent, + ring_members, + context, + message, + ) + } +} + +/// SCALE-encoded Substrate junction → 32-byte chain code. Numeric path +/// components must be passed as `u64`, even though purse/page/item are `u32`. +/// Oversized encodings hash through BLAKE2b-256; short ones zero-pad. +fn junction_chain_code(junction: impl Encode) -> [u8; 32] { + let mut chain_code = [0u8; 32]; + if junction.encoded_size() > chain_code.len() { + chain_code = blake2b_256(&junction.encode()); + } else { + junction.encode_to(&mut &mut chain_code[..]); + } + chain_code +} + +fn keyed_blake2b_256(message: &[u8], key: &[u8]) -> [u8; 32] { + let mut params = blake2b_simd::Params::new(); + params.hash_length(32).key(key); + params + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +fn blake2b_256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoinDerivedWalletError { + /// The requesting signer's account id is not this coin's public key. + UnexpectedAccount, +} + +impl std::fmt::Display for CoinDerivedWalletError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "signer account does not match the coin key") + } +} + +impl std::error::Error for CoinDerivedWalletError {} + +pub struct CoinDerivedWallet { + keypair: schnorrkel::Keypair, +} + +impl CoinDerivedWallet { + pub fn new(keypair: schnorrkel::Keypair) -> Self { + Self { keypair } + } + + /// The wallet for one coin index. + pub fn for_index(factory: &CoinKeypairFactory, index: u32) -> Result { + Ok(Self::new(factory.keypair(index)?)) + } + + /// The coin's raw public key (== its sr25519 account id). + pub fn public_key(&self) -> [u8; 32] { + self.keypair.public.to_bytes() + } + + pub fn fetch_signer_secret( + &self, + signer_account_id: &[u8; 32], + ) -> Result<[u8; 64], CoinDerivedWalletError> { + if *signer_account_id != self.public_key() { + return Err(CoinDerivedWalletError::UnexpectedAccount); + } + Ok(self.keypair.secret.to_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENTROPY: [u8; 16] = [7u8; 16]; + + #[test] + fn coin_keys_are_distinct_per_index_and_deterministic() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let a0 = factory.public_key(0).unwrap(); + let a1 = factory.public_key(1).unwrap(); + assert_ne!(a0, a1, "indices must never collide"); + assert_eq!(a0, CoinKeypairFactory::new(&ENTROPY).public_key(0).unwrap()); + } + + #[test] + fn clearing_cached_coin_parent_prevents_further_derivation() { + let mut factory = CoinKeypairFactory::new(&ENTROPY); + factory.public_key(0).unwrap(); + factory.zeroize(); + assert!(factory.public_key(0).is_err()); + assert!(factory.secret_bytes(1).is_err()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn coin_keys_match_canonical_main_purse_with_soft_items() { + use subxt_signer::{SecretUri, bip39::Mnemonic, sr25519}; + + let mnemonic = Mnemonic::from_entropy(&ENTROPY).unwrap(); + for index in [0, u32::MAX] { + let soft_uri: SecretUri = format!("{mnemonic}//coinage//4294967295//0/{index}") + .parse() + .unwrap(); + let hard_uri: SecretUri = format!("{mnemonic}//coinage//4294967295//0//{index}") + .parse() + .unwrap(); + let actual = CoinKeypairFactory::new(&ENTROPY).keypair(index).unwrap(); + let expected = sr25519::Keypair::from_uri(&soft_uri).unwrap(); + assert_eq!(actual.public.to_bytes(), expected.public_key().0); + assert_ne!( + actual.public.to_bytes(), + sr25519::Keypair::from_uri(&hard_uri) + .unwrap() + .public_key() + .0, + "coin items must be soft, unlike voucher items" + ); + let message = b"coin main-purse signing regression"; + let signature = actual.sign_simple(b"substrate", message); + assert!(sr25519::verify( + &sr25519::Signature(signature.to_bytes()), + message, + &expected.public_key(), + )); + } + } + + #[test] + fn coin_secret_bytes_are_64_and_rebuild_the_keypair() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let secret = factory.secret_bytes(3).unwrap(); + let rebuilt = schnorrkel::SecretKey::from_bytes(&secret).unwrap(); + assert_eq!( + rebuilt.to_public().to_bytes(), + factory.public_key(3).unwrap(), + "memo secret bytes must reconstruct the coin's public identity" + ); + } + + #[test] + fn voucher_seeds_are_deterministic_and_distinct() { + let factory = VoucherKeypairFactory::new(&ENTROPY); + assert_eq!( + factory.seed(0), + VoucherKeypairFactory::new(&ENTROPY).seed(0) + ); + assert_ne!(factory.seed(0), factory.seed(1)); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn voucher_entropy_matches_canonical_full_path_fold() { + use blake2::{ + Blake2bMac, + digest::{KeyInit, Mac, consts::U32}, + }; + use subxt_signer::{DeriveJunction, SecretUri}; + + // Independent URI parser and BLAKE2 implementation: no production + // chain-code helper, path constants, or keyed-hash helper is reused. + let uri: SecretUri = "//coinage-ring-vrf//4294967295//0//5".parse().unwrap(); + let mut expected = ENTROPY.to_vec(); + for junction in uri.junctions { + let DeriveJunction::Hard(chain_code) = junction else { + panic!("voucher reference path must be entirely hard"); + }; + let mut hash = as KeyInit>::new_from_slice(&chain_code).unwrap(); + Mac::update(&mut hash, &expected); + expected = hash.finalize().into_bytes().to_vec(); + } + assert_eq!( + VoucherKeypairFactory::new(&ENTROPY).seed(5).0.as_slice(), + expected + ); + } + + /// The voucher path must not shadow the coin path from the same + /// entropy — different junction lists, different key material. + #[test] + fn voucher_path_diverges_from_coin_path() { + let coins = CoinKeypairFactory::new(&ENTROPY); + let vouchers = VoucherKeypairFactory::new(&ENTROPY); + assert_ne!(vouchers.seed(0).0, coins.secret_bytes(0).unwrap()[..32]); + } + + #[test] + fn coin_derived_wallet_guards_the_signer_secret() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let wallet = CoinDerivedWallet::for_index(&factory, 3).unwrap(); + let account_id = wallet.public_key(); + + let secret = wallet.fetch_signer_secret(&account_id).unwrap(); + let rebuilt = schnorrkel::SecretKey::from_bytes(&secret).unwrap(); + assert_eq!(rebuilt.to_public().to_bytes(), account_id); + + let mut wrong = account_id; + wrong[0] ^= 1; + assert_eq!( + wallet.fetch_signer_secret(&wrong), + Err(CoinDerivedWalletError::UnexpectedAccount) + ); + } +} + +/// Required exact Bandersnatch primitive implementation. The ring exponent is +/// the chain's member-count exponent (9/10/14), not the PCS exponent. +/// Adapters must use the deployed Bandersnatch suite; a different curve or +/// synthetic proof is not a valid implementation. Seeds must not be retained. +pub trait VoucherCryptography: Send + Sync { + /// Derive the Bandersnatch public member key. + fn member_key(&self, seed: &VoucherSeed) -> Result<[u8; 32], String>; + /// Sign an ownership message with the derived Bandersnatch key. + fn sign(&self, seed: &VoucherSeed, message: &[u8]) -> Result<[u8; 64], String>; + /// Derive the context-bound alias for this voucher. + fn alias(&self, seed: &VoucherSeed, context: &[u8]) -> Result<[u8; 32], String>; + /// Generate the canonical 785-byte ring-VRF proof. + fn ring_vrf_proof( + &self, + seed: &VoucherSeed, + ring_exponent: u8, + ring_members: &[[u8; 32]], + context: &[u8], + message: &[u8], + ) -> Result, String>; +} + +// Derived from MIT-licensed host-rust-core product_account.rs. Copyright +// (c) 2026 Parity Technologies. The complete MIT notice is in LICENSE-MIT. +fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[[u8; 32]], +) -> Result { + use schnorrkel::{ExpansionMode, derive::ChainCode}; + let mini_secret = substrate_bip39::mini_secret_from_entropy(entropy, "") + .map_err(|error| format!("invalid BIP-39 entropy: {error:?}"))?; + let mut keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + for junction in junctions { + let chain_code = ChainCode(*junction); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + +#[cfg(test)] +mod root_derivation_tests { + #[test] + fn entropy_expansion_matches_deployed_host() { + let root = super::derive_sr25519_hard_path(&[0xAB; 16], &[]).unwrap(); + assert_eq!( + hex::encode(root.public.to_bytes()), + "0062ba8ae929ea64bc2ad6f21359e96a29e236a41d376d1c5ba76491da94fc72" + ); + } +} diff --git a/rust/crates/truapi-coinage/src/lib.rs b/rust/crates/truapi-coinage/src/lib.rs new file mode 100644 index 000000000..0205af17f --- /dev/null +++ b/rust/crates/truapi-coinage/src/lib.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Host-owned Coinage domain engine, licensed AGPL-3.0-only. +//! +//! The signing authority owns this crate and every memo it creates. Nothing +//! here grants product permission or exports a secret through a guest API. +//! The Host must authorize each outgoing operation before `confirm`, persist +//! the encrypted transport handoff, and reconcile ambiguous outcomes. +//! +//! Durable adapters implement repositories, monotonic indices, WAL and claim +//! plans. Chain adapters provide pinned storage/finality and exact transaction +//! submission; Bandersnatch primitives are supplied by the signing authority. +//! `tokio::sync` supplies runtime-independent channels/locks only. Background +//! execution uses the injected [`Spawner`]; timers support native and WASM. +//! +//! Source and modification notices: `NOTICE`; full license: `LICENSE`. + +/// Allocator domain contracts and algorithms. +pub mod allocator; +/// Balance domain contracts and algorithms. +pub mod balance; +/// Claim domain contracts and algorithms. +pub mod claim; +/// Claim plan domain contracts and algorithms. +pub mod claim_plan; +/// Clock domain contracts and algorithms. +pub mod clock; +/// Constants domain contracts and algorithms. +pub mod constants; +/// Denomination domain contracts and algorithms. +pub mod denomination; +/// Index store domain contracts and algorithms. +pub mod index_store; +/// Keys domain contracts and algorithms. +pub mod keys; +/// Members domain contracts and algorithms. +pub mod members; +/// Memo domain contracts and algorithms. +pub mod memo; +/// Model domain contracts and algorithms. +pub mod model; +/// Outgoing transfer domain contracts and algorithms. +pub mod outgoing_transfer; +/// Pallet domain contracts and algorithms. +pub mod pallet; +/// Query domain contracts and algorithms. +pub mod query; +/// Recipient domain contracts and algorithms. +pub mod recipient; +/// Recovery domain contracts and algorithms. +pub mod recovery; +/// Repo domain contracts and algorithms. +pub mod repo; +/// Ring proof domain contracts and algorithms. +pub mod ring_proof; +/// Secret claim domain contracts and algorithms. +pub mod secret_claim; +/// Selection domain contracts and algorithms. +pub mod selection; +/// Sync domain contracts and algorithms. +pub mod sync; +/// Tasks domain contracts and algorithms. +pub mod tasks; +mod timer; +/// Transfer sender domain contracts and algorithms. +pub mod transfer_sender; +/// Tx extensions domain contracts and algorithms. +pub mod tx_extensions; +/// Voucher location domain contracts and algorithms. +pub mod voucher_location; +/// Crash-safe write-ahead journal contracts and encoding. +pub mod wal; + +/// Host executor used for all long-lived Coinage work. +pub type Spawner = std::sync::Arc) + Send + Sync>; + +pub use allocator::{ + CoinAllocator, FixedDelayProvider, SystemJitterDelayProvider, VoucherAllocator, + VoucherDelayProvider, +}; +pub use balance::{BalanceBuckets, compute_balance, next_unlock_at_ms}; +pub use claim::{ + ClaimError, ClaimExecutor, ClaimOrchestrator, ClaimStatus, ClaimStatusStore, IncomingClaim, + SendConfirmation, TransferSendVerifying, claimed_amount_from_plan, restore_persisted_statuses, +}; +pub use claim_plan::{ + ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry, decode_claim_plan_entries, + encode_claim_plan_entries, +}; +pub use clock::{Clock, FixedClock, SystemClock}; +pub use constants::{ + CASH_ASSET_PRECISION, CASH_PLANKS_PER_CENT, COIN_MAX_AGE, MAX_VOUCHER_WAIT_TIME, + MINIMUM_RING_SIZE, RECYCLE_AT_AGE, SEND_VERIFY_BLOCK_TIMEOUT, WAL_MORTALITY_BLOCKS, +}; +pub use denomination::{Denomination, DenominationBreakdown, DenominationBreakdownContext}; +pub use index_store::{ + COIN_INDEX_KEY, CoinageIndexStore, InMemoryCoinageIndexStore, IndexKind, VOUCHER_INDEX_KEY, + decode_index, encode_index, +}; +pub use keys::VoucherCryptography; +pub use keys::{ + CoinDerivedWallet, CoinKeypairFactory, MAIN_PURSE, PAGE, VoucherKeypairFactory, VoucherSeed, +}; +pub use memo::{MemoEntry, TransferMemo}; +pub use model::{ + Coin, CoinState, EffectivePrivacy, Voucher, VoucherLocalState, VoucherPrivacyLevel, + VoucherRemoteState, ring_readiness_upgraded, +}; +pub use outgoing_transfer::{ + OutgoingCoinTransferParts, OutgoingCoinTransferService, OutgoingHandoffRejected, + OutgoingTransferError, OutgoingTransferReconciliation, +}; +pub use query::{ + AliasState, CoinOnChainQueryService, CoinageQueryError, CoinageStorageKey, CoinageStorageQuery, + LockInfo, LockReason, QueryVoucherLocationSubscriber, RecyclerReadinessLoader, + RecyclerRevisionSnapshot, VoucherOnChainInfo, VoucherOnChainQueryService, +}; +pub use recipient::{CoinageSendMessage, TransferRecipientService}; +pub use recovery::{RecoveryChainProbe, RecoveryReport, TransferRecoveryService}; +pub use repo::{ + CoinRepository, InMemoryCoinRepository, InMemoryVoucherRepository, TransferContext, + TransferStateCommitter, VoucherRepository, +}; +pub use ring_proof::BandersnatchRingProofProvider; +pub use ring_proof::PersonRingProofSigner; +pub use ring_proof::{ + FREE_UNLOAD_TOKEN_CONTEXT_PREFIX, PersonOriginKind, RECYCLER_ALIAS_CONTEXT, RING_VRF_PROOF_LEN, + ResolvedUnloadToken, RingProofError, RingProofParams, RingProofProvider, UnloadProofRequest, + UnloadTokenProof, +}; +pub use secret_claim::{ + ExternalCoinTransferBackend, ExternalCoinTransferRequest, ExternalSecretClaimService, + SpentCoinTransferRecoveryReport, SpentCoinTransferRecoveryService, external_claim_message_id, +}; +pub use secret_claim::{ExternalMemoClaiming, SpentCoinsRecovering}; +pub use selection::{ + CoinSelectionError, CoinSelectionResult, CoinSelector, PrivacyLevel, RecyclerKey, + TransferStrategy, VoucherGroup, find_exact_match, +}; +pub use sync::{ + CoinStateSubscriber, CoinStateSyncService, CoinStateUpdate, CoinageDatabaseDependencyFactory, + NotifyingCoinRepository, NotifyingVoucherRepository, OnChainCoin, +}; +pub use tasks::ActiveTaskRegistry; +pub use transfer_sender::{ + OpaqueTransferPreview, PreparedUnloadGroup, RegularCoinTransferParts, + RegularCoinTransferService, RegularTransferError, RegularTransferSubmitter, + SplitTransferSubmission, TransferPreviewChoice, TransferPreviewStrategy, UnloadGroupDraft, + UnloadOriginPreparation, VoucherSelectionDiagnostic, voucher_selection_diagnostics, +}; +pub use voucher_location::{ + RingPosition, RingStatus, VoucherLocationService, VoucherLocationSubscriber, + VoucherLocationUpdate, +}; + +#[cfg(test)] +fn test_spawner() -> Spawner { + std::sync::Arc::new(|future| { + tokio::spawn(future); + }) +} + +pub use wal::{CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore}; diff --git a/rust/crates/truapi-coinage/src/members.rs b/rust/crates/truapi-coinage/src/members.rs new file mode 100644 index 000000000..43a8e88ee --- /dev/null +++ b/rust/crates/truapi-coinage/src/members.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-chain/src/pallets/members.rs. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! SCALE layouts used by Coinage recycler queries. +use parity_scale_codec::{Decode, Encode}; + +/// A ring collection's 32-byte identifier. +pub type CollectionIdentifier = [u8; 32]; + +/// A member's 32-byte ring-VRF (bandersnatch) public key. +pub type MemberKey = [u8; 32]; + +pub type RingIndex = u32; + +/// Where a member key currently sits within a collection +/// (`indiv_support::traits::reality::RingPosition`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum RingPosition { + #[codec(index = 0)] + Onboarding { queue_page: u32, queued_at: u64 }, + #[codec(index = 1)] + Included { + ring_index: u32, + ring_page: u32, + ring_position: u32, + }, + #[codec(index = 2)] + Suspended, +} + +/// `Members.RingKeysStatus` value (`indiv_support::…::RingStatus`). It +/// stores `total` and `included` (queued = `total - included`) plus the +/// timestamp the ring became immutable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct RingStatus { + pub total: u32, + pub included: u32, + /// Seconds timestamp once the ring stopped accepting keys. + pub immutable_since: Option, +} + +impl RingStatus { + /// Keys queued behind the proof set. + pub fn queued(&self) -> u32 { + self.total.saturating_sub(self.included) + } +} + +/// `Members.Root` value: the current ring root commitment. +/// +/// 2026-08 wipe (spec 1000032): the root commitment shrank from 768 to +/// 288 bytes (live value = 288 + 4 + 848 = 1140 bytes, probed via +/// `brevity-ffi/examples/ring_root_type_probe.rs`). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingRoot { + pub root: [u8; 288], + pub revision: u32, + pub intermediate: [u8; 848], +} diff --git a/rust/crates/truapi-coinage/src/memo.rs b/rust/crates/truapi-coinage/src/memo.rs new file mode 100644 index 000000000..57da358c7 --- /dev/null +++ b/rust/crates/truapi-coinage/src/memo.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use blake2::Blake2b; +use blake2::digest::Digest; +use blake2::digest::consts::U32; +use parity_scale_codec::{Compact, Decode, Encode, Input}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// One raw 64-byte coin secret key. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct MemoEntry(pub [u8; 64]); + +impl std::fmt::Debug for MemoEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MemoEntry(..)") + } +} + +/// The transfer memo: entries plus the expected total value in planks. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct TransferMemo { + pub entries: Vec, + pub total_value: u128, +} + +impl std::fmt::Debug for TransferMemo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TransferMemo {{ entries: {}, .. }}", self.entries.len()) + } +} + +impl TransferMemo { + /// Exact iOS `TransferMemo.encode(scaleEncoder:)` layout: SCALE + /// `Vec>` entries followed by a SCALE compact `BigUInt` + /// total. The returned bytes are secret material: callers must retain + /// them only in zeroizing buffers and must never log or expose them. + pub fn scale_encoded(&self) -> Vec { + let count = u32::try_from(self.entries.len()).expect("memo entry count fits SCALE u32"); + let mut encoded = + Vec::with_capacity(self.entries.len().saturating_mul(66).saturating_add(22)); + Compact(count).encode_to(&mut encoded); + for entry in &self.entries { + Compact(64u32).encode_to(&mut encoded); + encoded.extend_from_slice(&entry.0); + } + Compact(self.total_value).encode_to(&mut encoded); + encoded + } + + /// Strict inverse of [`Self::scale_encoded`]. Every entry must be one + /// 64-byte expanded sr25519 secret, and trailing bytes reject. + /// Partially decoded keys are wiped on every exit. + pub fn from_scale_encoded(bytes: &[u8]) -> Result { + let mut input = bytes; + let count = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo entry count decode failed: {error}"))? + .0 as usize; + if count > input.len() / 66 { + return Err("transfer memo entries exceed its encoded length".into()); + } + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + let length = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo entry length decode failed: {error}"))? + .0; + if length != 64 { + return Err(format!( + "transfer memo entry is {length} bytes; expected 64" + )); + } + let mut entry = MemoEntry([0; 64]); + input + .read(&mut entry.0) + .map_err(|error| format!("transfer memo entry decode failed: {error}"))?; + entries.push(entry); + } + let total_value = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo total decode failed: {error}"))? + .0; + if !input.is_empty() { + return Err("transfer memo has trailing bytes".into()); + } + Ok(Self { + entries, + total_value, + }) + } + + pub fn identifier(&self) -> [u8; 32] { + let value_be = self.total_value.to_be_bytes(); + let first_nonzero = value_be + .iter() + .position(|byte| *byte != 0) + .unwrap_or(value_be.len()); + let value = &value_be[first_nonzero..]; + let Some((first, rest)) = self.entries.split_first() else { + return blake2b_256(value); + }; + let mut acc = blake2b_256_keyed(&first.0, value); + for entry in rest { + acc = blake2b_256_keyed(&entry.0, &acc); + } + acc + } +} + +fn blake2b_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Blake2b::::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn blake2b_256_keyed(key: &[u8], data: &[u8]) -> [u8; 32] { + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(key) + .hash(data); + <[u8; 32]>::try_from(hash.as_bytes()).expect("hash_length is 32") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn memo() -> TransferMemo { + TransferMemo { + entries: vec![MemoEntry([1u8; 64]), MemoEntry([2u8; 64])], + total_value: 1_234_567, + } + } + + #[test] + fn identifier_is_deterministic() { + assert_eq!(memo().identifier(), memo().identifier()); + } + + #[test] + fn identifier_matches_the_reference_fold_layout() { + let memo = TransferMemo { + entries: vec![MemoEntry([0x11; 64]), MemoEntry([0x22; 64])], + total_value: 100, + }; + // BigUInt(100).serialize == [0x64] — one byte, not 16. + let step1 = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x11; 64]) + .hash(&[0x64]); + let step2 = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x22; 64]) + .hash(step1.as_bytes()); + assert_eq!( + memo.identifier(), + <[u8; 32]>::try_from(step2.as_bytes()).unwrap() + ); + + // BigUInt(0).serialize is EMPTY — the fold starts from zero bytes. + let zero = TransferMemo { + entries: vec![MemoEntry([0x11; 64])], + total_value: 0, + }; + let expected = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x11; 64]) + .hash(&[]); + assert_eq!( + zero.identifier(), + <[u8; 32]>::try_from(expected.as_bytes()).unwrap() + ); + } + + #[test] + fn scale_layout_matches_ios_vec_data_then_compact_biguint() { + let memo = TransferMemo { + entries: vec![MemoEntry([0xAB; 64])], + total_value: 1_000, + }; + let encoded = memo.scale_encoded(); + assert_eq!(encoded[0], 0x04, "one-entry Vec compact prefix"); + assert_eq!(&encoded[1..3], &[0x01, 0x01], "64-byte Data prefix"); + assert_eq!(&encoded[3..67], &[0xAB; 64]); + assert_eq!(&encoded[67..], &[0xA1, 0x0F], "compact 1000"); + + let decoded = TransferMemo::from_scale_encoded(&encoded).unwrap(); + assert_eq!(decoded.entries, memo.entries); + assert_eq!(decoded.total_value, memo.total_value); + } + + #[test] + fn scale_decode_rejects_wrong_key_lengths_and_trailing_bytes() { + let mut wrong = vec![vec![1u8; 63]].encode(); + Compact(1u128).encode_to(&mut wrong); + assert!(TransferMemo::from_scale_encoded(&wrong).is_err()); + + let mut trailing = memo().scale_encoded(); + trailing.push(0); + assert!(TransferMemo::from_scale_encoded(&trailing).is_err()); + } + + #[test] + fn identifier_binds_value_entries_and_order() { + let base = memo().identifier(); + let mut other = memo(); + other.total_value += 1; + assert_ne!(base, other.identifier(), "total value is bound"); + + let mut reordered = memo(); + reordered.entries.reverse(); + assert_ne!(base, reordered.identifier(), "entry order is bound"); + + let mut truncated = memo(); + truncated.entries.pop(); + assert_ne!(base, truncated.identifier(), "every entry is bound"); + } + + #[test] + fn zeroize_clears_entry_bytes() { + let mut entry = MemoEntry([0xAB; 64]); + entry.zeroize(); + assert_eq!(entry.0, [0u8; 64]); + } + + #[test] + fn zeroize_clears_the_whole_memo() { + let mut memo = memo(); + memo.zeroize(); + assert!(memo.entries.is_empty(), "entries are dropped and wiped"); + assert_eq!(memo.total_value, 0); + } + + #[test] + fn debug_never_prints_key_bytes() { + let rendered = format!("{:?} {:?}", memo(), MemoEntry([0xCD; 64])); + assert!(!rendered.contains("205"), "no decimal byte dump"); + assert!(!rendered.to_lowercase().contains("cd"), "no hex byte dump"); + assert_eq!(rendered, "TransferMemo { entries: 2, .. } MemoEntry(..)"); + } +} diff --git a/rust/crates/truapi-coinage/src/model.rs b/rust/crates/truapi-coinage/src/model.rs new file mode 100644 index 000000000..7082461d4 --- /dev/null +++ b/rust/crates/truapi-coinage/src/model.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::constants::{COIN_MAX_AGE, MINIMUM_RING_SIZE, RECYCLE_AT_AGE}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoinState { + Available, + /// A recycle-into-voucher extrinsic is in flight. + Recycling, + /// Reserved for an outgoing transfer (set before submission, + /// reverted on failure). + PendingTransfer, + Spent, +} + +impl CoinState { + pub fn can_transition_to(self, to: CoinState) -> bool { + use CoinState::*; + matches!( + (self, to), + (Available, Recycling) + | (Available, PendingTransfer) + | (Recycling, Spent) + | (Recycling, Available) + | (PendingTransfer, Spent) + | (PendingTransfer, Available) + | (Spent, Available) + ) + } + + /// Stable integer for the `coins.state` column. + pub fn as_raw(self) -> i64 { + match self { + CoinState::Available => 0, + CoinState::Recycling => 1, + CoinState::PendingTransfer => 2, + CoinState::Spent => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => CoinState::Available, + 1 => CoinState::Recycling, + 2 => CoinState::PendingTransfer, + 3 => CoinState::Spent, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Coin { + pub exponent: i16, + pub derivation_index: u32, + pub age: Option, + pub state: CoinState, +} + +impl Coin { + pub fn is_expiring_soon(&self) -> bool { + matches!(self.age, Some(age) if age >= RECYCLE_AT_AGE) + } + + pub fn is_chain_invalid(&self) -> bool { + matches!(self.age, Some(age) if age >= COIN_MAX_AGE) + } + + /// Eligible for transfer selection: available and not expiring soon. + pub fn is_selectable(&self) -> bool { + self.state == CoinState::Available && !self.is_expiring_soon() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherRemoteState { + /// Not yet observed anywhere on-chain (also the state of freshly + /// recovered vouchers before `VoucherLocationService` reconciles). + Unlocated, + /// Ring membership submitted, not yet included. + Onboarding, + /// Included in a recycler ring at this index. + InRecycler { recycler_index: u32 }, + /// Consumed by an unload extrinsic. + Unloaded, +} + +impl VoucherRemoteState { + pub fn is_in_recycler(&self) -> bool { + matches!(self, VoucherRemoteState::InRecycler { .. }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherLocalState { + Available, + PendingTransfer, + PendingOnboarding, + Spent, +} + +impl VoucherLocalState { + pub fn as_raw(self) -> i64 { + match self { + VoucherLocalState::Available => 0, + VoucherLocalState::PendingTransfer => 1, + VoucherLocalState::PendingOnboarding => 2, + VoucherLocalState::Spent => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => VoucherLocalState::Available, + 1 => VoucherLocalState::PendingTransfer, + 2 => VoucherLocalState::PendingOnboarding, + 3 => VoucherLocalState::Spent, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherPrivacyLevel { + Degraded, + Full, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectivePrivacy { + Degraded, + Full, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Voucher { + pub exponent: i16, + pub derivation_index: u32, + pub allocated_at_ms: i64, + pub ready_at_ms: i64, + pub remote_state: VoucherRemoteState, + pub local_state: VoucherLocalState, + pub privacy: VoucherPrivacyLevel, +} + +impl Voucher { + pub fn effective_privacy(&self, now_ms: i64) -> EffectivePrivacy { + if self.privacy == VoucherPrivacyLevel::Full && now_ms >= self.ready_at_ms { + EffectivePrivacy::Full + } else { + EffectivePrivacy::Degraded + } + } + + /// Spendable through an unload strategy: locally available and + /// on-chain in a recycler. + pub fn is_unloadable(&self) -> bool { + self.local_state == VoucherLocalState::Available && self.remote_state.is_in_recycler() + } +} + +pub fn ring_readiness_upgraded(included_members: u32) -> bool { + included_members >= MINIMUM_RING_SIZE +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coin(age: Option, state: CoinState) -> Coin { + Coin { + exponent: 0, + derivation_index: 1, + age, + state, + } + } + + #[test] + fn coin_state_machine_progression() { + use CoinState::*; + // The forward paths. + assert!(Available.can_transition_to(Recycling)); + assert!(Available.can_transition_to(PendingTransfer)); + assert!(Recycling.can_transition_to(Spent)); + assert!(PendingTransfer.can_transition_to(Spent)); + assert!(Recycling.can_transition_to(Available)); + assert!(PendingTransfer.can_transition_to(Available)); + assert!(Spent.can_transition_to(Available)); + // Undocumented jumps are rejected. + assert!(!Available.can_transition_to(Spent)); + assert!(!Available.can_transition_to(Available)); + assert!(!Spent.can_transition_to(Recycling)); + assert!(!Spent.can_transition_to(PendingTransfer)); + assert!(!Recycling.can_transition_to(PendingTransfer)); + assert!(!PendingTransfer.can_transition_to(Recycling)); + } + + #[test] + fn coin_state_raw_round_trips() { + for state in [ + CoinState::Available, + CoinState::Recycling, + CoinState::PendingTransfer, + CoinState::Spent, + ] { + assert_eq!(CoinState::from_raw(state.as_raw()), Some(state)); + } + assert_eq!(CoinState::from_raw(9), None); + } + + #[test] + fn expiring_soon_at_the_recycle_age_boundary() { + assert!(!coin(Some(13), CoinState::Available).is_expiring_soon()); + assert!(coin(Some(14), CoinState::Available).is_expiring_soon()); + assert!(coin(Some(16), CoinState::Available).is_expiring_soon()); + assert!(!coin(None, CoinState::Available).is_expiring_soon()); + } + + #[test] + fn chain_invalid_at_max_age() { + assert!(!coin(Some(15), CoinState::Available).is_chain_invalid()); + assert!(coin(Some(16), CoinState::Available).is_chain_invalid()); + } + + #[test] + fn selectable_excludes_expiring_and_non_available() { + assert!(coin(Some(13), CoinState::Available).is_selectable()); + assert!(!coin(Some(14), CoinState::Available).is_selectable()); + assert!(!coin(Some(1), CoinState::Recycling).is_selectable()); + assert!(!coin(Some(1), CoinState::PendingTransfer).is_selectable()); + assert!(!coin(Some(1), CoinState::Spent).is_selectable()); + } + + fn voucher(privacy: VoucherPrivacyLevel, ready_at_ms: i64) -> Voucher { + Voucher { + exponent: 0, + derivation_index: 1, + allocated_at_ms: 0, + ready_at_ms, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 0 }, + local_state: VoucherLocalState::Available, + privacy, + } + } + + #[test] + fn full_privacy_voucher_is_degraded_before_ready_at() { + let v = voucher(VoucherPrivacyLevel::Full, 1_000); + assert_eq!(v.effective_privacy(999), EffectivePrivacy::Degraded); + assert_eq!(v.effective_privacy(1_000), EffectivePrivacy::Full); + assert_eq!(v.effective_privacy(2_000), EffectivePrivacy::Full); + } + + #[test] + fn degraded_voucher_never_upgrades_by_time() { + let v = voucher(VoucherPrivacyLevel::Degraded, 1_000); + assert_eq!(v.effective_privacy(i64::MAX), EffectivePrivacy::Degraded); + } + + #[test] + fn ring_readiness_upgrades_exactly_at_the_minimum() { + assert!(!ring_readiness_upgraded(MINIMUM_RING_SIZE - 1)); + assert!(ring_readiness_upgraded(MINIMUM_RING_SIZE)); + assert!(ring_readiness_upgraded(MINIMUM_RING_SIZE + 1)); + } + + #[test] + fn minimum_ring_size_is_ten() { + assert_eq!(MINIMUM_RING_SIZE, 10); + } + + #[test] + fn unloadable_requires_local_available_and_in_recycler() { + let mut v = voucher(VoucherPrivacyLevel::Full, 0); + assert!(v.is_unloadable()); + v.remote_state = VoucherRemoteState::Onboarding; + assert!(!v.is_unloadable()); + v.remote_state = VoucherRemoteState::InRecycler { recycler_index: 2 }; + v.local_state = VoucherLocalState::PendingTransfer; + assert!(!v.is_unloadable()); + } +} diff --git a/rust/crates/truapi-coinage/src/outgoing_transfer.rs b/rust/crates/truapi-coinage/src/outgoing_transfer.rs new file mode 100644 index 000000000..03b6b9b7e --- /dev/null +++ b/rust/crates/truapi-coinage/src/outgoing_transfer.rs @@ -0,0 +1,922 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +use futures::future::AbortHandle; +use tokio::sync::Mutex as AsyncMutex; +use tracing::warn; + +use crate::clock::Clock; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState}; +use crate::query::CoinOnChainQueryService; +use crate::repo::{CoinRepository, TransferContext, VoucherRepository}; +use crate::selection::{CoinSelectionError, CoinSelector, TransferStrategy}; +use crate::wal::{ + CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore, +}; + +/// Dependencies that are stable for one active signing session. +pub struct OutgoingCoinTransferParts { + pub spawner: crate::Spawner, + pub coins: Arc, + pub vouchers: Arc, + pub wal: Arc, + pub on_chain: Arc, + pub denominations: DenominationBreakdownContext, + pub clock: Arc, + /// Finalized heads allowed for the opportunistic live settlement watch. + /// A timeout retains the durable reservation for a later reconciliation. + pub settlement_timeout_heads: u32, +} + +/// The handoff returned a definitive *not accepted* result. +/// This is deliberately a unit type. Transport diagnostics belong at the +/// adapter boundary; carrying an arbitrary error string through the +/// key-owning service makes accidental secret interpolation much easier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct OutgoingHandoffRejected; + +impl std::fmt::Display for OutgoingHandoffRejected { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("outgoing coin memo was rejected before acceptance") + } +} + +impl std::error::Error for OutgoingHandoffRejected {} + +/// Typed outgoing-transfer failures. No variant contains memo bytes or raw +/// coin keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutgoingTransferError { + Selection(CoinSelectionError), + /// The amount is representable and funded only by an on-chain split or + /// voucher unload. This service intentionally performs neither. + RequiresOnChainPreparation, + Query(String), + KeyDerivation(String), + Reservation(String), + Journal(String), + HandoffRejected, + /// An explicit pre-handoff rejection was received, but the durable + /// reservation could not be completely removed. Funds remain reserved. + Rollback(String), +} + +impl std::fmt::Display for OutgoingTransferError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Selection(error) => write!(formatter, "{error}"), + Self::RequiresOnChainPreparation => formatter + .write_str("exact whole coins are unavailable; on-chain preparation is required"), + Self::Query(error) => write!(formatter, "coin query failed: {error}"), + Self::KeyDerivation(error) => write!(formatter, "coin key derivation failed: {error}"), + Self::Reservation(error) => write!(formatter, "coin reservation failed: {error}"), + Self::Journal(error) => write!(formatter, "coin transfer journal failed: {error}"), + Self::HandoffRejected => { + formatter.write_str("outgoing coin memo was rejected before acceptance") + } + Self::Rollback(error) => { + write!( + formatter, + "outgoing coin reservation rollback failed: {error}" + ) + } + } + } +} + +impl std::error::Error for OutgoingTransferError {} + +impl From for OutgoingTransferError { + fn from(error: CoinSelectionError) -> Self { + Self::Selection(error) + } +} + +/// Outcome of one startup/manual pending-transfer reconciliation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OutgoingTransferReconciliation { + pub confirmed_spent: Vec, + pub still_pending: Vec, + pub completed_journals: usize, +} + +/// Outgoing transfer engine tied to exactly one root entropy. +/// Construct a fresh instance on signing-session activation and drop (or +/// [`shutdown`](Self::shutdown)) it on session teardown. +pub struct OutgoingCoinTransferService { + spawner: crate::Spawner, + key_factory: Arc, + coins: Arc, + vouchers: Arc, + wal: Arc, + on_chain: Arc, + denominations: DenominationBreakdownContext, + clock: Arc, + settlement_timeout_heads: u32, + handoff_lock: AsyncMutex<()>, + settlement_tasks: Mutex>, +} + +impl OutgoingCoinTransferService { + pub fn new(root_entropy: &[u8], parts: OutgoingCoinTransferParts) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins: parts.coins, + vouchers: parts.vouchers, + wal: parts.wal, + on_chain: parts.on_chain, + denominations: parts.denominations, + clock: parts.clock, + settlement_timeout_heads: parts.settlement_timeout_heads.max(1), + handoff_lock: AsyncMutex::new(()), + settlement_tasks: Mutex::new(Vec::new()), + spawner: parts.spawner, + } + } + + /// Converts a W3S/UI CASH-cent amount to the raw planks required by the + /// exact-coin selector. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + self.denominations.cash_cents_to_planks(cents) + } + + pub fn planks_per_cash_cent(&self) -> u128 { + self.denominations.asset_unit + } + + /// Selects and hands off exact whole coins. + /// `handoff` owns the memo and must return `Err` **only** when it can + /// certify that no recipient/transport durably accepted the secret. If + /// acceptance is ambiguous (timeout, cancellation after submit, lost + /// acknowledgement), it must return `Ok`; the normal payment/chat + /// tracker can report the transport uncertainty while the coin + /// reservation remains safe. + /// + /// For a host-correlated payment with durable preparation/restart + /// receipts, use `RegularCoinTransferService::confirm_operation`, which + /// also handles exact whole-coin transfers without chain preparation. + pub async fn handoff_exact( + &self, + amount_planks: u128, + handoff: F, + ) -> Result<(), OutgoingTransferError> + where + F: FnOnce(TransferMemo) -> Fut, + Fut: Future>, + { + // Selection + reservation is serialized per session so two callers + // cannot observe the same pre-reservation snapshot. + let _guard = self.handoff_lock.lock().await; + let present = self.present_local_coins().await?; + let selection = CoinSelector::new(self.denominations.clone(), usize::MAX).select( + amount_planks, + &present, + &[], + self.clock.now_ms(), + )?; + let selected = match selection.strategy { + TransferStrategy::ExactMatch { coins } => coins, + TransferStrategy::Split { .. } | TransferStrategy::UnloadIntoCoins { .. } => { + return Err(OutgoingTransferError::RequiresOnChainPreparation); + } + }; + + let indices = selected + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + let public_keys = indices + .iter() + .map(|index| { + self.key_factory + .public_key(*index) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let entries = indices + .iter() + .map(|index| { + self.key_factory + .secret_bytes(*index) + .map(MemoEntry) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let memo = TransferMemo { + entries, + total_value: amount_planks, + }; + let memo_identifier = memo.identifier(); + let entry_id = format!("secret-handoff-{}", hex::encode(memo_identifier)); + let journal = TransferWalEntry { + entry_id: entry_id.clone(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: selected + .iter() + .map(|coin| WalCoinRef { + derivation_index: coin.derivation_index, + exponent: coin.exponent, + }) + .collect(), + ..WalPayload::default() + }, + // Secret handoffs ignore extrinsic mortality. Pending remains + // the truthful checkpoint because no extrinsic is broadcast. + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }; + + let context = Arc::new(TransferContext::new( + Arc::clone(&self.coins), + Arc::clone(&self.vouchers), + )); + if let Err(error) = context.reserve(&indices, &[]).await { + let rollback = context.revert().await; + return Err(match rollback { + Ok(()) => OutgoingTransferError::Reservation(error), + Err(rollback) => OutgoingTransferError::Rollback(format!( + "reserve error: {error}; state restore error: {rollback}" + )), + }); + } + if let Err(error) = self.wal.save(&journal).await { + // A local persistence failure is expected to mean no row, but + // delete the deterministic id first to close an ambiguous + // commit edge before making the inputs selectable again. + if let Err(cleanup) = self.wal.delete(&entry_id).await { + return Err(OutgoingTransferError::Rollback(format!( + "journal error: {error}; journal cleanup error: {cleanup}; inputs retained" + ))); + } + return match context.revert().await { + Ok(()) => Err(OutgoingTransferError::Journal(error)), + Err(rollback) => Err(OutgoingTransferError::Rollback(format!( + "journal error: {error}; state restore error: {rollback}" + ))), + }; + } + + if handoff(memo).await.is_err() { + // Delete protection before restoring availability. The opposite + // order leaves a crash window with an Available coin still + // referenced by an indefinite secret-handoff journal. + if let Err(error) = self.wal.delete(&entry_id).await { + return Err(OutgoingTransferError::Rollback(format!( + "handoff rejected; journal cleanup error: {error}; inputs retained" + ))); + } + if let Err(error) = context.revert().await { + return Err(OutgoingTransferError::Rollback(format!( + "handoff rejected; state restore error: {error}" + ))); + } + return Err(OutgoingTransferError::HandoffRejected); + } + + self.spawn_settlement_watch(context, indices, public_keys, entry_id); + Ok(()) + } + + /// Reconciles every durable secret-handoff entry in one ordered batch. + /// Absent inputs become `Spent`; present inputs remain (or are restored + /// to) `PendingTransfer`. A journal is removed only when all its inputs + /// are absent. + pub async fn reconcile_pending_transfers( + &self, + ) -> Result { + let journals = self + .wal + .load_all() + .await + .map_err(OutgoingTransferError::Journal)? + .into_iter() + // Correlated operations have a durable parent and are reconciled + // by TransferRecoveryService. In particular, Prepared must not be + // mistaken for a proven accepted handoff here. + .filter(|entry| { + entry.operation == WalOperation::SecretHandoff + && entry.operation_parent_id().is_none() + }) + .collect::>(); + if journals.is_empty() { + return Ok(OutgoingTransferReconciliation::default()); + } + + let mut references = Vec::new(); + for (journal_index, journal) in journals.iter().enumerate() { + for input in &journal.payload.input_coins { + let public_key = self + .key_factory + .public_key(input.derivation_index) + .map_err(OutgoingTransferError::KeyDerivation)?; + references.push((journal_index, input.derivation_index, public_key)); + } + } + let public_keys = references + .iter() + .map(|(_, _, key)| *key) + .collect::>(); + let remote = self + .on_chain + .fetch_coins(&public_keys, None) + .await + .map_err(|error| OutgoingTransferError::Query(error.to_string()))?; + if remote.len() != references.len() { + return Err(OutgoingTransferError::Query( + "coin query returned an incomplete batch".into(), + )); + } + let local_states = self + .coins + .list() + .await + .map_err(OutgoingTransferError::Reservation)? + .into_iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(); + + let mut report = OutgoingTransferReconciliation::default(); + let mut journal_has_present = vec![false; journals.len()]; + for ((journal_index, derivation_index, _), reading) in references.into_iter().zip(remote) { + if reading.is_none() { + if local_states.get(&derivation_index) != Some(&CoinState::Spent) { + self.coins + .set_state(derivation_index, CoinState::Spent) + .await + .map_err(OutgoingTransferError::Reservation)?; + } + report.confirmed_spent.push(derivation_index); + } else { + journal_has_present[journal_index] = true; + if local_states.get(&derivation_index) == Some(&CoinState::Available) { + self.coins + .set_state(derivation_index, CoinState::PendingTransfer) + .await + .map_err(OutgoingTransferError::Reservation)?; + } + report.still_pending.push(derivation_index); + } + } + for (journal, has_present) in journals.iter().zip(journal_has_present) { + if !has_present && !journal.payload.input_coins.is_empty() { + self.wal + .delete(&journal.entry_id) + .await + .map_err(OutgoingTransferError::Journal)?; + report.completed_journals += 1; + } + } + report.confirmed_spent.sort_unstable(); + report.confirmed_spent.dedup(); + report.still_pending.sort_unstable(); + report.still_pending.dedup(); + Ok(report) + } + + /// Aborts live finalized-head waits. Durable journal rows and + /// reservations deliberately remain for the next session startup. + pub fn shutdown(&self) { + for task in self.settlement_tasks.lock().drain(..) { + task.abort(); + } + } + + async fn present_local_coins(&self) -> Result, OutgoingTransferError> { + let local = self + .coins + .list() + .await + .map_err(OutgoingTransferError::Reservation)?; + let candidates = local + .into_iter() + .filter(|coin| coin.is_selectable()) + .collect::>(); + let public_keys = candidates + .iter() + .map(|coin| { + self.key_factory + .public_key(coin.derivation_index) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let remote = self + .on_chain + .fetch_coins(&public_keys, None) + .await + .map_err(|error| OutgoingTransferError::Query(error.to_string()))?; + if remote.len() != candidates.len() { + return Err(OutgoingTransferError::Query( + "coin query returned an incomplete batch".into(), + )); + } + Ok(candidates + .into_iter() + .zip(remote) + .filter_map(|(mut local, remote)| { + let remote = remote?; + local.exponent = remote.exponent; + local.age = Some(remote.age); + local.is_selectable().then_some(local) + }) + .collect()) + } + + fn spawn_settlement_watch( + &self, + context: Arc, + indices: Vec, + public_keys: Vec<[u8; 32]>, + entry_id: String, + ) { + let on_chain = Arc::clone(&self.on_chain); + let wal = Arc::clone(&self.wal); + let timeout = self.settlement_timeout_heads; + let task = crate::tasks::spawn_abortable(&self.spawner, async move { + match on_chain + .await_all_coins_off_chain(&public_keys, timeout) + .await + { + Ok(()) => { + if let Err(error) = context.process(&indices, &[]).await { + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement could not persist spent inputs" + ); + return; + } + if let Err(error) = wal.delete(&entry_id).await { + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement could not clear its journal" + ); + } + } + Err(error) => { + // Never revert after handoff. Startup/manual recovery + // can retry with a fresh connection. + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement watch ended; reservation retained" + ); + } + } + }); + self.settlement_tasks.lock().push(task); + } +} + +impl Drop for OutgoingCoinTransferService { + fn drop(&mut self) { + for task in self.settlement_tasks.get_mut().drain(..) { + task.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use async_trait::async_trait; + use futures::StreamExt; + use futures::channel::mpsc; + use futures::stream::{self, BoxStream}; + use parity_scale_codec::Encode; + + use super::*; + use crate::clock::FixedClock; + use crate::query::{CoinageStorageKey, CoinageStorageQuery}; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + const ENTROPY: [u8; 16] = [0x17; 16]; + type HeadReceiver = mpsc::UnboundedReceiver>; + + #[derive(Default)] + struct MemWal { + entries: Mutex>, + } + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.entries.lock(); + if entries.iter().any(|saved| saved.entry_id == entry.entry_id) { + return Err("duplicate journal".into()); + } + entries.push(entry.clone()); + Ok(()) + } + + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + for entry in entries { + self.save(entry).await?; + } + Ok(()) + } + + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.entries.lock(); + let entry = entries + .iter_mut() + .find(|entry| entry.entry_id == entry_id) + .ok_or_else(|| "journal not found".to_string())?; + entry.checkpoint = checkpoint; + Ok(()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self.entries.lock().clone()) + } + + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.entries + .lock() + .retain(|entry| entry.entry_id != entry_id); + Ok(()) + } + } + + struct TestStorage { + values: Mutex>>, + heads: Mutex>, + } + + impl Default for TestStorage { + fn default() -> Self { + Self { + values: Mutex::new(HashMap::new()), + heads: Mutex::new(None), + } + } + } + + impl TestStorage { + fn set_coin(&self, public_key: [u8; 32], exponent: i8, age: u16) { + self.values.lock().insert( + CoinageStorageKey::Coin(public_key), + (exponent, age).encode(), + ); + } + + fn remove_coin(&self, public_key: [u8; 32]) { + self.values + .lock() + .remove(&CoinageStorageKey::Coin(public_key)); + } + + fn head_channel(&self) -> mpsc::UnboundedSender> { + let (sender, receiver) = mpsc::unbounded(); + *self.heads.lock() = Some(receiver); + sender + } + } + + #[async_trait] + impl CoinageStorageQuery for TestStorage { + async fn query( + &self, + keys: &[CoinageStorageKey], + _at: Option<[u8; 32]>, + ) -> Result>>, String> { + let values = self.values.lock(); + Ok(keys.iter().map(|key| values.get(key).cloned()).collect()) + } + + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + self.heads + .lock() + .take() + .map(StreamExt::boxed) + .unwrap_or_else(|| stream::pending().boxed()) + } + } + + struct Harness { + service: OutgoingCoinTransferService, + coins: Arc, + wal: Arc, + storage: Arc, + } + + fn coin(index: u32) -> Coin { + Coin { + // Deliberately stale: selection must use the on-chain value. + exponent: 0, + derivation_index: index, + age: None, + state: CoinState::Available, + } + } + + fn denominations() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 6, + min_exponent: 0, + precision: 2, + } + } + + fn harness(local: Vec) -> Harness { + let coins = Arc::new(InMemoryCoinRepository::with_coins(local)); + let wal = Arc::new(MemWal::default()); + let storage = Arc::new(TestStorage::default()); + let service = OutgoingCoinTransferService::new( + &ENTROPY, + OutgoingCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers: Arc::new(InMemoryVoucherRepository::default()), + wal: Arc::clone(&wal) as Arc<_>, + on_chain: Arc::new(CoinOnChainQueryService::new(Arc::clone(&storage) as Arc<_>)), + denominations: denominations(), + clock: Arc::new(FixedClock(1_234)), + settlement_timeout_heads: 3, + }, + ); + Harness { + service, + coins, + wal, + storage, + } + } + + async fn state(coins: &InMemoryCoinRepository, index: u32) -> CoinState { + coins + .list() + .await + .unwrap() + .into_iter() + .find(|coin| coin.derivation_index == index) + .unwrap() + .state + } + + #[tokio::test] + async fn exact_on_chain_selection_reserves_before_handoff_and_settles_on_absence() { + let harness = harness(vec![coin(1), coin(2), coin(3)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(1).unwrap(), 2, 1); // 40 + harness.storage.set_coin(keys.public_key(2).unwrap(), 1, 1); // 20 + harness.storage.set_coin(keys.public_key(3).unwrap(), 0, 1); // 10 + let heads = harness.storage.head_channel(); + let observed = Arc::new(Mutex::new(None)); + + harness + .service + .handoff_exact(60, { + let observed = Arc::clone(&observed); + let coins = Arc::clone(&harness.coins); + move |memo| async move { + assert_eq!(state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(state(&coins, 2).await, CoinState::PendingTransfer); + assert_eq!(state(&coins, 3).await, CoinState::Available); + *observed.lock() = Some(( + memo.total_value, + memo.entries.iter().map(|entry| entry.0).collect::>(), + )); + Ok(()) + } + }) + .await + .unwrap(); + + let observed = observed.lock().take().unwrap(); + assert_eq!(observed.0, 60); + assert_eq!( + observed.1, + vec![keys.secret_bytes(1).unwrap(), keys.secret_bytes(2).unwrap()] + ); + let journal = harness.wal.load_all().await.unwrap(); + assert_eq!(journal.len(), 1); + assert_eq!(journal[0].operation, WalOperation::SecretHandoff); + assert_eq!(journal[0].created_at_ms, 1_234); + + harness.storage.remove_coin(keys.public_key(1).unwrap()); + harness.storage.remove_coin(keys.public_key(2).unwrap()); + heads.unbounded_send(Ok([9; 32])).unwrap(); + for _ in 0..40 { + if state(&harness.coins, 1).await == CoinState::Spent { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(state(&harness.coins, 1).await, CoinState::Spent); + assert_eq!(state(&harness.coins, 2).await, CoinState::Spent); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_insufficient_nonrepresentable_and_preparation_only_amounts() { + let harness = harness(vec![coin(1)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(1).unwrap(), 2, 1); // 40 + + let never = |_memo| async { Ok::<(), OutgoingHandoffRejected>(()) }; + assert_eq!( + harness.service.handoff_exact(5, never).await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::AmountNotRepresentable { remainder: 5 } + )) + ); + assert_eq!( + harness + .service + .handoff_exact(80, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::InsufficientFunds + )) + ); + assert_eq!( + harness + .service + .handoff_exact(20, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::RequiresOnChainPreparation) + ); + assert_eq!(state(&harness.coins, 1).await, CoinState::Available); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn explicit_pre_handoff_rejection_removes_journal_then_rolls_back() { + let harness = harness(vec![coin(4)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(4).unwrap(), 1, 1); + let saw_commit_boundary = Arc::new(Mutex::new(false)); + + let result = harness + .service + .handoff_exact(20, { + let saw_commit_boundary = Arc::clone(&saw_commit_boundary); + let coins = Arc::clone(&harness.coins); + let wal = Arc::clone(&harness.wal); + move |_memo| async move { + assert_eq!(state(&coins, 4).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + *saw_commit_boundary.lock() = true; + Err(OutgoingHandoffRejected) + } + }) + .await; + + assert_eq!(result, Err(OutgoingTransferError::HandoffRejected)); + assert!(*saw_commit_boundary.lock()); + assert_eq!(state(&harness.coins, 4).await, CoinState::Available); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn accepted_handoff_stays_pending_and_cannot_be_selected_twice() { + let harness = harness(vec![coin(5)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(5).unwrap(), 2, 1); + harness + .service + .handoff_exact(40, |_memo| async { Ok(()) }) + .await + .unwrap(); + + assert_eq!(state(&harness.coins, 5).await, CoinState::PendingTransfer); + assert_eq!(harness.wal.load_all().await.unwrap().len(), 1); + assert_eq!( + harness + .service + .handoff_exact(40, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::EmptyWallet + )) + ); + let report = harness.service.reconcile_pending_transfers().await.unwrap(); + assert_eq!(report.still_pending, [5]); + assert!(report.confirmed_spent.is_empty()); + assert_eq!(harness.wal.load_all().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn startup_reconciliation_retires_absent_handoffs() { + let harness = harness(vec![coin(6)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + let public_key = keys.public_key(6).unwrap(); + harness.storage.set_coin(public_key, 0, 1); + harness + .service + .handoff_exact(10, |_memo| async { Ok(()) }) + .await + .unwrap(); + harness.service.shutdown(); + harness.storage.remove_coin(public_key); + + let report = harness.service.reconcile_pending_transfers().await.unwrap(); + assert_eq!(report.confirmed_spent, [6]); + assert!(report.still_pending.is_empty()); + assert_eq!(report.completed_journals, 1); + assert_eq!(state(&harness.coins, 6).await, CoinState::Spent); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn diagnostics_never_render_expanded_coin_secrets() { + let harness = harness(vec![coin(7)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(7).unwrap(), 0, 1); + let secret_hex = hex::encode(keys.secret_bytes(7).unwrap()); + let rendered_memo = Arc::new(Mutex::new(String::new())); + + let error = harness + .service + .handoff_exact(10, { + let rendered_memo = Arc::clone(&rendered_memo); + move |memo| async move { + *rendered_memo.lock() = format!("{memo:?}"); + Err(OutgoingHandoffRejected) + } + }) + .await + .unwrap_err(); + let diagnostics = format!("{error:?} {error} {}", rendered_memo.lock()); + assert!(!diagnostics.contains(&secret_hex)); + } + + struct FailingReservationRepository { + inner: Arc, + fail_index: u32, + } + + #[async_trait] + impl CoinRepository for FailingReservationRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.inner.upsert(coin).await + } + + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + if derivation_index == self.fail_index && state == CoinState::PendingTransfer { + return Err("injected reservation failure".into()); + } + self.inner.set_state(derivation_index, state).await + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await + } + } + + #[tokio::test] + async fn partial_reservation_failure_restores_every_prior_input() { + let inner = Arc::new(InMemoryCoinRepository::with_coins([coin(1), coin(2)])); + let coins = Arc::new(FailingReservationRepository { + inner: Arc::clone(&inner), + fail_index: 2, + }); + let storage = Arc::new(TestStorage::default()); + let keys = CoinKeypairFactory::new(&ENTROPY); + storage.set_coin(keys.public_key(1).unwrap(), 1, 1); // 20 + storage.set_coin(keys.public_key(2).unwrap(), 0, 1); // 10 + let wal = Arc::new(MemWal::default()); + let service = OutgoingCoinTransferService::new( + &ENTROPY, + OutgoingCoinTransferParts { + spawner: crate::test_spawner(), + coins, + vouchers: Arc::new(InMemoryVoucherRepository::default()), + wal: Arc::clone(&wal) as Arc<_>, + on_chain: Arc::new(CoinOnChainQueryService::new(storage)), + denominations: denominations(), + clock: Arc::new(FixedClock(0)), + settlement_timeout_heads: 1, + }, + ); + + let error = service + .handoff_exact(30, |_memo| async { Ok(()) }) + .await + .unwrap_err(); + assert!(matches!(error, OutgoingTransferError::Reservation(_))); + assert_eq!(state(&inner, 1).await, CoinState::Available); + assert_eq!(state(&inner, 2).await, CoinState::Available); + assert!(wal.load_all().await.unwrap().is_empty()); + } +} diff --git a/rust/crates/truapi-coinage/src/pallet.rs b/rust/crates/truapi-coinage/src/pallet.rs new file mode 100644 index 000000000..52109a779 --- /dev/null +++ b/rust/crates/truapi-coinage/src/pallet.rs @@ -0,0 +1,963 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::{Compact, Encode}; +use serde_json::{Value, json}; + +/// The pallet name every call and storage path hangs off. +pub const PALLET_NAME: &str = "Coinage"; + +/// The transaction-extension identifier of the coinage origins. +pub const AS_COINAGE_EXTENSION_ID: &str = "AsCoinage"; + +pub mod storage { + pub const COINS_BY_OWNER: &str = "CoinsByOwner"; + pub const RECYCLERS_COIN_TO_RECYCLER: &str = "RecyclersCoinToRecycler"; + /// Alias state storage. The Host resolves legacy denomination/ring/alias + /// keys or asset-instance-prefixed keys from runtime metadata. + pub const RECYCLER_ALIAS_STATES: &str = "RecyclerAliasStates"; + pub const CONSUMED_FREE_UNLOAD_TOKENS: &str = "ConsumedFreeUnloadTokens"; +} + +/// Call names, exactly as the runtime metadata spells them. +pub mod calls { + pub const LOAD_EXTERNAL_ASSET_UNPAID_BATCH: &str = + "load_recycler_with_external_asset_unpaid_batch"; + pub const SPLIT: &str = "split"; + pub const UNLOAD_RECYCLER_INTO_COINS: &str = "unload_recycler_into_coins"; + pub const LOAD_RECYCLER_WITH_COIN: &str = "load_recycler_with_coin"; + pub const UNLOAD_RECYCLER_INTO_EXTERNAL_ASSET: &str = "unload_recycler_into_external_asset"; + /// 2026-08 upgrade rename of `…_and_vouchers`. + pub const UNLOAD_RECYCLER_INTO_EXTERNAL_ASSET_AND_LOADED_COINS: &str = + "unload_recycler_into_external_asset_and_loaded_coins"; + pub const TRANSFER: &str = "transfer"; +} + +/// `Coinage.load_recycler_with_coin(member_key, proof_of_ownership)`. +/// Both arguments are fixed byte arrays in the live metadata, so their +/// SCALE representation is the raw bytes with no compact length prefix. +/// Pallet/call indices are resolved from runtime metadata by the host. +pub fn load_recycler_with_coin_call( + pallet_index: u8, + call_index: u8, + member_key: &[u8; 32], + proof_of_ownership: &[u8; 64], +) -> Vec { + let mut call = Vec::with_capacity(2 + member_key.len() + proof_of_ownership.len()); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(member_key); + call.extend_from_slice(proof_of_ownership); + call +} + +/// `Coinage.transfer(to)`. +/// The live runtime declares `to` as one fixed 32-byte account id. The +/// source coin is not an argument: it is selected by the signed +/// `AsCoinage(Some(AsCoin))` transaction extension and the extrinsic +/// signer's sr25519 account id. Keeping this builder fixed-width prevents +/// accidentally encoding the recipient as a SCALE `Vec`. +pub fn transfer_call(pallet_index: u8, call_index: u8, recipient: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + recipient.len()); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(recipient); + call +} + +/// Physical Members collection for a runtime-selected Coinage ABI. +/// `None` uses the legacy denomination at byte 16; `Some` inserts the +/// little-endian asset instance at bytes 16..20 and moves denomination to +/// byte 20, matching native `RecyclerCollectionIdentifier`. +/// The Host validates the runtime denomination before calling this helper. +pub fn recycler_collection_identifier(instance_id: Option, coin_value: i16) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..16].copy_from_slice(b"coinage/recycler"); + let denomination_offset = if let Some(instance_id) = instance_id { + id[16..20].copy_from_slice(&instance_id.to_le_bytes()); + 20 + } else { + 16 + }; + id[denomination_offset] = coin_value.clamp(0, u8::MAX as i16) as u8; + id +} + +fn hex_value(bytes: &[u8]) -> Value { + Value::String(format!("0x{}", hex::encode(bytes))) +} + +fn string_number(value: impl ToString) -> Value { + Value::String(value.to_string()) +} + +/// `Preservation` of an unpaid external-asset load (tagged union, payload +/// always null). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preservation { + Protect, + Preserve, + Expendable, +} + +impl Preservation { + fn to_json(self) -> Value { + let tag = match self { + Preservation::Protect => "Protect", + Preservation::Preserve => "Preserve", + Preservation::Expendable => "Expendable", + }; + json!([tag, null]) + } + + /// Pinned from the live `CodecPreservation` metadata variant indices, + /// which are NOT the declaration order of this enum. + fn scale_index(self) -> u8 { + match self { + Self::Expendable => 0, + Self::Protect => 1, + Self::Preserve => 2, + } + } +} + +/// One item of `load_recycler_with_external_asset_unpaid_batch`. +/// Field order and widths are pinned from the live +/// `indiv_pallet_coinage::pallet::UnpaidLoadInput` composite: +/// `preservation: CodecPreservation, value: i8, member_key: [u8; 32], +/// proof_of_ownership: [u8; 64]`. `value` is a coin exponent, so the +/// runtime's one-byte width is the whole domain (`MinimumExponent` 0..= +/// `MaximumExponent` 14). +#[derive(Debug, Clone)] +pub struct UnpaidLoadInput { + pub value: i8, + pub preservation: Preservation, + pub member_key: [u8; 32], + pub proof_of_ownership: Vec, +} + +impl UnpaidLoadInput { + pub fn to_json(&self) -> Value { + json!({ + "value": string_number(self.value), + "preservation": self.preservation.to_json(), + "memberKey": hex_value(&self.member_key), + "proofOfOwnership": hex_value(&self.proof_of_ownership), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SplitDestination { + pub exponent: i16, + pub accounts: Vec<[u8; 32]>, +} + +impl SplitDestination { + pub fn to_json(&self) -> Value { + json!([ + string_number(self.exponent), + self.accounts + .iter() + .map(|a| hex_value(a)) + .collect::>(), + ]) + } +} + +/// The `split` call args (`split_into`). +pub fn split_args(split_into: &[SplitDestination]) -> Value { + json!({ + "split_into": split_into.iter().map(SplitDestination::to_json).collect::>(), + }) +} + +/// SCALE call bytes for `Coinage.split(split_into)`. +/// Live metadata declares `split_into` as +/// `Vec<(i8, Vec)>`. The public model retains `i16` so it can +/// share the denomination domain; this boundary rejects an exponent that the +/// runtime cannot represent rather than truncating it. +pub fn split_call( + pallet_index: u8, + call_index: u8, + split_into: &[SplitDestination], +) -> Result, String> { + let count = u32::try_from(split_into.len()) + .map_err(|_| "Coinage split destination list is too large".to_string())?; + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&Compact(count).encode()); + for destination in split_into { + let exponent = i8::try_from(destination.exponent).map_err(|_| { + format!( + "Coinage split exponent {} does not fit the live i8 field", + destination.exponent + ) + })?; + call.push(exponent as u8); + call.extend_from_slice(&destination.accounts.encode()); + } + Ok(call) +} + +/// The batch-load call args (`items`). +pub fn load_external_asset_unpaid_batch_args(items: &[UnpaidLoadInput]) -> Value { + json!({ + "items": items.iter().map(UnpaidLoadInput::to_json).collect::>(), + }) +} + +/// SCALE call bytes for +/// `Coinage.load_recycler_with_external_asset_unpaid_batch(items)`. +/// Runtime metadata pins each item as +/// `(Preservation, i8, [u8; 32], [u8; 64])`. Keeping the proof +/// length check at this boundary prevents a JSON-era `Vec` assumption +/// from silently producing a different call layout. +pub fn load_external_asset_unpaid_batch_call( + pallet_index: u8, + call_index: u8, + items: &[UnpaidLoadInput], +) -> Result, String> { + let item_count = u32::try_from(items.len()) + .map_err(|_| "Coinage unpaid voucher batch is too large".to_string())?; + let mut call = Vec::with_capacity(2 + 5 + items.len().saturating_mul(99)); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&Compact(item_count).encode()); + for item in items { + let proof: &[u8; 64] = item.proof_of_ownership.as_slice().try_into().map_err( + |_: std::array::TryFromSliceError| { + format!( + "Coinage unpaid voucher proof has {} bytes; expected 64", + item.proof_of_ownership.len() + ) + }, + )?; + // `preservation` precedes `value` in the runtime composite, and + // `value` is one byte — the reverse of both was a silent + // field-shift that made the node's decode panic. + call.push(item.preservation.scale_index()); + call.extend_from_slice(&item.value.to_le_bytes()); + call.extend_from_slice(&item.member_key); + call.extend_from_slice(proof); + } + Ok(call) +} + +fn aliases_json(aliases: &[Vec]) -> Vec { + aliases.iter().map(|alias| hex_value(alias)).collect() +} + +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoCoinsArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub split_into: Vec, + pub max_fee: u128, +} + +impl UnloadRecyclerIntoCoinsArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "split_into": self + .split_into + .iter() + .map(SplitDestination::to_json) + .collect::>(), + "max_fee": string_number(self.max_fee), + }) + } +} + +/// SCALE call bytes for `Coinage.unload_recycler_into_coins`. +/// Legacy field order and widths are: +/// `Vec<[u8;32]>, i8, u32, u32, Vec<(i8, Vec)>, u128`. +/// For the asset-instance ABI, `Some(instance_id)` inserts a fixed-width +/// little-endian `u32` before aliases, with no SCALE `Option` discriminant. +/// The Host must select the ABI and instance from metadata and trusted config. +/// Aliases are accepted as fixed arrays here so a JSON-era variable-length +/// value cannot shift every following field. +#[allow(clippy::too_many_arguments)] +pub fn unload_recycler_into_coins_call( + pallet_index: u8, + call_index: u8, + instance_id: Option, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + split_into: &[SplitDestination], + max_fee: u128, +) -> Result, String> { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + if let Some(instance_id) = instance_id { + call.extend_from_slice(&instance_id.to_le_bytes()); + } + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + + let count = u32::try_from(split_into.len()) + .map_err(|_| "Coinage unload destination list is too large".to_string())?; + call.extend_from_slice(&Compact(count).encode()); + for destination in split_into { + let exponent = i8::try_from(destination.exponent).map_err(|_| { + format!( + "Coinage unload destination exponent {} does not fit the live i8 field", + destination.exponent + ) + })?; + call.push(exponent as u8); + call.extend_from_slice(&destination.accounts.encode()); + } + call.extend_from_slice(&max_fee.to_le_bytes()); + Ok(call) +} + +/// Arguments for `Coinage.unload_recycler_into_external_asset`. +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoExternalAssetArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub to: [u8; 32], +} + +impl UnloadRecyclerIntoExternalAssetArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "to": hex_value(&self.to), + }) + } +} + +/// SCALE call bytes for `unload_recycler_into_external_asset`. +/// Live metadata: `Vec<[u8; 32]>`, `i8`, `u32`, `u32`, `[u8; 32]`. +pub fn unload_recycler_into_external_asset_call( + pallet_index: u8, + call_index: u8, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + to: &[u8; 32], +) -> Vec { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + call.extend_from_slice(to); + call +} + +#[derive(Debug, Clone)] +pub struct NewVoucher { + pub coin_value: i8, + pub member_key: [u8; 32], +} + +impl NewVoucher { + pub fn to_json(&self) -> Value { + json!([string_number(self.coin_value), hex_value(&self.member_key),]) + } +} + +/// Arguments for +/// `Coinage.unload_recycler_into_external_asset_and_vouchers`. +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoExternalAssetAndVouchersArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub to: [u8; 32], + pub external_asset_amount: u128, + pub new_vouchers: Vec, +} + +impl UnloadRecyclerIntoExternalAssetAndVouchersArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "to": hex_value(&self.to), + "external_asset_amount": string_number(self.external_asset_amount), + "new_vouchers": self + .new_vouchers + .iter() + .map(NewVoucher::to_json) + .collect::>(), + }) + } +} + +/// SCALE call bytes for +/// `unload_recycler_into_external_asset_and_vouchers`. +/// The surplus entries are unkeyed `(i8, [u8; 32])` tuples and the external +/// amount is a fixed-width little-endian `u128`, as pinned by live metadata. +#[allow(clippy::too_many_arguments)] +pub fn unload_recycler_into_external_asset_and_vouchers_call( + pallet_index: u8, + call_index: u8, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + to: &[u8; 32], + external_asset_amount: u128, + new_vouchers: &[NewVoucher], +) -> Vec { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + call.extend_from_slice(to); + call.extend_from_slice(&external_asset_amount.to_le_bytes()); + call.extend_from_slice(&Compact(new_vouchers.len() as u32).encode()); + for voucher in new_vouchers { + call.push(voucher.coin_value as u8); + call.extend_from_slice(&voucher.member_key); + } + call +} + +/// The personhood half of an unload-token proof: the ring-VRF proof and +/// the ring it opens against. +#[derive(Debug, Clone)] +pub struct PeopleProof { + pub proof: Vec, + pub ring: u32, + /// The ring revision the proof was built against — required by the + /// 2026-08 runtime's `MembershipProof` (spec 1000032). + pub revision: u32, +} + +impl PeopleProof { + fn to_json(&self) -> Value { + json!({ + "proof": hex_value(&self.proof), + "ring": string_number(self.ring), + "revision": string_number(self.revision), + }) + } +} + +#[derive(Debug, Clone)] +pub enum AsCoinageMode { + /// Sr25519 coin-keypair origin — the variant tag is the whole payload. + AsCoin, + /// Full-person unload token: people-ring proof + per-voucher recycler + /// alias proofs. + AsUnloadTokenPeople { + proof: PeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Lite-person unload token (same payload as the full-person mode). + AsUnloadTokenLitePeople { + proof: PeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Pre-computed proof from the PAID unload-token ring. + AsUnloadTokenPaid { + proof: Vec, + period: u32, + paid_token_ring_index: u32, + paid_token_ring_revision: u32, + alias_proofs: Vec>, + }, + /// Fee recycler output as the token (first alias proof = fee coin). + /// `retry_counter` joined in the 2026-08 runtime (spec 1000032). + AsUnloadTokenFromOutput { + fee_recycler_value: i8, + fee_recycler_index: u32, + fee_recycler_revision: u32, + retry_counter: u8, + alias_proofs: Vec>, + }, + InfallibleUnpaidSigned { + nonce: u32, + }, +} + +impl AsCoinageMode { + /// The `["TagName", null | payload]` JSON the dynamic SCALE layer + /// consumes. + pub fn to_json(&self) -> Value { + let alias_list = + |proofs: &[Vec]| proofs.iter().map(|p| hex_value(p)).collect::>(); + match self { + AsCoinageMode::AsCoin => json!(["AsCoin", null]), + AsCoinageMode::AsUnloadTokenPeople { + proof, + period, + counter, + alias_proofs, + } => json!([ + "AsUnloadTokenPeople", + { + "proof": proof.to_json(), + "period": string_number(period), + "counter": string_number(counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenLitePeople { + proof, + period, + counter, + alias_proofs, + } => json!([ + "AsUnloadTokenLitePeople", + { + "proof": proof.to_json(), + "period": string_number(period), + "counter": string_number(counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenPaid { + proof, + period, + paid_token_ring_index, + paid_token_ring_revision, + alias_proofs, + } => json!([ + "AsUnloadTokenPaid", + { + "proof": hex_value(proof), + "period": string_number(period), + "paidTokenRingIndex": string_number(paid_token_ring_index), + "paidTokenRingRevision": string_number(paid_token_ring_revision), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenFromOutput { + fee_recycler_value, + fee_recycler_index, + fee_recycler_revision, + retry_counter, + alias_proofs, + } => json!([ + "AsUnloadTokenFromOutput", + { + "feeRecyclerValue": string_number(fee_recycler_value), + "feeRecyclerIndex": string_number(fee_recycler_index), + "feeRecyclerRevision": string_number(fee_recycler_revision), + "retryCounter": string_number(retry_counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::InfallibleUnpaidSigned { nonce } => json!([ + "InfallibleUnpaidSigned", + { "nonce": string_number(nonce) } + ]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_recycler_with_coin_call_has_fixed_array_arguments() { + let call = load_recycler_with_coin_call(52, 2, &[0x11; 32], &[0x22; 64]); + assert_eq!(call.len(), 98); + assert_eq!(&call[..2], &[52, 2]); + assert_eq!(&call[2..34], &[0x11; 32]); + assert_eq!(&call[34..], &[0x22; 64]); + } + + #[test] + fn unpaid_external_asset_batch_call_matches_fixed_runtime_layout() { + let call = load_external_asset_unpaid_batch_call( + 52, + 1, + &[ + UnpaidLoadInput { + value: -2, + preservation: Preservation::Expendable, + member_key: [0x11; 32], + proof_of_ownership: vec![0x22; 64], + }, + UnpaidLoadInput { + value: 3, + preservation: Preservation::Protect, + member_key: [0x33; 32], + proof_of_ownership: vec![0x44; 64], + }, + ], + ) + .unwrap(); + // Pinned from the live `UnpaidLoadInput` composite: preservation + // (1 byte, live variant indices) then value (1 byte), member key, + // proof. Getting either the order or the value width wrong shifts + // every later field and the node's decode panics. + assert_eq!(&call[..3], &[52, 1, 8], "two-item Compact"); + assert_eq!(call[3], 0, "Expendable variant"); + assert_eq!(call[4] as i8, -2i8); + assert_eq!(&call[5..37], &[0x11; 32]); + assert_eq!(&call[37..101], &[0x22; 64]); + assert_eq!(call[101], 1, "Protect variant"); + assert_eq!(call[102] as i8, 3i8); + assert_eq!(&call[103..135], &[0x33; 32]); + assert_eq!(&call[135..199], &[0x44; 64]); + assert_eq!(call.len(), 199, "2 + 1 + 2 * (1 + 1 + 32 + 64)"); + } + + #[test] + fn unpaid_external_asset_batch_rejects_variable_length_proof() { + let error = load_external_asset_unpaid_batch_call( + 1, + 2, + &[UnpaidLoadInput { + value: 0, + preservation: Preservation::Preserve, + member_key: [0; 32], + proof_of_ownership: vec![0; 63], + }], + ) + .unwrap_err(); + assert!(error.contains("63 bytes; expected 64")); + } + + #[test] + fn transfer_call_has_one_fixed_account_argument() { + let call = transfer_call(52, 6, &[0xA5; 32]); + assert_eq!(call.len(), 34); + assert_eq!(&call[..2], &[52, 6]); + assert_eq!(&call[2..], &[0xA5; 32]); + } + + #[test] + fn split_call_matches_live_scale_layout() { + let call = split_call( + 52, + 3, + &[ + SplitDestination { + exponent: -1, + accounts: vec![[0x11; 32], [0x22; 32]], + }, + SplitDestination { + exponent: 4, + accounts: vec![[0x33; 32]], + }, + ], + ) + .unwrap(); + assert_eq!(&call[..3], &[52, 3, 8], "two destination tuples"); + assert_eq!(call[3], 0xff, "i8 exponent"); + assert_eq!(call[4], 8, "two accounts"); + assert_eq!(&call[5..37], &[0x11; 32]); + assert_eq!(&call[37..69], &[0x22; 32]); + assert_eq!(call[69], 4); + assert_eq!(call[70], 4, "one account"); + assert_eq!(&call[71..103], &[0x33; 32]); + assert_eq!(call.len(), 103); + } + + #[test] + fn unload_into_coins_call_matches_legacy_scale_layout() { + let call = unload_recycler_into_coins_call( + 52, + 4, + None, + &[[0xAA; 32], [0xBB; 32]], + -2, + 0x1122_3344, + 0x5566_7788, + &[SplitDestination { + exponent: 3, + accounts: vec![[0xCC; 32]], + }], + 9, + ) + .unwrap(); + assert_eq!(&call[..3], &[52, 4, 8], "two aliases"); + assert_eq!(&call[3..35], &[0xAA; 32]); + assert_eq!(&call[35..67], &[0xBB; 32]); + assert_eq!(call[67], 0xfe); + assert_eq!(&call[68..72], &0x1122_3344u32.to_le_bytes()); + assert_eq!(&call[72..76], &0x5566_7788u32.to_le_bytes()); + assert_eq!(call[76], 4, "one split tuple"); + assert_eq!(call[77], 3); + assert_eq!(call[78], 4, "one account"); + assert_eq!(&call[79..111], &[0xCC; 32]); + assert_eq!(&call[111..], &9u128.to_le_bytes()); + assert_eq!(call.len(), 127); + } + + #[test] + fn unload_into_coins_call_matches_asset_instance_scale_layout() { + let call = unload_recycler_into_coins_call( + 52, + 4, + Some(0x1122_3344), + &[[0xAA; 32], [0xBB; 32]], + -2, + 0x5566_7788, + 0x99AA_BBCC, + &[SplitDestination { + exponent: 3, + accounts: vec![[0xCC; 32]], + }], + 9, + ) + .unwrap(); + // The complete runtime argument tuple catches an Option tag, misplaced + // instance, variable-width alias or shifted trailing argument. + let expected = ( + 52u8, + 4u8, + 0x1122_3344u32, + vec![[0xAAu8; 32], [0xBB; 32]], + -2i8, + 0x5566_7788u32, + 0x99AA_BBCCu32, + vec![(3i8, vec![[0xCCu8; 32]])], + 9u128, + ) + .encode(); + assert_eq!(call, expected); + } + + #[test] + fn coin_output_calls_reject_exponents_outside_live_i8() { + for exponent in [-129, 128] { + let destinations = [SplitDestination { + exponent, + accounts: vec![[0; 32]], + }]; + assert!(split_call(1, 2, &destinations).is_err()); + for instance_id in [None, Some(0x1122_3344)] { + assert!( + unload_recycler_into_coins_call( + 1, + 2, + instance_id, + &[], + 0, + 0, + 0, + &destinations, + 0, + ) + .is_err() + ); + } + } + } + + #[test] + fn recycler_collection_matches_legacy_and_asset_instance_wire_layouts() { + assert_eq!( + recycler_collection_identifier(None, 5), + *b"coinage/recycler\x05\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0" + ); + assert_eq!( + recycler_collection_identifier(Some(0x1122_3344), 5), + *b"coinage/recycler\x44\x33\x22\x11\x05\0\0\0\0\0\0\0\0\0\0\0" + ); + } + + #[test] + fn as_coinage_modes_match_the_reference_json() { + assert_eq!(AsCoinageMode::AsCoin.to_json(), json!(["AsCoin", null])); + assert_eq!( + AsCoinageMode::InfallibleUnpaidSigned { nonce: 7 }.to_json(), + json!(["InfallibleUnpaidSigned", { "nonce": "7" }]) + ); + let people = AsCoinageMode::AsUnloadTokenPeople { + proof: PeopleProof { + proof: vec![0xAA], + ring: 2, + revision: 6, + }, + period: 9, + counter: 1, + alias_proofs: vec![vec![0xBB], vec![0xCC]], + } + .to_json(); + assert_eq!( + people, + json!([ + "AsUnloadTokenPeople", + { + "proof": { "proof": "0xaa", "ring": "2", "revision": "6" }, + "period": "9", + "counter": "1", + "aliasProofs": ["0xbb", "0xcc"], + } + ]) + ); + let from_output = AsCoinageMode::AsUnloadTokenFromOutput { + fee_recycler_value: -1, + fee_recycler_index: 3, + fee_recycler_revision: 4, + retry_counter: 2, + alias_proofs: vec![], + } + .to_json(); + assert_eq!( + from_output[1]["feeRecyclerValue"], + Value::String("-1".into()) + ); + } + + #[test] + fn call_args_match_the_reference_json() { + let split = split_args(&[SplitDestination { + exponent: 3, + accounts: vec![[1u8; 32]], + }]); + assert_eq!(split["split_into"][0][0], Value::String("3".into())); + assert!( + split["split_into"][0][1][0] + .as_str() + .unwrap() + .starts_with("0x0101") + ); + + let batch = load_external_asset_unpaid_batch_args(&[UnpaidLoadInput { + value: 2, + preservation: Preservation::Expendable, + member_key: [9u8; 32], + proof_of_ownership: vec![0xDD], + }]); + let item = &batch["items"][0]; + assert_eq!(item["value"], Value::String("2".into())); + assert_eq!(item["preservation"], json!(["Expendable", null])); + assert_eq!(item["proofOfOwnership"], Value::String("0xdd".into())); + } + + #[test] + fn unload_call_args_match_the_reference_json() { + let into_coins = UnloadRecyclerIntoCoinsArgs { + aliases: vec![vec![0xAA; 32]], + value: -2, + index: 7, + revision: 9, + split_into: vec![SplitDestination { + exponent: 3, + accounts: vec![[0x11; 32]], + }], + max_fee: 0, + } + .to_json(); + assert_eq!( + into_coins, + json!({ + "aliases": [format!("0x{}", "aa".repeat(32))], + "value": "-2", + "index": "7", + "revision": "9", + "split_into": [[ + "3", + [format!("0x{}", "11".repeat(32))] + ]], + "max_fee": "0", + }) + ); + + let into_asset = UnloadRecyclerIntoExternalAssetArgs { + aliases: vec![vec![0xBB; 32], vec![0xCC; 32]], + value: 4, + index: 10, + revision: 11, + to: [0x22; 32], + } + .to_json(); + assert_eq!(into_asset["aliases"].as_array().unwrap().len(), 2); + assert_eq!(into_asset["value"], "4"); + assert_eq!(into_asset["index"], "10"); + assert_eq!(into_asset["revision"], "11"); + assert_eq!( + into_asset["to"], + Value::String(format!("0x{}", "22".repeat(32))) + ); + + let with_change = UnloadRecyclerIntoExternalAssetAndVouchersArgs { + aliases: vec![vec![0xDD; 32]], + value: 5, + index: 12, + revision: 13, + to: [0x33; 32], + external_asset_amount: u128::MAX, + new_vouchers: vec![NewVoucher { + coin_value: -1, + member_key: [0x44; 32], + }], + } + .to_json(); + assert_eq!( + with_change["external_asset_amount"], + Value::String(u128::MAX.to_string()) + ); + assert_eq!( + with_change["new_vouchers"][0], + json!(["-1", format!("0x{}", "44".repeat(32)),]) + ); + assert!(with_change.get("externalAssetAmount").is_none()); + assert!(with_change.get("newVouchers").is_none()); + } + + #[test] + fn external_asset_unload_calls_match_live_scale_layout() { + let aliases = [[0x11; 32], [0x22; 32]]; + let plain = + unload_recycler_into_external_asset_call(68, 5, &aliases, -2, 7, 9, &[0x33; 32]); + assert_eq!(&plain[..3], &[68, 5, 8], "two aliases, compact len 2"); + assert_eq!(&plain[3..35], &[0x11; 32]); + assert_eq!(&plain[35..67], &[0x22; 32]); + assert_eq!(plain[67], (-2i8) as u8); + assert_eq!(&plain[68..72], &7u32.to_le_bytes()); + assert_eq!(&plain[72..76], &9u32.to_le_bytes()); + assert_eq!(&plain[76..], &[0x33; 32]); + + let with_change = unload_recycler_into_external_asset_and_vouchers_call( + 68, + 9, + &aliases[..1], + 4, + 10, + 11, + &[0x44; 32], + 123, + &[NewVoucher { + coin_value: -1, + member_key: [0x55; 32], + }], + ); + assert_eq!(&with_change[..3], &[68, 9, 4], "one alias"); + let amount_offset = 3 + 32 + 1 + 4 + 4 + 32; + assert_eq!( + &with_change[amount_offset..amount_offset + 16], + &123u128.to_le_bytes() + ); + assert_eq!(with_change[amount_offset + 16], 4, "one voucher"); + assert_eq!(with_change[amount_offset + 17], (-1i8) as u8); + assert_eq!(&with_change[amount_offset + 18..], &[0x55; 32]); + } +} diff --git a/rust/crates/truapi-coinage/src/query.rs b/rust/crates/truapi-coinage/src/query.rs new file mode 100644 index 000000000..94446c198 --- /dev/null +++ b/rust/crates/truapi-coinage/src/query.rs @@ -0,0 +1,1441 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use crate::members::{self, RingPosition, RingRoot}; +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt}; +use parity_scale_codec::{Decode, DecodeAll, Encode}; +use tokio::sync::mpsc; +use tracing::warn; + +use crate::claim::SendConfirmation; +use crate::repo::VoucherRepository; +use crate::selection::RecyclerKey; +use crate::sync::OnChainCoin; +use crate::voucher_location::{ + RingPosition as VoucherRingPosition, RingStatus as VoucherRingStatus, + VoucherLocationSubscriber, VoucherLocationUpdate, +}; + +/// Semantic Coinage storage requests. The Host resolves physical keys +/// from runtime metadata pinned to the query block and the configured asset +/// instance. Recycler collections are never encoded by the portable engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CoinageStorageKey { + Coin([u8; 32]), + Recycler([u8; 32]), + RecyclerAlias { + exponent: i8, + ring_index: u32, + alias: [u8; 32], + }, + Member { + exponent: i16, + member: [u8; 32], + }, + Root { + exponent: i16, + ring_index: u32, + }, + RingKeysStatus { + exponent: i16, + ring_index: u32, + }, +} + +impl CoinageStorageKey { + fn recycler_alias( + exponent: i16, + ring_index: u32, + alias: [u8; 32], + ) -> Result { + let exponent = + i8::try_from(exponent).map_err(|_| CoinageQueryError::InvalidExponent(exponent))?; + Ok(Self::RecyclerAlias { + exponent, + ring_index, + alias, + }) + } +} + +/// The storage/finalized-head effect. Production uses a Host RPC +/// adapter; tests provide a deterministic batch source. +#[async_trait] +pub trait CoinageStorageQuery: Send + Sync { + /// One head-pinned query, resolving semantic keys against that head's + /// runtime metadata and configured asset instance, preserving request order. + /// + /// Values are canonical engine SCALE rows, not raw runtime storage bytes: + /// `Coin` is `(i8, u16)`, `Recycler` is `i8`, `RecyclerAlias` is `AliasState`, + /// `Member` is `RingPosition`, `Root` is `RingRoot`, and `RingKeysStatus` is + /// `members::RingStatus`. The Host must validate each complete runtime value + /// against metadata and the selected asset before removing runtime-specific + /// instance fields and returning the canonical row. + async fn query( + &self, + keys: &[CoinageStorageKey], + at: Option<[u8; 32]>, + ) -> Result>>, String>; + + /// The canonical finalized head used to pin a coherent multi-stage + /// query. Test/deterministic sources may supply it through their + /// finalized stream; the RPC adapter uses the direct one-shot method. + async fn finalized_head(&self) -> Result<[u8; 32], String> { + let mut heads = self.finalized_heads(); + heads + .next() + .await + .ok_or_else(|| "finalized-head subscription terminated".to_string())? + } + + /// A fresh finalized-head stream. Consumers race this against their + /// storage condition and block timeout. + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>>; + + fn observation_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + self.finalized_heads() + } +} + +/// Typed failures from the query layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoinageQueryError { + Storage(String), + Decode { + storage: &'static str, + message: String, + }, + ResponseLength { + storage: &'static str, + expected: usize, + actual: usize, + }, + SubscriptionTerminated, + Timeout { + finalized_heads: u32, + }, + InvalidExponent(i16), +} + +impl fmt::Display for CoinageQueryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Storage(message) => formatter.write_str(message), + Self::Decode { storage, message } => { + write!(formatter, "{storage} SCALE decode failed: {message}") + } + Self::ResponseLength { + storage, + expected, + actual, + } => write!( + formatter, + "{storage} returned {actual} values for {expected} keys" + ), + Self::SubscriptionTerminated => { + formatter.write_str("finalized-head subscription terminated") + } + Self::Timeout { finalized_heads } => { + write!( + formatter, + "coin query timed out after {finalized_heads} finalized heads" + ) + } + Self::InvalidExponent(exponent) => { + write!( + formatter, + "coinage exponent {exponent} does not fit the runtime i8" + ) + } + } + } +} + +impl std::error::Error for CoinageQueryError {} + +/// `Coinage.RecyclerAliasStates` value +/// (`indiv_pallet_coinage::pallet::AliasState`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum AliasState { + /// Load/unload is locked until the carried seconds timestamp + /// (`LockInfo { reason: LockReason::FailedDispatch(u8), until: u64 }`). + #[codec(index = 0)] + Locked(LockInfo), + #[codec(index = 1)] + Unloaded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct LockInfo { + pub reason: LockReason, + pub until: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum LockReason { + #[codec(index = 0)] + FailedDispatch(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct RawOnChainCoin { + value: i8, + age: u16, +} + +fn decode_coin(bytes: &[u8]) -> Result { + let raw = + RawOnChainCoin::decode_all(&mut &bytes[..]).map_err(|error| CoinageQueryError::Decode { + storage: "Coinage.CoinsByOwner", + message: error.to_string(), + })?; + let age = i16::try_from(raw.age).map_err(|_| CoinageQueryError::Decode { + storage: "Coinage.CoinsByOwner", + message: format!("age {} does not fit i16", raw.age), + })?; + Ok(OnChainCoin { + exponent: i16::from(raw.value), + age, + }) +} + +fn decode_optional( + bytes: Option>, + storage: &'static str, +) -> Result, CoinageQueryError> { + bytes + .map(|bytes| { + T::decode_all(&mut &bytes[..]).map_err(|error| CoinageQueryError::Decode { + storage, + message: error.to_string(), + }) + }) + .transpose() +} + +fn ensure_response_len( + storage: &'static str, + expected: usize, + actual: usize, +) -> Result<(), CoinageQueryError> { + if expected == actual { + Ok(()) + } else { + Err(CoinageQueryError::ResponseLength { + storage, + expected, + actual, + }) + } +} + +/// Batch coin fetch + finalized-head-raced presence/claim waits. +pub struct CoinOnChainQueryService { + storage: Arc, +} + +impl CoinOnChainQueryService { + pub fn new(storage: Arc) -> Self { + Self { storage } + } + + /// Fetches N `CoinsByOwner` entries in one RPC, preserving input + /// order. `at` enables the historical anchor probe used after a fast + /// claim. + pub async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + at: Option<[u8; 32]>, + ) -> Result>, CoinageQueryError> { + if public_keys.is_empty() { + return Ok(Vec::new()); + } + let keys = public_keys + .iter() + .copied() + .map(CoinageStorageKey::Coin) + .collect::>(); + let values = self + .storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len("Coinage.CoinsByOwner", keys.len(), values.len())?; + values + .into_iter() + .map(|value| value.map(|bytes| decode_coin(&bytes)).transpose()) + .collect() + } + + /// Resolves once every key has been observed present. Presence is + /// accumulated across finalized heads because a storage subscription + /// may emit only the keys changed in a block, and an early coin may + /// already be claimed by the time a later one appears. + pub async fn await_all_coins_on_chain( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result<(), CoinageQueryError> { + self.await_all_coins_sent_or_claimed(public_keys, block_timeout) + .await + .map(|_| ()) + } + + /// Resolves once every key is absent (claimed/spent). + pub async fn await_all_coins_off_chain( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result<(), CoinageQueryError> { + self.await_condition(public_keys, block_timeout, |coins| { + coins.iter().all(Option::is_none).then_some(()) + }) + .await + } + + /// Accumulates `seen` across partial lifecycle observations: once all + /// keys have appeared at least once, returns whether any remains + /// present. This distinguishes `OnChain` from an ultra-fast + /// `AlreadyClaimed` completion and races the finalized-head count. + pub async fn await_all_coins_sent_or_claimed( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result { + if public_keys.is_empty() { + return Ok(true); + } + let mut heads = self.storage.finalized_heads(); + let mut seen = vec![false; public_keys.len()]; + let mut present = vec![false; public_keys.len()]; + let limit = block_timeout.max(1); + let mut count = 0u32; + while let Some(head) = heads.next().await { + let head = head.map_err(CoinageQueryError::Storage)?; + count = count.saturating_add(1); + let coins = self.fetch_coins(public_keys, Some(head)).await?; + for (index, coin) in coins.into_iter().enumerate() { + present[index] = coin.is_some(); + if present[index] { + seen[index] = true; + } + } + if seen.iter().all(|value| *value) { + return Ok(present.iter().any(|value| *value)); + } + if count >= limit { + return Err(CoinageQueryError::Timeout { + finalized_heads: limit, + }); + } + } + Err(CoinageQueryError::SubscriptionTerminated) + } + + pub async fn await_send_or_claimed( + &self, + public_keys: &[[u8; 32]], + anchor_hash: Option<[u8; 32]>, + block_timeout: u32, + ) -> Result { + if public_keys.is_empty() { + return Ok(SendConfirmation::OnChain); + } + let mut confirmed = vec![false; public_keys.len()]; + let current = self.fetch_coins(public_keys, None).await?; + let mut any_present = false; + for (index, coin) in current.into_iter().enumerate() { + if coin.is_some() { + confirmed[index] = true; + any_present = true; + } + } + + if confirmed.iter().any(|value| !*value) + && let Some(anchor_hash) = anchor_hash + { + let absent_offsets = confirmed + .iter() + .enumerate() + .filter_map(|(index, confirmed)| (!confirmed).then_some(index)) + .collect::>(); + let absent_keys = absent_offsets + .iter() + .map(|index| public_keys[*index]) + .collect::>(); + let historical = self.fetch_coins(&absent_keys, Some(anchor_hash)).await?; + for (offset, coin) in historical.into_iter().enumerate() { + if coin.is_some() { + confirmed[absent_offsets[offset]] = true; + } + } + } + + if confirmed.iter().all(|value| *value) { + return Ok(if any_present { + SendConfirmation::OnChain + } else { + SendConfirmation::AlreadyClaimed + }); + } + + let remaining = confirmed + .iter() + .enumerate() + .filter_map(|(index, confirmed)| (!confirmed).then_some(public_keys[index])) + .collect::>(); + let any_remaining_present = self + .await_all_coins_sent_or_claimed(&remaining, block_timeout) + .await?; + Ok(if any_present || any_remaining_present { + SendConfirmation::OnChain + } else { + SendConfirmation::AlreadyClaimed + }) + } + + async fn await_condition( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + condition: impl Fn(&[Option]) -> Option, + ) -> Result { + if public_keys.is_empty() { + return condition(&[]).ok_or(CoinageQueryError::SubscriptionTerminated); + } + let mut heads = self.storage.finalized_heads(); + let limit = block_timeout.max(1); + let mut count = 0u32; + while let Some(head) = heads.next().await { + let head = head.map_err(CoinageQueryError::Storage)?; + count = count.saturating_add(1); + let coins = self.fetch_coins(public_keys, Some(head)).await?; + if let Some(output) = condition(&coins) { + return Ok(output); + } + if count >= limit { + return Err(CoinageQueryError::Timeout { + finalized_heads: limit, + }); + } + } + Err(CoinageQueryError::SubscriptionTerminated) + } +} + +/// Complete on-chain voucher observation after the four query stages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherOnChainInfo { + pub exponent: i16, + pub ring_position: RingPosition, + pub is_unloaded: bool, +} + +type KeyProvider = dyn Fn(u32) -> Result<[u8; 32], String> + Send + Sync; + +#[derive(Debug, Clone)] +enum VoucherLocationRequest { + Position { derivation_index: u32 }, + Status { derivation_index: u32 }, +} + +pub struct QueryVoucherLocationSubscriber { + spawner: crate::Spawner, + storage: Arc, + vouchers: Arc, + public_key_provider: Arc, + observation_refresh_interval: Duration, +} + +/// A head notification is the fast path, while this current-best refresh is +/// the recovery path. In particular, a rejected/stopped `chainHead` follow +/// must not strand vouchers whose ring proof set advances after their member +/// position was first observed. +const VOUCHER_LOCATION_OBSERVATION_REFRESH: Duration = Duration::from_secs(8); + +impl QueryVoucherLocationSubscriber { + pub fn new( + storage: Arc, + vouchers: Arc, + public_key_provider: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + storage, + vouchers, + public_key_provider, + observation_refresh_interval: VOUCHER_LOCATION_OBSERVATION_REFRESH, + spawner, + } + } + + #[cfg(test)] + fn with_observation_refresh_interval(mut self, interval: Duration) -> Self { + self.observation_refresh_interval = interval; + self + } + + async fn requests( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + let vouchers = self + .vouchers + .list() + .await? + .into_iter() + .map(|voucher| (voucher.derivation_index, voucher)) + .collect::>(); + let voucher = |index: u32| { + vouchers + .get(&index) + .ok_or_else(|| format!("voucher {index} disappeared before location subscribe")) + }; + + let mut requests = Vec::with_capacity(pending.len() + included.len() + degraded.len()); + for derivation_index in pending { + let voucher = voucher(derivation_index)?; + let member = (self.public_key_provider)(derivation_index)?; + requests.push(( + VoucherLocationRequest::Position { derivation_index }, + CoinageStorageKey::Member { + exponent: voucher.exponent, + member, + }, + )); + } + for (derivation_index, ring_index) in included.into_iter().chain(degraded) { + let voucher = voucher(derivation_index)?; + requests.push(( + VoucherLocationRequest::Status { derivation_index }, + CoinageStorageKey::RingKeysStatus { + exponent: voucher.exponent, + ring_index, + }, + )); + } + Ok(requests) + } +} + +async fn fetch_voucher_location_update( + storage: &dyn CoinageStorageQuery, + requests: &[(VoucherLocationRequest, CoinageStorageKey)], + at: Option<[u8; 32]>, +) -> Result { + let keys = requests.iter().map(|(_, key)| *key).collect::>(); + let values = storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Members voucher-location batch", + requests.len(), + values.len(), + )?; + + let mut update = VoucherLocationUpdate::default(); + for ((request, _), value) in requests.iter().zip(values) { + match request { + VoucherLocationRequest::Position { derivation_index } => { + let position = + decode_optional::(value, "Members.Members")?.map(|position| { + match position { + RingPosition::Included { + ring_index, + ring_position, + .. + } => VoucherRingPosition::Included { + ring_index, + included_at: ring_position, + }, + RingPosition::Onboarding { .. } | RingPosition::Suspended => { + VoucherRingPosition::Onboarding + } + } + }); + update.ring_positions.push((*derivation_index, position)); + } + VoucherLocationRequest::Status { derivation_index } => { + if let Some(status) = + decode_optional::(value, "Members.RingKeysStatus")? + { + update.ring_statuses.push(( + *derivation_index, + VoucherRingStatus { + included_members: status.included, + }, + )); + } + } + } + } + Ok(update) +} + +#[async_trait] +impl VoucherLocationSubscriber for QueryVoucherLocationSubscriber { + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + let requests = self.requests(pending, included, degraded).await?; + let storage = Arc::clone(&self.storage); + let observation_refresh_interval = self.observation_refresh_interval; + let (sender, receiver) = mpsc::channel(4); + (self.spawner)(Box::pin(async move { + // Start the live observer before the initial snapshot so a best + // change racing the query is buffered rather than lost. `None` + // makes `state_queryStorageAt` read the current best state, which + // is the same visibility used by the iOS storage subscription. + let mut heads = storage.observation_heads(); + match fetch_voucher_location_update(storage.as_ref(), &requests, None).await { + Ok(update) => { + if sender.send(update).await.is_err() { + return; + } + } + Err(error) => warn!(%error, "initial voucher location query failed"), + } + + let mut heads_open = true; + let refresh = crate::timer::sleep(observation_refresh_interval).fuse(); + futures::pin_mut!(refresh); + loop { + let open = heads_open; + let next_head = async { + if open { + heads.next().await + } else { + futures::future::pending().await + } + } + .fuse(); + let closed = sender.closed().fuse(); + futures::pin_mut!(next_head, closed); + let at = futures::select! { + head = next_head => match head { + Some(Ok(head)) => Some(head), + Some(Err(error)) => { + warn!(%error, "voucher location head update failed"); + continue; + } + None => { + heads_open = false; + continue; + } + }, + _ = refresh => { + refresh.set(crate::timer::sleep(observation_refresh_interval).fuse()); + None + }, + _ = closed => return, + }; + + if sender.is_closed() { + return; + } + match fetch_voucher_location_update(storage.as_ref(), &requests, at).await { + Ok(update) => { + if sender.send(update).await.is_err() { + return; + } + } + Err(error) => warn!(%error, "voucher location query failed"), + } + } + })); + Ok(receiver) + } +} + +/// Four-stage voucher query (`RecyclersCoinToRecycler` → `Members` → +/// `RecyclerAliasStates`), preserving the original derivation-index order. +pub struct VoucherOnChainQueryService { + storage: Arc, + public_key_provider: Arc, + alias_provider: Arc, +} + +impl VoucherOnChainQueryService { + pub fn new( + storage: Arc, + public_key_provider: Arc, + alias_provider: Arc, + ) -> Self { + Self { + storage, + public_key_provider, + alias_provider, + } + } + + pub async fn fetch_vouchers( + &self, + derivation_indices: &[u32], + at: Option<[u8; 32]>, + ) -> Result>, CoinageQueryError> { + self.fetch_voucher_observations(derivation_indices, at, false) + .await + } + + /// Recovery must observe every assigned derivation index, not only keys + /// already included in a recycler ring. Otherwise onboarding/suspended + /// keys could be allocated again after restoring the wallet. + /// + /// Every stage is pinned to the caller's finalized recovery snapshot. + /// An assignment without a Members row is ambiguous, not an empty index. + pub async fn fetch_recovery_vouchers( + &self, + derivation_indices: &[u32], + at: [u8; 32], + ) -> Result>, CoinageQueryError> { + self.fetch_voucher_observations(derivation_indices, Some(at), true) + .await + } + + async fn fetch_voucher_observations( + &self, + derivation_indices: &[u32], + at: Option<[u8; 32]>, + recovery: bool, + ) -> Result>, CoinageQueryError> { + if derivation_indices.is_empty() { + return Ok(Vec::new()); + } + let mut output = vec![None; derivation_indices.len()]; + + struct Candidate { + output_index: usize, + derivation_index: u32, + exponent: i16, + position: RingPosition, + ring_index: u32, + } + + // Step 1: key derivation. + let indexed_keys = derivation_indices + .iter() + .copied() + .enumerate() + .map(|(output_index, derivation_index)| { + (self.public_key_provider)(derivation_index) + .map(|public_key| (output_index, derivation_index, public_key)) + .map_err(CoinageQueryError::Storage) + }) + .collect::, _>>()?; + + // Step 2: recycler denomination. + let exponent_keys = indexed_keys + .iter() + .map(|(_, _, public_key)| CoinageStorageKey::Recycler(*public_key)) + .collect::>(); + let exponent_values = self + .storage + .query(&exponent_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Coinage.RecyclersCoinToRecycler", + exponent_keys.len(), + exponent_values.len(), + )?; + let mut with_exponents = Vec::new(); + for ((output_index, derivation_index, public_key), value) in + indexed_keys.into_iter().zip(exponent_values) + { + if let Some(exponent) = decode_optional::(value, "Coinage.RecyclersCoinToRecycler")? + { + with_exponents.push(( + output_index, + derivation_index, + public_key, + i16::from(exponent), + )); + } + } + if with_exponents.is_empty() { + return Ok(output); + } + + // Step 3: Members position under the denomination's recycler + // collection. Normal unload queries exclude onboarding/suspended + // rows; recovery retains them as used, non-unloadable indices. + let position_keys = with_exponents + .iter() + .map(|(_, _, public_key, exponent)| CoinageStorageKey::Member { + exponent: *exponent, + member: *public_key, + }) + .collect::>(); + let position_values = self + .storage + .query(&position_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Members.Members", + position_keys.len(), + position_values.len(), + )?; + let mut with_positions = Vec::::new(); + for ((output_index, derivation_index, _public_key, exponent), value) in + with_exponents.into_iter().zip(position_values) + { + let Some(position) = decode_optional::(value, "Members.Members")? else { + if recovery { + return Err(CoinageQueryError::Storage( + "Coinage recovery found a recycler assignment without a Members row".into(), + )); + } + continue; + }; + let RingPosition::Included { ring_index, .. } = position else { + if recovery { + output[output_index] = Some(VoucherOnChainInfo { + exponent, + ring_position: position, + is_unloaded: false, + }); + } + continue; + }; + with_positions.push(Candidate { + output_index, + derivation_index, + exponent, + position, + ring_index, + }); + } + if with_positions.is_empty() { + return Ok(output); + } + + // Step 4: alias state. A missing row is a loaded, unlocked alias; + // only an explicit `AliasState::Unloaded` counts as unloaded (a + // `Locked` row is still loaded — the lock only gates dispatch). + let state_keys = with_positions + .iter() + .map(|candidate| { + let alias = (self.alias_provider)(candidate.derivation_index) + .map_err(CoinageQueryError::Storage)?; + CoinageStorageKey::recycler_alias(candidate.exponent, candidate.ring_index, alias) + }) + .collect::, _>>()?; + let state_values = self + .storage + .query(&state_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Coinage.RecyclerAliasStates", + state_keys.len(), + state_values.len(), + )?; + + for (candidate, state) in with_positions.into_iter().zip(state_values) { + let state = decode_optional::(state, "Coinage.RecyclerAliasStates")?; + output[candidate.output_index] = Some(VoucherOnChainInfo { + exponent: candidate.exponent, + ring_position: candidate.position, + is_unloaded: state == Some(AliasState::Unloaded), + }); + } + Ok(output) + } +} + +pub struct RecyclerReadinessLoader { + storage: Arc, +} + +/// One coherent recycler-readiness snapshot. Every revision was read from +/// the same finalized block, and callers carry that block into unload +/// preparation rather than mixing roots observed at different heads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecyclerRevisionSnapshot { + pub block_hash: [u8; 32], + pub revisions: HashMap, +} + +impl RecyclerReadinessLoader { + pub fn new(storage: Arc) -> Self { + Self { storage } + } + + /// Captures the canonical finalized head and pins the complete revision + /// batch to it. This is the production entry point for unload + /// preparation: a missing root stays missing and must fail the caller + /// rather than being replaced by a guessed revision. + pub async fn fetch_finalized_revisions( + &self, + recycler_keys: &[RecyclerKey], + ) -> Result { + let block_hash = self + .storage + .finalized_head() + .await + .map_err(CoinageQueryError::Storage)?; + let revisions = self + .fetch_revisions(recycler_keys, Some(block_hash)) + .await?; + Ok(RecyclerRevisionSnapshot { + block_hash, + revisions, + }) + } + + pub async fn fetch_revisions( + &self, + recycler_keys: &[RecyclerKey], + at: Option<[u8; 32]>, + ) -> Result, CoinageQueryError> { + if recycler_keys.is_empty() { + return Ok(HashMap::new()); + } + let keys = recycler_keys + .iter() + .map(|recycler| CoinageStorageKey::Root { + exponent: recycler.exponent, + ring_index: recycler.index, + }) + .collect::>(); + let values = self + .storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len("Members.Root", keys.len(), values.len())?; + let mut revisions = HashMap::new(); + for (recycler, value) in recycler_keys.iter().copied().zip(values) { + if let Some(root) = decode_optional::(value, "Members.Root")? { + revisions.insert(recycler, root.revision); + } + } + Ok(revisions) + } +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + use std::collections::VecDeque; + use std::time::Duration; + + use futures::stream; + + use super::*; + + type QueryRecord = (Vec, Option<[u8; 32]>); + type QueryResponse = Result>>, String>; + type HeadBatch = Vec>; + + #[derive(Default)] + struct ScriptedStorage { + queries: Mutex>, + responses: Mutex>, + heads: Mutex>, + } + + impl ScriptedStorage { + fn push_response(&self, response: Vec>>) { + self.responses.lock().push_back(Ok(response)); + } + + fn push_heads(&self, heads: impl IntoIterator) { + self.heads + .lock() + .push_back(heads.into_iter().map(|byte| Ok([byte; 32])).collect()); + } + } + + #[async_trait] + impl CoinageStorageQuery for ScriptedStorage { + async fn query( + &self, + keys: &[CoinageStorageKey], + at: Option<[u8; 32]>, + ) -> Result>>, String> { + self.queries.lock().push((keys.to_vec(), at)); + self.responses + .lock() + .pop_front() + .expect("missing scripted response") + } + + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + Box::pin(stream::iter( + self.heads + .lock() + .pop_front() + .expect("missing scripted heads"), + )) + } + } + + fn raw_coin(value: i8, age: u16) -> Vec { + RawOnChainCoin { value, age }.encode() + } + + fn location_voucher(derivation_index: u32, exponent: i16) -> crate::Voucher { + crate::Voucher { + exponent, + derivation_index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: crate::VoucherRemoteState::Unlocated, + local_state: crate::VoucherLocalState::Available, + privacy: crate::VoucherPrivacyLevel::Degraded, + } + } + + #[test] + fn alias_query_rejects_out_of_range_exponent() { + assert!(matches!( + CoinageStorageKey::recycler_alias(500, 0, [0; 32]), + Err(CoinageQueryError::InvalidExponent(500)) + )); + } + + #[test] + fn alias_state_scale_wire_boundary() { + // AliasState wire shape: Unloaded is the bare variant 1; Locked + // carries reason + until. + assert_eq!(AliasState::Unloaded.encode(), vec![1]); + let locked = AliasState::Locked(LockInfo { + reason: LockReason::FailedDispatch(2), + until: 99, + }); + let encoded = locked.encode(); + assert_eq!(encoded[0], 0); + assert_eq!(encoded.len(), 1 + 1 + 1 + 8); + assert_eq!(AliasState::decode(&mut &encoded[..]).unwrap(), locked); + } + + #[tokio::test] + async fn voucher_location_batch_decodes_included_position_and_coverage() { + let storage = ScriptedStorage::default(); + storage.push_response(vec![ + Some( + RingPosition::Included { + ring_index: 7, + ring_page: 2, + ring_position: 4, + } + .encode(), + ), + Some( + members::RingStatus { + total: 9, + included: 5, + immutable_since: None, + } + .encode(), + ), + ]); + let requests = vec![ + ( + VoucherLocationRequest::Position { + derivation_index: 11, + }, + CoinageStorageKey::Member { + exponent: 3, + member: [11; 32], + }, + ), + ( + VoucherLocationRequest::Status { + derivation_index: 11, + }, + CoinageStorageKey::RingKeysStatus { + exponent: 3, + ring_index: 7, + }, + ), + ]; + + assert_eq!( + fetch_voucher_location_update(&storage, &requests, Some([9; 32])) + .await + .unwrap(), + VoucherLocationUpdate { + ring_positions: vec![( + 11, + Some(VoucherRingPosition::Included { + ring_index: 7, + included_at: 4, + }), + )], + ring_statuses: vec![( + 11, + VoucherRingStatus { + included_members: 5, + }, + )], + } + ); + } + + #[tokio::test] + async fn voucher_location_reads_current_best_then_follows_live_best_changes() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some( + RingPosition::Onboarding { + queue_page: 0, + queued_at: 10, + } + .encode(), + )]); + storage.push_response(vec![Some( + RingPosition::Included { + ring_index: 4, + ring_page: 0, + ring_position: 2, + } + .encode(), + )]); + storage.push_heads([0xA5]); + let vouchers = Arc::new(crate::InMemoryVoucherRepository::with_vouchers([ + location_voucher(7, 3), + ])); + let subscriber = QueryVoucherLocationSubscriber::new( + Arc::clone(&storage) as Arc, + vouchers, + Arc::new(|_| Ok([7; 32])), + crate::test_spawner(), + ); + + let mut updates = subscriber.subscribe(vec![7], vec![], vec![]).await.unwrap(); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![(7, Some(VoucherRingPosition::Onboarding))] + ); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![( + 7, + Some(VoucherRingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )] + ); + + let queries = storage.queries.lock(); + assert_eq!(queries.len(), 2); + assert_eq!(queries[0].1, None, "initial snapshot is current best"); + assert_eq!( + queries[1].1, + Some([0xA5; 32]), + "live best change pins the follow-up batch" + ); + } + + /// A `chainHead` follow can be rejected or terminate after the first + /// snapshot. The location observer must still re-read current best state, + /// otherwise a member position seen before `RingKeysStatus.included` + /// advances remains pending forever. + #[tokio::test] + async fn voucher_location_periodically_reconciles_after_head_stream_ends() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some( + RingPosition::Onboarding { + queue_page: 0, + queued_at: 10, + } + .encode(), + )]); + storage.push_response(vec![Some( + RingPosition::Included { + ring_index: 4, + ring_page: 0, + ring_position: 2, + } + .encode(), + )]); + storage.push_heads([]); + let vouchers = Arc::new(crate::InMemoryVoucherRepository::with_vouchers([ + location_voucher(7, 3), + ])); + let subscriber = QueryVoucherLocationSubscriber::new( + Arc::clone(&storage) as Arc, + vouchers, + Arc::new(|_| Ok([7; 32])), + crate::test_spawner(), + ) + .with_observation_refresh_interval(Duration::from_millis(10)); + + let mut updates = subscriber.subscribe(vec![7], vec![], vec![]).await.unwrap(); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![(7, Some(VoucherRingPosition::Onboarding))] + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), updates.recv()) + .await + .expect("periodic current-best reconciliation timed out") + .unwrap() + .ring_positions, + vec![( + 7, + Some(VoucherRingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )] + ); + + let queries = storage.queries.lock(); + assert_eq!(queries.len(), 2); + assert_eq!(queries[0].1, None); + assert_eq!(queries[1].1, None, "fallback re-reads current best"); + } + + #[tokio::test] + async fn coin_batch_fetch_preserves_order_and_strictly_decodes() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some(raw_coin(4, 12)), None, Some(raw_coin(-2, 1))]); + let service = CoinOnChainQueryService::new(storage.clone()); + let keys = [[1; 32], [2; 32], [3; 32]]; + assert_eq!( + service.fetch_coins(&keys, Some([9; 32])).await.unwrap(), + vec![ + Some(OnChainCoin { + exponent: 4, + age: 12 + }), + None, + Some(OnChainCoin { + exponent: -2, + age: 1 + }) + ] + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1, "one batch RPC"); + assert_eq!(calls[0].0.len(), 3); + assert_eq!(calls[0].1, Some([9; 32])); + } + + #[tokio::test] + async fn sent_or_claimed_accumulates_seen_after_an_earlier_coin_is_claimed() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([1, 2, 3]); + storage.push_response(vec![Some(raw_coin(1, 0)), None]); + storage.push_response(vec![None, Some(raw_coin(1, 0))]); + let service = CoinOnChainQueryService::new(storage); + let keys = [[1; 32], [2; 32]]; + assert!( + service + .await_all_coins_sent_or_claimed(&keys, 3) + .await + .unwrap(), + "the first key stays confirmed after it is spent; the second remains present" + ); + } + + #[tokio::test] + async fn sent_or_claimed_races_the_finalized_head_timeout() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([1, 2]); + storage.push_response(vec![None]); + storage.push_response(vec![None]); + let service = CoinOnChainQueryService::new(storage); + assert_eq!( + service + .await_all_coins_sent_or_claimed(&[[1; 32]], 2) + .await + .unwrap_err(), + CoinageQueryError::Timeout { finalized_heads: 2 } + ); + } + + #[tokio::test] + async fn historical_probe_confirms_a_fast_claim_without_a_live_wait() { + let storage = Arc::new(ScriptedStorage::default()); + // Current: both absent. Anchor: both were present. + storage.push_response(vec![None, None]); + storage.push_response(vec![Some(raw_coin(1, 0)), Some(raw_coin(2, 0))]); + let service = CoinOnChainQueryService::new(storage.clone()); + let result = service + .await_send_or_claimed(&[[1; 32], [2; 32]], Some([7; 32]), 10) + .await + .unwrap(); + assert_eq!(result, SendConfirmation::AlreadyClaimed); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].1, None); + assert_eq!(calls[1].1, Some([7; 32])); + } + + #[tokio::test] + async fn voucher_query_is_four_stages_and_preserves_nil_slots() { + let storage = Arc::new(ScriptedStorage::default()); + // Exponents: index 11 is not a recycler member. + storage.push_response(vec![Some(3i8.encode()), None, Some(5i8.encode())]); + // Positions for indices 10 and 12: index 12 is onboarding. + storage.push_response(vec![ + Some( + RingPosition::Included { + ring_index: 7, + ring_page: 0, + ring_position: 4, + } + .encode(), + ), + Some( + RingPosition::Onboarding { + queue_page: 1, + queued_at: 2, + } + .encode(), + ), + ]); + // An explicit `AliasState::Unloaded` row means unloaded. + storage.push_response(vec![Some(AliasState::Unloaded.encode())]); + + let public_key_provider: Arc = + Arc::new(|index| Ok([u8::try_from(index).unwrap(); 32])); + let alias_provider: Arc = + Arc::new(|index| Ok([u8::try_from(index + 1).unwrap(); 32])); + let service = + VoucherOnChainQueryService::new(storage.clone(), public_key_provider, alias_provider); + let result = service + .fetch_vouchers(&[10, 11, 12], Some([4; 32])) + .await + .unwrap(); + assert_eq!( + result, + vec![ + Some(VoucherOnChainInfo { + exponent: 3, + ring_position: RingPosition::Included { + ring_index: 7, + ring_page: 0, + ring_position: 4, + }, + is_unloaded: true, + }), + None, + None, + ] + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 3, "derivation plus three serial RPC batches"); + assert_eq!( + calls.iter().map(|call| call.0.len()).collect::>(), + [3, 2, 1] + ); + assert!(calls.iter().all(|call| call.1 == Some([4; 32]))); + } + + #[tokio::test] + async fn voucher_query_short_circuits_when_no_recycler_rows_exist() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![None, None]); + let service = VoucherOnChainQueryService::new( + storage.clone(), + Arc::new(|index| Ok([index as u8; 32])), + Arc::new(|index| Ok([(index + 1) as u8; 32])), + ); + assert_eq!( + service.fetch_vouchers(&[1, 2], None).await.unwrap(), + vec![None, None] + ); + assert_eq!( + storage.queries.lock().len(), + 1, + "an empty first stage must not issue empty RPC batches" + ); + } + + #[tokio::test] + async fn recycler_revision_fetch_omits_missing_roots() { + let storage = Arc::new(ScriptedStorage::default()); + let root = RingRoot { + root: [1; 288], + revision: 42, + intermediate: [2; 848], + }; + storage.push_response(vec![Some(root.encode()), None]); + let loader = RecyclerReadinessLoader::new(storage.clone()); + let first = RecyclerKey { + exponent: 3, + index: 7, + }; + let second = RecyclerKey { + exponent: 5, + index: 9, + }; + let revisions = loader + .fetch_revisions(&[first, second], Some([8; 32])) + .await + .unwrap(); + assert_eq!(revisions, HashMap::from([(first, 42)])); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0.len(), 2); + assert_eq!(calls[0].1, Some([8; 32])); + } + + #[tokio::test] + async fn recycler_readiness_uses_one_finalized_head_for_the_batch() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([0xA5]); + storage.push_response(vec![ + Some( + RingRoot { + root: [1; 288], + revision: 17, + intermediate: [2; 848], + } + .encode(), + ), + Some( + RingRoot { + root: [3; 288], + revision: 23, + intermediate: [4; 848], + } + .encode(), + ), + ]); + let loader = RecyclerReadinessLoader::new(storage.clone()); + let first = RecyclerKey { + exponent: 2, + index: 4, + }; + let second = RecyclerKey { + exponent: 7, + index: 9, + }; + + let snapshot = loader + .fetch_finalized_revisions(&[first, second]) + .await + .unwrap(); + + assert_eq!(snapshot.block_hash, [0xA5; 32]); + assert_eq!( + snapshot.revisions, + HashMap::from([(first, 17), (second, 23)]) + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1, "one Members.Root batch"); + assert_eq!(calls[0].0.len(), 2); + assert_eq!(calls[0].1, Some([0xA5; 32])); + } +} diff --git a/rust/crates/truapi-coinage/src/recipient.rs b/rust/crates/truapi-coinage/src/recipient.rs new file mode 100644 index 000000000..64da865b3 --- /dev/null +++ b/rust/crates/truapi-coinage/src/recipient.rs @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use {parking_lot::Mutex, std::sync::Arc}; + +use futures::future::AbortHandle; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::claim::{ + ClaimError, ClaimOrchestrator, ClaimStatus, ClaimStatusStore, IncomingClaim, SendConfirmation, + TransferSendVerifying, +}; +use crate::claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::tasks::ActiveTaskRegistry; + +/// One `coinageSend` chat message, already decoded by the chat layer: +/// `memo_key = TransferMemo::identifier` and the memo's declared total. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoinageSendMessage { + pub message_id: String, + pub memo_key: [u8; 32], + pub total_value: u128, +} + +pub struct TransferRecipientService { + spawner: crate::Spawner, + orchestrator: Arc, + plans: Arc, + verifier: Arc, + statuses: Arc, + registry: Arc, + loops: Mutex>, +} + +impl TransferRecipientService { + /// `statuses` must be the same store injected into `orchestrator`, so + /// incoming and outgoing pushes land on one per-message channel. + pub fn new( + orchestrator: Arc, + plans: Arc, + verifier: Arc, + statuses: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + orchestrator, + plans, + verifier, + statuses, + registry: Arc::new(ActiveTaskRegistry::new(Arc::clone(&spawner))), + spawner, + loops: Mutex::new(Vec::new()), + } + } + + pub fn registry(&self) -> Arc { + Arc::clone(&self.registry) + } + + pub async fn start( + self: &Arc, + incoming: mpsc::Receiver, + outgoing: mpsc::Receiver, + ) -> Result<(), String> { + self.orchestrator.restore_persisted_statuses().await?; + let mut loops = self.loops.lock(); + loops.push(self.spawn_loop(incoming, Direction::Incoming)); + loops.push(self.spawn_loop(outgoing, Direction::Outgoing)); + Ok(()) + } + + pub fn throttle(&self) { + for handle in self.loops.lock().drain(..) { + handle.abort(); + } + self.registry.cancel_all(); + } + + fn spawn_loop( + self: &Arc, + mut messages: mpsc::Receiver, + direction: Direction, + ) -> AbortHandle { + let service = Arc::clone(self); + let handle = crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(message) = messages.recv().await { + service.dispatch(message, direction); + } + }); + handle + } + + fn dispatch(self: &Arc, message: CoinageSendMessage, direction: Direction) { + if matches!( + self.statuses.status(&message.message_id), + Some(ClaimStatus::Finished { .. }) + ) { + return; + } + let service = Arc::clone(self); + let id = message.message_id.clone(); + self.registry.try_start(&id, async move { + match direction { + Direction::Incoming => service.run_incoming(message).await, + Direction::Outgoing => service.run_outgoing(message).await, + } + }); + } + + async fn run_incoming(&self, message: CoinageSendMessage) { + let outcome = self + .orchestrator + .claim_incoming(IncomingClaim { + memo_key: message.memo_key, + message_id: message.message_id.clone(), + total_value: message.total_value, + }) + .await; + match outcome { + Ok(claimed) => info!( + message_id = message.message_id, + claimed, "incoming coinage send claimed" + ), + Err(ClaimError::AlreadyClaiming) => { + info!( + message_id = message.message_id, + "claim already in flight for this memo" + ); + } + Err(ClaimError::Failed(error)) => { + warn!( + error, + message_id = message.message_id, + "incoming claim failed" + ); + } + } + } + + async fn run_outgoing(&self, message: CoinageSendMessage) { + if let Err(error) = self.verify_outgoing(&message).await { + warn!( + error, + message_id = message.message_id, + "outgoing send verification failed" + ); + if let Err(stamp_error) = self + .plans + .update_status(&message.memo_key, ClaimPlanStatus::Error, None) + .await + { + warn!(stamp_error, "outgoing error status stamp failed"); + } + self.statuses + .update_status(&message.message_id, ClaimStatus::Error); + } + } + + async fn verify_outgoing(&self, message: &CoinageSendMessage) -> Result<(), String> { + let existing = self.plans.plan(&message.memo_key).await?; + match &existing { + // Finished on a prior run: report only. + Some(plan) if plan.status == ClaimPlanStatus::Finished => { + self.statuses.update_status( + &message.message_id, + ClaimStatus::Finished { + claimed_amount: plan.claimed_amount.unwrap_or(plan.total_value), + }, + ); + return Ok(()); + } + Some(plan) if plan.status == ClaimPlanStatus::Detected => { + self.statuses + .update_status(&message.message_id, ClaimStatus::Sent); + self.verifier + .await_claim_on_chain(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await?; + } + other => { + if other.is_none() { + self.plans + .save(&ClaimPlan { + memo_key: message.memo_key, + message_id: Some(message.message_id.clone()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: message.total_value, + }) + .await?; + } + self.statuses + .update_status(&message.message_id, ClaimStatus::Detecting); + match self + .verifier + .await_send_or_claimed(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await? + { + SendConfirmation::OnChain => { + self.plans + .update_status(&message.memo_key, ClaimPlanStatus::Detected, None) + .await?; + self.statuses + .update_status(&message.message_id, ClaimStatus::Sent); + self.verifier + .await_claim_on_chain(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await?; + } + // Consumed before the watch saw them — the recipient + // already claimed; terminal. + SendConfirmation::AlreadyClaimed => {} + } + } + } + self.plans + .update_status( + &message.memo_key, + ClaimPlanStatus::Finished, + Some(message.total_value), + ) + .await?; + self.statuses.update_status( + &message.message_id, + ClaimStatus::Finished { + claimed_amount: message.total_value, + }, + ); + Ok(()) + } +} + +#[derive(Clone, Copy)] +enum Direction { + Incoming, + Outgoing, +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + + use super::*; + use crate::claim::ClaimExecutor; + use crate::claim_plan::CodableClaimPlanEntry; + use crate::denomination::DenominationBreakdownContext; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + #[derive(Default)] + struct MemPlans { + plans: Mutex>, + } + + #[async_trait] + impl ClaimPlanStore for MemPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.plans.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.plans.lock().get(memo_key).cloned()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.plans.lock().values().cloned().collect()) + } + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.plans.lock(); + let plan = plans.get_mut(memo_key).ok_or("plan not found")?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.plans.lock().remove(memo_key); + Ok(()) + } + } + + struct ScriptedVerifier { + plans: Arc, + send_or_claimed: Result, + claim_outcome: Result<(), String>, + send_or_claimed_calls: AtomicUsize, + claim_calls: AtomicUsize, + } + + impl ScriptedVerifier { + fn new( + plans: Arc, + send_or_claimed: Result, + claim_outcome: Result<(), String>, + ) -> Arc { + Arc::new(Self { + plans, + send_or_claimed, + claim_outcome, + send_or_claimed_calls: AtomicUsize::new(0), + claim_calls: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl TransferSendVerifying for ScriptedVerifier { + async fn await_send_on_chain( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result<(), String> { + Ok(()) + } + async fn await_claim_on_chain( + &self, + _memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String> { + assert_eq!(block_timeout, 100, "COINM-024"); + self.claim_calls.fetch_add(1, Ordering::SeqCst); + self.claim_outcome.clone() + } + async fn await_send_or_claimed( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result { + assert_eq!(block_timeout, 100, "COINM-024"); + self.send_or_claimed_calls.fetch_add(1, Ordering::SeqCst); + assert!( + self.plans.plan(memo_key).await.unwrap().is_some(), + "the .processing placeholder must exist before the send race (COINA-016)" + ); + self.send_or_claimed.clone() + } + } + + struct NoopExecutor; + + #[async_trait] + impl ClaimExecutor for NoopExecutor { + async fn claim( + &self, + _memo_key: &[u8; 32], + _message_id: &str, + ) -> Result, String> { + Ok(vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 0, + derivation_index: 1, + }]) + } + } + + fn service( + plans: Arc, + verifier: Arc, + ) -> (Arc, Arc) { + let statuses = Arc::new(ClaimStatusStore::default()); + let orchestrator = Arc::new(ClaimOrchestrator::new( + Arc::clone(&plans) as Arc, + Arc::clone(&verifier) as Arc, + Arc::new(NoopExecutor), + Arc::clone(&statuses), + ctx(), + )); + ( + Arc::new(TransferRecipientService::new( + orchestrator, + plans, + verifier, + Arc::clone(&statuses), + crate::test_spawner(), + )), + statuses, + ) + } + + fn message() -> CoinageSendMessage { + CoinageSendMessage { + message_id: "m1".into(), + memo_key: [7; 32], + total_value: 990, + } + } + + fn detected_plan() -> ClaimPlan { + ClaimPlan { + memo_key: [7; 32], + message_id: Some("m1".into()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Detected, + claimed_amount: None, + total_value: 990, + } + } + + async fn settle(statuses: &ClaimStatusStore, id: &str) -> ClaimStatus { + for _ in 0..1_000 { + if let Some(status) = statuses.status(id) + && status.is_terminal() + { + return status; + } + tokio::task::yield_now().await; + } + panic!("status never became terminal"); + } + + #[tokio::test] + async fn detected_plan_jumps_to_await_claim() { + let plans = Arc::new(MemPlans::default()); + plans.save(&detected_plan()).await.unwrap(); + let verifier = + ScriptedVerifier::new(Arc::clone(&plans), Ok(SendConfirmation::OnChain), Ok(())); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.send_or_claimed_calls.load(Ordering::SeqCst), 0); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 1); + let plan = plans.plan(&[7; 32]).await.unwrap().unwrap(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, Some(990)); + } + + #[tokio::test] + async fn fresh_outgoing_send_walks_the_full_path() { + let plans = Arc::new(MemPlans::default()); + let verifier = + ScriptedVerifier::new(Arc::clone(&plans), Ok(SendConfirmation::OnChain), Ok(())); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.send_or_claimed_calls.load(Ordering::SeqCst), 1); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 1); + } + + /// `AlreadyClaimed`: the coins were consumed before the watch — the + /// claim await is skipped entirely and the send finishes. + #[tokio::test] + async fn already_claimed_short_circuits_the_claim_await() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn timeout_is_a_terminal_error() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Err("timeout after 100 blocks".into()), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!(settle(&statuses, "m1").await, ClaimStatus::Error); + assert_eq!( + plans.plan(&[7; 32]).await.unwrap().unwrap().status, + ClaimPlanStatus::Error + ); + } + + #[tokio::test] + async fn loops_dedup_and_skip_finished_messages() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + let (incoming_tx, incoming_rx) = mpsc::channel(8); + let (outgoing_tx, outgoing_rx) = mpsc::channel(8); + service.start(incoming_rx, outgoing_rx).await.unwrap(); + + outgoing_tx.send(message()).await.unwrap(); + outgoing_tx.send(message()).await.unwrap(); + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + // Re-emission after finish: skipped by the terminal guard. + outgoing_tx.send(message()).await.unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!( + verifier.send_or_claimed_calls.load(Ordering::SeqCst), + 1, + "one verification for three emissions" + ); + + // Incoming path delegates to the orchestrator. + incoming_tx + .send(CoinageSendMessage { + message_id: "m2".into(), + memo_key: [8; 32], + total_value: 10, + }) + .await + .unwrap(); + assert_eq!( + settle(&statuses, "m2").await, + ClaimStatus::Finished { claimed_amount: 10 } + ); + service.throttle(); + } + + /// `throttle` stops the loops: messages sent afterwards are never + /// processed. + #[tokio::test] + async fn throttle_stops_the_subscription_loops() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + let (_incoming_tx, incoming_rx) = mpsc::channel::(8); + let (outgoing_tx, outgoing_rx) = mpsc::channel(8); + service.start(incoming_rx, outgoing_rx).await.unwrap(); + service.throttle(); + + outgoing_tx.send(message()).await.unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!(statuses.status("m1"), None, "loop is dead after throttle"); + } +} diff --git a/rust/crates/truapi-coinage/src/recovery.rs b/rust/crates/truapi-coinage/src/recovery.rs new file mode 100644 index 000000000..c7f277335 --- /dev/null +++ b/rust/crates/truapi-coinage/src/recovery.rs @@ -0,0 +1,1296 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::warn; + +use crate::model::{ + Coin, CoinState, Voucher, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState, +}; +use crate::repo::{CoinRepository, VoucherRepository}; +use crate::wal::{TransferWalEntry, WalOperation, WalStore}; + +/// The chain-lookup effect. Presence answers are positional (same order +/// as the input indices); implementations typically batch these through +/// `chain::get_head_storage`. +#[async_trait] +pub trait RecoveryChainProbe: Send + Sync { + /// Current finalized head number. + async fn finalized_block(&self) -> Result; + + /// Canonical block hash at a height; `None` when unavailable. + async fn canonical_hash(&self, block_number: u64) -> Result, String>; + + /// Whether each coin derivation index currently has an on-chain + /// `CoinsByOwner` entry. + async fn coins_present(&self, derivation_indices: &[u32]) -> Result, String>; + + /// Exact denomination at the finalized snapshot of this recovery sweep. + /// Responses are positional and must cover every requested index. `None` + /// proves absence; query errors or truncated batches are not absence. + async fn coin_exponents(&self, derivation_indices: &[u32]) -> Result>, String>; + + /// Whether each voucher derivation index is currently present + /// on-chain (in a recycler / member set). + async fn vouchers_present(&self, derivation_indices: &[u32]) -> Result, String>; +} + +/// One sweep's outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RecoveryReport { + /// Entries whose extrinsic landed (outputs materialized). + pub landed: usize, + /// Entries resolved by input consumption without visible outputs. + pub inputs_consumed: usize, + /// Entries dead (expired / forked / never broadcast) — inputs + /// reverted. + pub reverted: usize, + /// Entries left journaled for the next sweep. + pub still_pending: usize, + pub orphans_restored: usize, + pub orphans_deleted: usize, +} + +pub struct TransferRecoveryService { + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: Arc, +} + +impl TransferRecoveryService { + pub fn new( + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: Arc, + ) -> Self { + Self { + wal, + coins, + vouchers, + probe, + } + } + + /// One recovery pass: resolve every WAL entry, then sweep orphans. + /// Never guesses a terminal state for money in flight — an entry + /// stays pending until the chain proves it landed or died. + pub async fn recover(&self) -> Result { + let entries = self.wal.load_all().await?; + let finalized = self.probe.finalized_block().await?; + let mut report = RecoveryReport::default(); + let parents = entries + .iter() + .filter(|entry| entry.operation.is_transfer_receipt()) + .map(|entry| (entry.entry_id.as_str(), entry.operation)) + .collect::>(); + + for entry in &entries { + let parent = entry.operation_parent_id(); + let parent_state = parent.as_deref().and_then(|id| parents.get(id)).copied(); + match self.resolve_entry(entry, finalized, parent_state).await { + Ok(Resolution::Landed) => report.landed += 1, + Ok(Resolution::InputsConsumed) => report.inputs_consumed += 1, + Ok(Resolution::Reverted) => report.reverted += 1, + Ok(Resolution::StillPending) => report.still_pending += 1, + Ok(Resolution::Receipt) => {} + Err(error) => { + warn!( + error, + entry_id = entry.entry_id, + "wal recovery probe failed" + ); + report.still_pending += 1; + } + } + } + + let remaining = self.wal.load_all().await?; + for parent in remaining + .iter() + .filter(|entry| entry.operation == WalOperation::TransferAccepted) + { + if !remaining.iter().any(|entry| { + entry.entry_id != parent.entry_id + && entry.operation_parent_id().as_deref() == Some(parent.entry_id.as_str()) + }) { + let mut completed = parent.clone(); + completed.operation = WalOperation::TransferCompleted; + self.wal.save(&completed).await?; + } + } + let (protected_coins, protected_vouchers) = referenced_indices(&remaining); + + for coin in self.coins.list().await? { + let orphaned = matches!( + coin.state, + CoinState::PendingTransfer | CoinState::Recycling + ) && !protected_coins.contains(&coin.derivation_index); + if orphaned { + self.coins + .set_state(coin.derivation_index, CoinState::Available) + .await?; + report.orphans_restored += 1; + } + } + for voucher in self.vouchers.list().await? { + if protected_vouchers.contains(&voucher.derivation_index) { + continue; + } + match voucher.local_state { + VoucherLocalState::PendingTransfer => { + self.vouchers + .set_local_state(voucher.derivation_index, VoucherLocalState::Available) + .await?; + report.orphans_restored += 1; + } + VoucherLocalState::PendingOnboarding => { + self.vouchers.remove(voucher.derivation_index).await?; + report.orphans_deleted += 1; + } + _ => {} + } + } + Ok(report) + } + + async fn resolve_entry( + &self, + entry: &TransferWalEntry, + finalized: u64, + parent_state: Option, + ) -> Result { + if entry.operation.is_transfer_receipt() { + // Receipts preserve idempotency after children disappear. Prepared + // is deliberately not interpreted as accepted or rejected. + return Ok(Resolution::Receipt); + } + let correlated = entry.operation_parent_id().is_some(); + if correlated { + match parent_state { + Some(WalOperation::TransferRejected) + if matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) => + { + self.revert_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Some(WalOperation::TransferAccepted) => {} + // Missing/ambiguous parent evidence must never unlock inputs. + _ => return Ok(Resolution::StillPending), + } + } + let dead = match entry.checkpoint { + crate::wal::CheckpointBlock::Pending => true, + crate::wal::CheckpointBlock::Known { number, .. } => { + let canonical = self.probe.canonical_hash(number).await?; + entry.is_forked(canonical.as_ref()) || entry.is_expired(finalized) + } + }; + + match entry.operation { + // A handed-off expanded secret never expires. The recipient + // may claim it long after an extrinsic mortality window, so + // presence always remains reserved and only all-input absence + // is terminal. + WalOperation::SecretHandoff => { + if correlated || self.inputs_consumed(entry).await? { + self.retire_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + Ok(Resolution::StillPending) + } + WalOperation::IntoCoins | WalOperation::Split => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) { + let output_indices: Vec = entry + .payload + .output_coins + .iter() + .map(|c| c.derivation_index) + .collect(); + let outputs = if correlated { + let exponents = self.probe.coin_exponents(&output_indices).await?; + if exponents.len() != output_indices.len() { + return Err("coin recovery probe returned an incomplete batch".into()); + } + if exponents.iter().zip(&entry.payload.output_coins).any( + |(actual, expected)| { + actual.is_some_and(|exponent| exponent != expected.exponent) + }, + ) { + return Err( + "recovered output denomination differs from the approved plan" + .into(), + ); + } + exponents + .into_iter() + .map(|exponent| exponent.is_some()) + .collect::>() + } else { + self.probe.coins_present(&output_indices).await? + }; + if outputs.len() != output_indices.len() { + return Err("coin recovery probe returned an incomplete batch".into()); + } + let landed = if correlated { + !outputs.is_empty() && outputs.iter().all(|present| *present) + } else { + outputs.iter().any(|present| *present) + }; + if correlated + && outputs.iter().any(|present| *present) + && (!landed || !self.inputs_consumed(entry).await?) + { + // Partial/mixed evidence cannot certify this allocation + // and must never authorize rebroadcast after mortality. + return Ok(Resolution::StillPending); + } + if landed { + // Landed: materialize outputs, retire inputs. + for (reference, present) in entry.payload.output_coins.iter().zip(&outputs) + { + if *present { + let destination = + entry.payload.destination_coins.iter().any(|coin| { + coin.derivation_index == reference.derivation_index + }); + self.coins + .upsert(&Coin { + exponent: reference.exponent, + derivation_index: reference.derivation_index, + age: None, + state: if destination { + CoinState::Spent + } else { + CoinState::Available + }, + }) + .await?; + } + } + self.retire_transfer_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Landed); + } + if self.inputs_consumed(entry).await? { + if correlated { + // Consumption alone does not prove this operation + // produced its promised outputs. Retain the receipt + // and checkpoint rather than reporting completion. + return Ok(Resolution::StillPending); + } + self.retire_transfer_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + } + if dead { + if correlated { + // Keep the SAME allocated outputs and input reservation. + // Only explicit host resume may recreate the extrinsic. + // Mixed input presence is ambiguous, not permission to + // spend the remaining inputs in a second transaction. + if matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + || self.inputs_present(entry).await? + { + self.wal + .update_checkpoint( + &entry.entry_id, + crate::wal::CheckpointBlock::Pending, + ) + .await?; + } + return Ok(Resolution::StillPending); + } + self.revert_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::IntoExternalAsset => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + && self.inputs_consumed(entry).await? + { + self.retire_inputs(entry).await?; + self.confirm_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + if dead { + self.revert_inputs(entry).await?; + self.delete_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::RecycleIntoVoucher => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + && self.inputs_consumed(entry).await? + { + self.retire_inputs(entry).await?; + self.confirm_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Landed); + } + if dead { + self.revert_inputs(entry).await?; + self.delete_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::TransferPrepared + | WalOperation::TransferAccepted + | WalOperation::TransferCompleted + | WalOperation::TransferRejected => Ok(Resolution::Receipt), + } + } + + async fn inputs_consumed(&self, entry: &TransferWalEntry) -> Result { + let coin_indices: Vec = entry + .payload + .input_coins + .iter() + .map(|c| c.derivation_index) + .collect(); + let voucher_indices: Vec = entry + .payload + .input_vouchers + .iter() + .map(|v| v.derivation_index) + .collect(); + if coin_indices.is_empty() && voucher_indices.is_empty() { + return Ok(false); + } + let coins = self.probe.coins_present(&coin_indices).await?; + let vouchers = self.probe.vouchers_present(&voucher_indices).await?; + if coins.len() != coin_indices.len() || vouchers.len() != voucher_indices.len() { + return Err("input recovery probe returned an incomplete batch".into()); + } + Ok(coins.iter().all(|present| !present) && vouchers.iter().all(|present| !present)) + } + + async fn inputs_present(&self, entry: &TransferWalEntry) -> Result { + let coin_indices = entry + .payload + .input_coins + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + let voucher_indices = entry + .payload + .input_vouchers + .iter() + .map(|voucher| voucher.derivation_index) + .collect::>(); + let coins = self.probe.coins_present(&coin_indices).await?; + let vouchers = self.probe.vouchers_present(&voucher_indices).await?; + if coins.len() != coin_indices.len() || vouchers.len() != voucher_indices.len() { + return Err("input recovery probe returned an incomplete batch".into()); + } + Ok(coins.iter().all(|present| *present) && vouchers.iter().all(|present| *present)) + } + + async fn retire_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Spent) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state(input.derivation_index, VoucherLocalState::Spent) + .await?; + } + Ok(()) + } + + /// Split/unload parity: consumed vouchers are removed from the local + /// purse rather than retained as spent tombstones. Destination outputs + /// remain spent rows so sync/monitoring can observe their lifecycle. + async fn retire_transfer_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Spent) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers.remove(input.derivation_index).await?; + } + Ok(()) + } + + async fn revert_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Available) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state(input.derivation_index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } + + async fn confirm_surplus_vouchers(&self, entry: &TransferWalEntry) -> Result<(), String> { + let indices: Vec = entry + .payload + .output_vouchers + .iter() + .map(|v| v.derivation_index) + .collect(); + if indices.is_empty() { + return Ok(()); + } + let present = self.probe.vouchers_present(&indices).await?; + let known: HashSet = self + .vouchers + .list() + .await? + .into_iter() + .map(|v| v.derivation_index) + .collect(); + for (reference, present) in entry.payload.output_vouchers.iter().zip(&present) { + if !*present { + continue; + } + if known.contains(&reference.derivation_index) { + self.vouchers + .set_local_state(reference.derivation_index, VoucherLocalState::Available) + .await?; + } else { + // Materialize a minimal record; the voucher location + // service reconciles remote state and readiness + self.vouchers + .upsert(&Voucher { + exponent: reference.exponent, + derivation_index: reference.derivation_index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Degraded, + }) + .await?; + } + } + Ok(()) + } + + async fn delete_surplus_vouchers(&self, entry: &TransferWalEntry) -> Result<(), String> { + for output in &entry.payload.output_vouchers { + self.vouchers.remove(output.derivation_index).await?; + } + Ok(()) + } +} + +enum Resolution { + Landed, + InputsConsumed, + Reverted, + StillPending, + Receipt, +} + +/// The coin/voucher indices any journaled entry still references. +fn referenced_indices(entries: &[TransferWalEntry]) -> (HashSet, HashSet) { + let mut coins = HashSet::new(); + let mut vouchers = HashSet::new(); + for entry in entries { + coins.extend(entry.payload.input_coins.iter().map(|c| c.derivation_index)); + vouchers.extend( + entry + .payload + .input_vouchers + .iter() + .map(|v| v.derivation_index), + ); + vouchers.extend( + entry + .payload + .output_vouchers + .iter() + .map(|v| v.derivation_index), + ); + } + (coins, vouchers) +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + + use super::*; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + use crate::wal::{CheckpointBlock, WalCoinRef, WalPayload}; + + /// In-memory WAL store for the sweep tests. + #[derive(Default)] + struct MemWal { + entries: Mutex>, + } + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.entries.lock(); + entries.retain(|existing| existing.entry_id != entry.entry_id); + entries.push(entry.clone()); + Ok(()) + } + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + let mut stored = self.entries.lock(); + stored.retain(|existing| { + !entries + .iter() + .any(|entry| entry.entry_id == existing.entry_id) + }); + stored.extend_from_slice(entries); + Ok(()) + } + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.entries.lock(); + let entry = entries + .iter_mut() + .find(|e| e.entry_id == entry_id) + .ok_or("wal entry not found")?; + entry.checkpoint = checkpoint; + Ok(()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.entries.lock().clone()) + } + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.entries.lock().retain(|e| e.entry_id != entry_id); + Ok(()) + } + } + + struct MockProbe { + finalized: u64, + canonical: Option<[u8; 32]>, + coins_on_chain: HashSet, + vouchers_on_chain: HashSet, + output_exponents: HashMap, + } + + impl Default for MockProbe { + fn default() -> Self { + Self { + finalized: 100, + canonical: Some([1; 32]), + coins_on_chain: HashSet::new(), + vouchers_on_chain: HashSet::new(), + output_exponents: HashMap::new(), + } + } + } + + #[async_trait] + impl RecoveryChainProbe for MockProbe { + async fn finalized_block(&self) -> Result { + Ok(self.finalized) + } + async fn canonical_hash(&self, _block_number: u64) -> Result, String> { + Ok(self.canonical) + } + async fn coins_present(&self, indices: &[u32]) -> Result, String> { + Ok(indices + .iter() + .map(|i| self.coins_on_chain.contains(i)) + .collect()) + } + async fn coin_exponents(&self, indices: &[u32]) -> Result>, String> { + Ok(indices + .iter() + .map(|index| self.output_exponents.get(index).copied()) + .collect()) + } + async fn vouchers_present(&self, indices: &[u32]) -> Result, String> { + Ok(indices + .iter() + .map(|i| self.vouchers_on_chain.contains(i)) + .collect()) + } + } + + fn coin(index: u32, state: CoinState) -> Coin { + Coin { + exponent: 1, + derivation_index: index, + age: Some(1), + state, + } + } + + fn service( + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: MockProbe, + ) -> TransferRecoveryService { + TransferRecoveryService::new(wal, coins, vouchers, Arc::new(probe)) + } + + fn into_coins_entry(checkpoint: CheckpointBlock) -> TransferWalEntry { + TransferWalEntry { + entry_id: "e1".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + input_vouchers: vec![], + output_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 0, + }], + output_vouchers: vec![], + destination_coins: vec![], + }, + checkpoint, + created_at_ms: 0, + } + } + + async fn coin_state(repo: &InMemoryCoinRepository, index: u32) -> CoinState { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + .state + } + + /// A `Pending` checkpoint means the extrinsic never left the device: + /// the input coin reverts to `.available` and the entry is dropped. + #[tokio::test] + async fn crash_between_wal_write_and_broadcast_reverts_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Pending)) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + assert!(wal.load_all().await.unwrap().is_empty(), "entry retired"); + } + + #[tokio::test] + async fn into_coins_landed_materializes_outputs_and_retires_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let probe = MockProbe { + coins_on_chain: HashSet::from([50]), + ..MockProbe::default() + }; + let service = service(Arc::clone(&wal), Arc::clone(&coins), vouchers, probe); + + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + let listed = coins.list().await.unwrap(); + let output = listed.iter().find(|c| c.derivation_index == 50).unwrap(); + assert_eq!(output.state, CoinState::Available); + assert_eq!(output.age, None, "age unknown until first sync"); + assert!(wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn unload_recovery_removes_consumed_voucher_and_keeps_destination_spent() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "unload".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload { + input_coins: vec![], + input_vouchers: vec![WalCoinRef { + derivation_index: 10, + exponent: 2, + }], + output_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 2, + }], + output_vouchers: vec![], + destination_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 2, + }], + }, + checkpoint: CheckpointBlock::Known { + number: 90, + hash: [1; 32], + }, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::default()); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([Voucher { + exponent: 2, + derivation_index: 10, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 1 }, + local_state: VoucherLocalState::PendingTransfer, + privacy: VoucherPrivacyLevel::Full, + }])); + let probe = MockProbe { + coins_on_chain: HashSet::from([50]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert!(vouchers.list().await.unwrap().is_empty()); + assert_eq!(coin_state(&coins, 50).await, CoinState::Spent); + assert!(wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn forked_checkpoint_reverts_immediately() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [9; 32], // stored hash ≠ canonical [1; 32] + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + // Input still on-chain (not consumed), output absent, + // finalized 100 < 90 + 300 (not expired) — ONLY the fork + // can resolve this entry. + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn unresolved_entry_stays_pending() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + // Input still on-chain, output absent, not expired. + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.still_pending, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn expired_entry_reverts_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + finalized: 391, // > 90 + 300 + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn recycle_into_voucher_confirms_the_surplus_voucher() { + use crate::model::{VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "r1".into(), + operation: WalOperation::RecycleIntoVoucher, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + input_vouchers: vec![], + output_coins: vec![], + output_vouchers: vec![WalCoinRef { + derivation_index: 70, + exponent: 1, + }], + destination_coins: vec![], + }, + checkpoint: CheckpointBlock::Known { + number: 90, + hash: [1; 32], + }, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::Recycling, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([Voucher { + exponent: 1, + derivation_index: 70, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::PendingOnboarding, + privacy: VoucherPrivacyLevel::Full, + }])); + let probe = MockProbe { + vouchers_on_chain: HashSet::from([70]), + // Input coin 1 absent → consumed. + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + } + + #[tokio::test] + async fn orphaned_pending_assets_are_restored_or_deleted() { + use crate::model::{VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + let coins = Arc::new(InMemoryCoinRepository::with_coins([ + coin(1, CoinState::PendingTransfer), + coin(2, CoinState::Recycling), + coin(3, CoinState::Spent), + ])); + let orphan_voucher = |index, local_state| Voucher { + exponent: 0, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Unlocated, + local_state, + privacy: VoucherPrivacyLevel::Full, + }; + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([ + orphan_voucher(10, VoucherLocalState::PendingTransfer), + orphan_voucher(11, VoucherLocalState::PendingOnboarding), + ])); + let service = service( + Arc::new(MemWal::default()), + Arc::clone(&coins), + Arc::clone(&vouchers), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.orphans_restored, 3, "two coins + one voucher"); + assert_eq!(report.orphans_deleted, 1, "pending-onboarding voucher"); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + assert_eq!(coin_state(&coins, 2).await, CoinState::Available); + assert_eq!( + coin_state(&coins, 3).await, + CoinState::Spent, + "spent untouched" + ); + let listed = vouchers.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].derivation_index, 10); + assert_eq!(listed[0].local_state, VoucherLocalState::Available); + } + + /// A journaled still-pending entry PROTECTS its assets from the + /// orphan sweep. + #[tokio::test] + async fn journaled_assets_are_not_swept_as_orphans() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + coins_on_chain: HashSet::from([1]), // unresolved + ..MockProbe::default() + }; + let service = service( + wal, + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.orphans_restored, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + /// Expanded secrets have no mortality. Even a very old pending + /// checkpoint remains protected while its coin is present. + #[tokio::test] + async fn secret_handoff_never_mortality_reverts_a_present_coin() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "secret-1".into(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + MockProbe { + finalized: u64::MAX, + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.still_pending, 1); + assert_eq!(report.reverted, 0); + assert_eq!(report.orphans_restored, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + } + + /// Chain absence is the sole terminal proof for an out-of-band + /// secret handoff. + #[tokio::test] + async fn secret_handoff_absence_retires_inputs_and_journal() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "secret-2".into(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.inputs_consumed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert!(wal.load_all().await.unwrap().is_empty()); + } + async fn operation_fixture( + state: WalOperation, + checkpoint: CheckpointBlock, + probe: MockProbe, + ) -> ( + TransferRecoveryService, + Arc, + Arc, + ) { + let mut child = into_coins_entry(checkpoint); + child.entry_id = crate::wal::operation_entry_id("host-payment", "split"); + child.operation = WalOperation::Split; + child.payload.output_coins.push(WalCoinRef { + derivation_index: 51, + exponent: 0, + }); + child.payload.destination_coins = child.payload.output_coins.clone(); + let parent = TransferWalEntry { + entry_id: crate::wal::operation_entry_id("host-payment", "parent"), + operation: state, + payload: WalPayload { + output_coins: child.payload.destination_coins.clone(), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }; + let wal = Arc::new(MemWal::default()); + wal.save_all(&[parent, child]).await.unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let recovery = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + (recovery, wal, coins) + } + + #[tokio::test] + async fn prepared_transport_ambiguity_never_releases_a_pending_chain_input() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferPrepared, + CheckpointBlock::Pending, + MockProbe { + finalized: 10_000, + ..MockProbe::default() + }, + ) + .await; + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferPrepared) + ); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::Split) + ); + } + + #[tokio::test] + async fn expired_accepted_plan_is_resumeable_without_unlocking_or_reallocating() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }, + MockProbe { + finalized: 1_000, + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }, + ) + .await; + let original = wal + .load_operation("host-payment") + .await + .unwrap() + .into_iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + service.recover().await.unwrap(); + service.recover().await.unwrap(); + let restored = wal + .load_operation("host-payment") + .await + .unwrap() + .into_iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + assert_eq!(restored.entry_id, original.entry_id); + assert_eq!(restored.payload, original.payload); + assert_eq!(restored.checkpoint, CheckpointBlock::Pending); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + #[tokio::test] + async fn consumed_inputs_without_output_evidence_cannot_complete_an_operation() { + let checkpoint = CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }; + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + checkpoint, + MockProbe { + finalized: 1_000, + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + let child = rows + .iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + assert_eq!(child.checkpoint, checkpoint); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + #[tokio::test] + async fn landed_operation_keeps_its_receipt_after_repeated_recovery() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }, + MockProbe { + coins_on_chain: HashSet::from([50, 51]), + output_exponents: HashMap::from([(50, 0), (51, 0)]), + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + service.recover().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!(coin_state(&coins, 50).await, CoinState::Spent); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].operation, WalOperation::TransferCompleted); + assert_eq!(rows[0].payload.output_coins[0].derivation_index, 50); + } + + #[tokio::test] + async fn partial_or_wrong_output_evidence_cannot_complete_or_rebroadcast() { + for outputs in [HashMap::from([(50, 0)]), HashMap::from([(50, 0), (51, 4)])] { + let checkpoint = CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }; + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + checkpoint, + MockProbe { + finalized: 1_000, + coins_on_chain: HashSet::from([1, 50, 51]), + output_exponents: outputs, + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + assert_eq!( + rows.iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap() + .checkpoint, + checkpoint + ); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + } +} diff --git a/rust/crates/truapi-coinage/src/repo.rs b/rust/crates/truapi-coinage/src/repo.rs new file mode 100644 index 000000000..4f992363c --- /dev/null +++ b/rust/crates/truapi-coinage/src/repo.rs @@ -0,0 +1,571 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Coin/voucher repository contracts and the transfer reservation +//! +//! The repositories are the local-DB effect boundary: durable implementations +//! belong to the Host; in-memory implementations here serve tests and the +//! executable contract. [`TransferContext`] is the reserve → process / +//! revert lifecycle wrapped around one transfer: reserve marks inputs +//! `PendingTransfer` before any extrinsic, process retires spent inputs, +//! revert restores whatever is still pending after a failure. + +use parking_lot::Mutex; +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::model::{Coin, CoinState, Voucher, VoucherLocalState, VoucherRemoteState}; + +/// Local coin persistence effect. +#[async_trait] +pub trait CoinRepository: Send + Sync { + async fn list(&self) -> Result, String>; + + /// Insert or replace by `derivation_index`. + async fn upsert(&self, coin: &Coin) -> Result<(), String>; + + /// Moves one coin's state; unknown index is an error (a recovery + /// sweep must never silently miss). + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String>; + + async fn remove(&self, derivation_index: u32) -> Result<(), String>; +} + +/// Local voucher persistence effect. +#[async_trait] +pub trait VoucherRepository: Send + Sync { + async fn list(&self) -> Result, String>; + + async fn upsert(&self, voucher: &Voucher) -> Result<(), String>; + + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String>; + + /// Updates only the chain-location projection. Unknown indices are + /// errors so a subscription cannot silently lose a mapper request. + async fn set_remote_state( + &self, + derivation_index: u32, + state: VoucherRemoteState, + ) -> Result<(), String>; + + async fn remove(&self, derivation_index: u32) -> Result<(), String>; +} + +/// Optional persistence fast path for one logical strategy/group commit. +/// SQLite uses this to make output insertion, input retirement, and voucher +/// deletion one transaction; in-memory/domain-only compositions can retain +/// the repository fallback. +#[async_trait] +pub trait TransferStateCommitter: Send + Sync { + async fn reserve(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String>; + + async fn revert(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String>; + + async fn commit( + &self, + spent_coins: &[u32], + spent_vouchers: &[u32], + change: &[Coin], + destination: &[Coin], + ) -> Result<(), String>; +} + +/// Mutex-guarded in-memory [`CoinRepository`]. +#[derive(Default)] +pub struct InMemoryCoinRepository { + coins: Mutex>, +} + +impl InMemoryCoinRepository { + pub fn with_coins(coins: impl IntoIterator) -> Self { + Self { + coins: Mutex::new(coins.into_iter().map(|c| (c.derivation_index, c)).collect()), + } + } +} + +#[async_trait] +impl CoinRepository for InMemoryCoinRepository { + async fn list(&self) -> Result, String> { + let mut coins: Vec = self.coins.lock().values().cloned().collect(); + coins.sort_by_key(|c| c.derivation_index); + Ok(coins) + } + + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.coins + .lock() + .insert(coin.derivation_index, coin.clone()); + Ok(()) + } + + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + match self.coins.lock().get_mut(&derivation_index) { + Some(coin) => { + coin.state = state; + Ok(()) + } + None => Err(format!("coin {derivation_index} not found")), + } + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.coins.lock().remove(&derivation_index); + Ok(()) + } +} + +/// Mutex-guarded in-memory [`VoucherRepository`]. +#[derive(Default)] +pub struct InMemoryVoucherRepository { + vouchers: Mutex>, +} + +impl InMemoryVoucherRepository { + pub fn with_vouchers(vouchers: impl IntoIterator) -> Self { + Self { + vouchers: Mutex::new( + vouchers + .into_iter() + .map(|v| (v.derivation_index, v)) + .collect(), + ), + } + } +} + +#[async_trait] +impl VoucherRepository for InMemoryVoucherRepository { + async fn list(&self) -> Result, String> { + let mut vouchers: Vec = self.vouchers.lock().values().cloned().collect(); + vouchers.sort_by_key(|v| v.derivation_index); + Ok(vouchers) + } + + async fn upsert(&self, voucher: &Voucher) -> Result<(), String> { + self.vouchers + .lock() + .insert(voucher.derivation_index, voucher.clone()); + Ok(()) + } + + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String> { + match self.vouchers.lock().get_mut(&derivation_index) { + Some(voucher) => { + voucher.local_state = state; + Ok(()) + } + None => Err(format!("voucher {derivation_index} not found")), + } + } + + async fn set_remote_state( + &self, + derivation_index: u32, + state: VoucherRemoteState, + ) -> Result<(), String> { + match self.vouchers.lock().get_mut(&derivation_index) { + Some(voucher) => { + voucher.remote_state = state; + Ok(()) + } + None => Err(format!("voucher {derivation_index} not found")), + } + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.vouchers.lock().remove(&derivation_index); + Ok(()) + } +} + +use std::sync::Arc; + +/// The reserve → process / revert lifecycle around one transfer +/// +/// - [`reserve`](Self::reserve) marks inputs `PendingTransfer` BEFORE the +/// strategy runs — a process death here is caught by orphan recovery; +/// - [`process`](Self::process) retires spent inputs, removing them from +/// the pending sets before the first await; if a later `set_state` call +/// fails, the removed ids are re-appended so `revert` can still find +/// them; +/// - [`revert`](Self::revert) restores everything still pending back to +/// `Available`. +pub struct TransferContext { + coins: Arc, + vouchers: Arc, + pending_coins: Mutex>, + pending_vouchers: Mutex>, + committer: Option>, +} + +impl TransferContext { + pub fn new(coins: Arc, vouchers: Arc) -> Self { + Self { + coins, + vouchers, + pending_coins: Mutex::new(Vec::new()), + pending_vouchers: Mutex::new(Vec::new()), + committer: None, + } + } + + pub fn with_committer(mut self, committer: Arc) -> Self { + self.committer = Some(committer); + self + } + + pub async fn reserve(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + if let Some(committer) = &self.committer { + committer.reserve(coins, vouchers).await?; + self.pending_coins.lock().extend_from_slice(coins); + self.pending_vouchers.lock().extend_from_slice(vouchers); + return Ok(()); + } + let mut reserved_coins = Vec::new(); + for &index in coins { + if let Err(error) = self + .coins + .set_state(index, CoinState::PendingTransfer) + .await + { + for reserved in reserved_coins { + let _ = self.coins.set_state(reserved, CoinState::Available).await; + } + self.pending_coins.lock().clear(); + return Err(error); + } + reserved_coins.push(index); + self.pending_coins.lock().push(index); + } + let mut reserved_vouchers = Vec::new(); + for &index in vouchers { + if let Err(error) = self + .vouchers + .set_local_state(index, VoucherLocalState::PendingTransfer) + .await + { + for reserved in reserved_vouchers { + let _ = self + .vouchers + .set_local_state(reserved, VoucherLocalState::Available) + .await; + } + for reserved in reserved_coins { + let _ = self.coins.set_state(reserved, CoinState::Available).await; + } + self.pending_coins.lock().clear(); + self.pending_vouchers.lock().clear(); + return Err(error); + } + reserved_vouchers.push(index); + self.pending_vouchers.lock().push(index); + } + Ok(()) + } + + pub async fn process(&self, spent_coins: &[u32], spent_vouchers: &[u32]) -> Result<(), String> { + let removed_coins: Vec = { + let mut pending = self.pending_coins.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_coins.contains(index)) + .collect(); + pending.retain(|index| !spent_coins.contains(index)); + removed + }; + let removed_vouchers: Vec = { + let mut pending = self.pending_vouchers.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_vouchers.contains(index)) + .collect(); + pending.retain(|index| !spent_vouchers.contains(index)); + removed + }; + + let restore = |this: &Self| { + this.pending_coins.lock().extend_from_slice(&removed_coins); + this.pending_vouchers + .lock() + .extend_from_slice(&removed_vouchers); + }; + for &index in spent_coins { + if let Err(error) = self.coins.set_state(index, CoinState::Spent).await { + restore(self); + return Err(error); + } + } + for &index in spent_vouchers { + if let Err(error) = self + .vouchers + .set_local_state(index, VoucherLocalState::Spent) + .await + { + restore(self); + return Err(error); + } + } + Ok(()) + } + + pub async fn process_outputs( + &self, + spent_coins: &[u32], + spent_vouchers: &[u32], + change: &[Coin], + destination: &[Coin], + ) -> Result<(), String> { + let removed_coins: Vec = { + let mut pending = self.pending_coins.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_coins.contains(index)) + .collect(); + pending.retain(|index| !spent_coins.contains(index)); + removed + }; + let removed_vouchers: Vec = { + let mut pending = self.pending_vouchers.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_vouchers.contains(index)) + .collect(); + pending.retain(|index| !spent_vouchers.contains(index)); + removed + }; + let restore = |this: &Self| { + this.pending_coins.lock().extend_from_slice(&removed_coins); + this.pending_vouchers + .lock() + .extend_from_slice(&removed_vouchers); + }; + + let commit = async { + if let Some(committer) = &self.committer { + return committer + .commit(spent_coins, spent_vouchers, change, destination) + .await; + } + for coin in change { + let mut coin = coin.clone(); + coin.state = CoinState::Available; + self.coins.upsert(&coin).await?; + } + for coin in destination { + let mut coin = coin.clone(); + coin.state = CoinState::Spent; + self.coins.upsert(&coin).await?; + } + for &index in spent_coins { + self.coins.set_state(index, CoinState::Spent).await?; + } + for &index in spent_vouchers { + self.vouchers.remove(index).await?; + } + Ok::<(), String>(()) + } + .await; + if let Err(error) = commit { + restore(self); + return Err(error); + } + Ok(()) + } + + /// Restores only the supplied still-pending inputs. Used after a + /// definitive pre-broadcast failure so another recycler group with a + /// known checkpoint remains reserved for recovery. + pub async fn revert_inputs(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + let coins = { + let mut pending = self.pending_coins.lock(); + let selected = pending + .iter() + .copied() + .filter(|index| coins.contains(index)) + .collect::>(); + pending.retain(|index| !coins.contains(index)); + selected + }; + let vouchers = { + let mut pending = self.pending_vouchers.lock(); + let selected = pending + .iter() + .copied() + .filter(|index| vouchers.contains(index)) + .collect::>(); + pending.retain(|index| !vouchers.contains(index)); + selected + }; + if let Some(committer) = &self.committer { + if let Err(error) = committer.revert(&coins, &vouchers).await { + self.pending_coins.lock().extend_from_slice(&coins); + self.pending_vouchers.lock().extend_from_slice(&vouchers); + return Err(error); + } + return Ok(()); + } + for index in coins { + self.coins.set_state(index, CoinState::Available).await?; + } + for index in vouchers { + self.vouchers + .set_local_state(index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } + + pub async fn revert(&self) -> Result<(), String> { + let coins: Vec = std::mem::take(&mut *self.pending_coins.lock()); + let vouchers: Vec = std::mem::take(&mut *self.pending_vouchers.lock()); + if let Some(committer) = &self.committer { + if let Err(error) = committer.revert(&coins, &vouchers).await { + self.pending_coins.lock().extend_from_slice(&coins); + self.pending_vouchers.lock().extend_from_slice(&vouchers); + return Err(error); + } + return Ok(()); + } + for index in coins { + self.coins.set_state(index, CoinState::Available).await?; + } + for index in vouchers { + self.vouchers + .set_local_state(index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{VoucherPrivacyLevel, VoucherRemoteState}; + + fn coin(index: u32) -> Coin { + Coin { + exponent: 0, + derivation_index: index, + age: Some(1), + state: CoinState::Available, + } + } + + fn voucher(index: u32) -> Voucher { + Voucher { + exponent: 0, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 0 }, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + async fn coin_state(repo: &InMemoryCoinRepository, index: u32) -> CoinState { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + .state + } + + #[tokio::test] + async fn reserve_process_revert_lifecycle() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1), coin(2)])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([voucher(10)])); + let context = TransferContext::new( + Arc::clone(&coins) as Arc<_>, + Arc::clone(&vouchers) as Arc<_>, + ); + + context.reserve(&[1, 2], &[10]).await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(coin_state(&coins, 2).await, CoinState::PendingTransfer); + + // One coin is processed (spent); the rest reverts. + context.process(&[1], &[]).await.unwrap(); + context.revert().await.unwrap(); + + assert_eq!( + coin_state(&coins, 1).await, + CoinState::Spent, + "processed stays spent" + ); + assert_eq!( + coin_state(&coins, 2).await, + CoinState::Available, + "unprocessed reverts" + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available, + "unprocessed voucher reverts" + ); + } + + #[tokio::test] + async fn failed_process_keeps_items_revertable() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let context = TransferContext::new(Arc::clone(&coins) as Arc<_>, vouchers); + + context.reserve(&[1], &[]).await.unwrap(); + // Index 99 does not exist — set_state fails mid-process. + assert!(context.process(&[99], &[]).await.is_err()); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn double_revert_is_a_no_op() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let context = TransferContext::new(Arc::clone(&coins) as Arc<_>, vouchers); + context.reserve(&[1], &[]).await.unwrap(); + context.revert().await.unwrap(); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn process_outputs_persists_change_spends_destination_and_deletes_vouchers() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([voucher(10)])); + let context = TransferContext::new( + Arc::clone(&coins) as Arc<_>, + Arc::clone(&vouchers) as Arc<_>, + ); + context.reserve(&[1], &[10]).await.unwrap(); + context + .process_outputs(&[1], &[10], &[coin(2)], &[coin(3)]) + .await + .unwrap(); + + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!(coin_state(&coins, 2).await, CoinState::Available); + assert_eq!(coin_state(&coins, 3).await, CoinState::Spent); + assert!(vouchers.list().await.unwrap().is_empty()); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + } +} diff --git a/rust/crates/truapi-coinage/src/ring_proof.rs b/rust/crates/truapi-coinage/src/ring_proof.rs new file mode 100644 index 000000000..a9f033254 --- /dev/null +++ b/rust/crates/truapi-coinage/src/ring_proof.rs @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::Encode; + +use crate::selection::RecyclerKey; + +/// `"pop:polkadot.network/coinrecyclr"` — the recycler alias context. +pub const RECYCLER_ALIAS_CONTEXT: &[u8; 32] = b"pop:polkadot.network/coinrecyclr"; +/// Prefix of the free unload-token context. +pub const FREE_UNLOAD_TOKEN_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/coinftk"; +/// A single-context Bandersnatch ring-VRF proof is fixed at 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonOriginKind { + Full, + Lite, +} + +/// One finalized ring snapshot. The exponent is the on-chain +/// `Members.CollectionInfo.ring_size` exponent (9/10/14), not the +/// Bandersnatch PCS exponent (11/12/16). `ring_revision` is the live +/// `Members.Root.revision` of the snapshot — the 2026-08 runtime +/// (spec 1000032) requires it inside every proof-bearing extension. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RingProofParams { + pub ring_exponent: u8, + pub ring_index: u32, + pub ring_revision: u32, + pub ring_members: Vec<[u8; 32]>, +} + +/// One distinct free unload-token slot selected for a recycler group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedUnloadToken { + pub period: u32, + pub counter: u32, +} + +impl ResolvedUnloadToken { + /// `coinftk || period(le u32) || counter(le u32)`. + pub fn context(self) -> Vec { + let mut context = Vec::with_capacity(FREE_UNLOAD_TOKEN_CONTEXT_PREFIX.len() + 8); + context.extend_from_slice(FREE_UNLOAD_TOKEN_CONTEXT_PREFIX); + context.extend_from_slice(&self.period.to_le_bytes()); + context.extend_from_slice(&self.counter.to_le_bytes()); + context + } +} + +/// Everything the proof signer needs after the transaction builder has +/// produced the inherited implication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadProofRequest { + pub recycler: RecyclerKey, + pub voucher_derivation_indices: Vec, + pub recycler_ring: RingProofParams, + pub person_origin: PersonOriginKind, + pub people_ring: RingProofParams, + pub token: ResolvedUnloadToken, + pub inherited_implication: Vec, +} + +/// The complete proof-bearing `AsCoinage` payload plus the aliases carried by +/// the unload call itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadTokenProof { + pub person_origin: PersonOriginKind, + pub people_proof: Vec, + pub people_ring_index: u32, + pub people_ring_revision: u32, + pub period: u32, + pub counter: u32, + pub aliases: Vec<[u8; 32]>, + pub alias_proofs: Vec>, +} + +impl UnloadTokenProof { + /// SCALE-ready `AsCoinage(Some(…))` value for installation into the + /// prepared transaction extension. + pub fn as_coinage_extension(&self) -> crate::tx_extensions::AsCoinage { + use crate::tx_extensions::{AsCoinage, CoinagePeopleProof}; + + let proof = CoinagePeopleProof { + proof: self.people_proof.clone(), + ring: self.people_ring_index, + revision: self.people_ring_revision, + }; + match self.person_origin { + PersonOriginKind::Full => AsCoinage::unload_token_people( + proof, + self.period, + self.counter, + self.alias_proofs.clone(), + ), + PersonOriginKind::Lite => AsCoinage::unload_token_lite_people( + proof, + self.period, + self.counter, + self.alias_proofs.clone(), + ), + } + } +} + +/// A fail-closed proof construction error. Every variant carries a +/// concrete runtime/data cause (empty input, mismatched ring parameters, +/// or an underlying proof-generation failure) rather than standing in for +/// missing crypto support. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RingProofError { + EmptyVoucherGroup, + RecyclerMismatch, + EmptyRing(&'static str), + Proof(String), +} + +impl std::fmt::Display for RingProofError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyVoucherGroup => f.write_str("cannot prove an empty voucher group"), + Self::RecyclerMismatch => { + f.write_str("recycler proof parameters do not match the voucher group") + } + Self::EmptyRing(label) => write!(f, "{label} ring has no included members"), + Self::Proof(message) => f.write_str(message), + } + } +} + +impl std::error::Error for RingProofError {} + +/// The signing effect consumed by an offboard transaction submitter. +/// Chain-state preparation (person-origin selection, ring pages, and free +/// token slot resolution) stays outside this secret-holding trait. Both +/// methods are intentionally synchronous: once those inputs and the +/// implication exist, proof generation is local CPU work only. +pub trait RingProofProvider: Send + Sync { + /// Derives the public aliases needed to build the unload call. Aliases do + /// not depend on the transaction implication, so this is the first half of + /// the submitter's two-step flow. + fn unload_aliases( + &self, + voucher_derivation_indices: &[u32], + ) -> Result, RingProofError>; + + /// Creates the proof-bearing extension after the aliases are in the call + /// and the resulting inherited implication has been derived. + fn unload_proof( + &self, + request: &UnloadProofRequest, + ) -> Result; +} + +/// The production local signer. Root entropy is retained only in zeroizing +/// memory and no derived secret appears in the returned payload. +pub struct BandersnatchRingProofProvider { + vouchers: crate::keys::VoucherKeypairFactory, + crypto: std::sync::Arc, + people: std::sync::Arc, +} + +impl BandersnatchRingProofProvider { + pub fn new( + entropy: &[u8], + crypto: std::sync::Arc, + people: std::sync::Arc, + ) -> Self { + Self { + vouchers: crate::keys::VoucherKeypairFactory::new(entropy), + crypto, + people, + } + } +} + +impl RingProofProvider for BandersnatchRingProofProvider { + fn unload_aliases( + &self, + voucher_derivation_indices: &[u32], + ) -> Result, RingProofError> { + if voucher_derivation_indices.is_empty() { + return Err(RingProofError::EmptyVoucherGroup); + } + let vouchers = &self.vouchers; + voucher_derivation_indices + .iter() + .map(|index| { + vouchers + .alias(*index, RECYCLER_ALIAS_CONTEXT, self.crypto.as_ref()) + .map_err(RingProofError::Proof) + }) + .collect() + } + + fn unload_proof( + &self, + request: &UnloadProofRequest, + ) -> Result { + if request.voucher_derivation_indices.is_empty() { + return Err(RingProofError::EmptyVoucherGroup); + } + if request.recycler_ring.ring_index != request.recycler.index { + return Err(RingProofError::RecyclerMismatch); + } + if request.recycler_ring.ring_members.is_empty() { + return Err(RingProofError::EmptyRing("recycler")); + } + if request.people_ring.ring_members.is_empty() { + return Err(RingProofError::EmptyRing("People")); + } + + let alias_message = blake2b_256(&request.inherited_implication); + let vouchers = &self.vouchers; + let aliases = self.unload_aliases(&request.voucher_derivation_indices)?; + let mut alias_proofs = Vec::with_capacity(request.voucher_derivation_indices.len()); + for index in &request.voucher_derivation_indices { + alias_proofs.push( + vouchers + .ring_vrf_proof( + *index, + request.recycler_ring.ring_exponent, + &request.recycler_ring.ring_members, + RECYCLER_ALIAS_CONTEXT, + &alias_message, + self.crypto.as_ref(), + ) + .map_err(RingProofError::Proof)?, + ); + } + + let mut people_payload = alias_proofs.encode(); + people_payload.extend_from_slice(&request.inherited_implication); + let people_message = blake2b_256(&people_payload); + let people_proof = self + .people + .ring_vrf_proof( + request.person_origin, + &request.people_ring, + &request.token.context(), + &people_message, + ) + .map_err(RingProofError::Proof)?; + if alias_proofs + .iter() + .any(|proof| proof.len() != RING_VRF_PROOF_LEN) + || people_proof.len() != RING_VRF_PROOF_LEN + { + return Err(RingProofError::Proof( + "Bandersnatch proof has an invalid length".into(), + )); + } + + debug_assert!( + alias_proofs + .iter() + .all(|proof| proof.len() == RING_VRF_PROOF_LEN) + ); + debug_assert_eq!(people_proof.len(), RING_VRF_PROOF_LEN); + Ok(UnloadTokenProof { + person_origin: request.person_origin, + people_proof, + people_ring_index: request.people_ring.ring_index, + people_ring_revision: request.people_ring.ring_revision, + period: request.token.period, + counter: request.token.counter, + aliases, + alias_proofs, + }) + } +} + +fn blake2b_256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Authority-owned People signer. The adapter chooses the deployed full/lite +/// identity derivation for its network; Coinage never exports that identity. +pub trait PersonRingProofSigner: Send + Sync { + /// Sign the exact ring/context/implication supplied by the unload builder. + fn ring_vrf_proof( + &self, + origin: PersonOriginKind, + ring: &RingProofParams, + context: &[u8], + message: &[u8], + ) -> Result, String>; +} diff --git a/rust/crates/truapi-coinage/src/secret_claim.rs b/rust/crates/truapi-coinage/src/secret_claim.rs new file mode 100644 index 000000000..75ef15261 --- /dev/null +++ b/rust/crates/truapi-coinage/src/secret_claim.rs @@ -0,0 +1,1649 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Chain-backed claiming of externally supplied Coinage secrets. +//! [`ExternalMemoClaiming`] is the secret-preserving claim effect. This module implements that effect without ever projecting a +//! secret through FFI: +//! 1. validate every expanded sr25519 secret and reject duplicate sources; +//! 2. read the live Coinage denomination context and source/destination +//! state in ordered batches; +//! 3. allocate one session-derived destination with the same denomination; +//! 4. treat a destination present on-chain while its source is absent as +//! already complete; the inverse is safe to retry. Both present is a +//! recipient collision and both absent is ambiguous, so both cases fail +//! closed; +//! 5. submit one `Coinage.transfer` per source under its own `AsCoin` +//! origin; and +//! 6. save only destinations whose finalized on-chain value was verified. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::COIN_MAX_AGE; +use crate::allocator::CoinAllocator; +use crate::claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState}; +use crate::repo::CoinRepository; +use crate::sync::OnChainCoin; + +type SecretSource = Option<(MemoEntry, [u8; 32])>; + +/// One source-coin transfer. This type intentionally has no `Debug` +/// implementation: `source_secret` is seed-phrase-tier material. +pub struct ExternalCoinTransferRequest { + pub source_secret: MemoEntry, + pub source_public: [u8; 32], + pub recipient: [u8; 32], + pub exponent: i16, + pub asset_unit: u128, + pub amount_planks: u128, +} + +/// The chain edge used by [`ExternalSecretClaimService`]. +/// A production implementation must revalidate the request against live +/// metadata/state immediately before signing and return only after the +/// submitted extrinsic finalized successfully and the destination coin was +/// observed with the requested exponent. +#[async_trait] +pub trait ExternalCoinTransferBackend: Send + Sync { + async fn denomination_context(&self) -> Result; + + /// Ordered `Coinage.CoinsByOwner` batch. + async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + ) -> Result>, String>; + + /// Consumes the raw secret and returns the finalized destination row. + async fn submit_transfer( + &self, + request: ExternalCoinTransferRequest, + ) -> Result; +} + +/// Session-scoped recipient for W3S/external Coinage secrets. +/// Construct a fresh instance from the active wallet entropy. The +/// destination key factory owns no FFI-visible surface and zeroizes its root +/// material on drop. +pub struct ExternalSecretClaimService { + key_factory: Arc, + allocator: Arc, + coins: Arc, + plans: Arc, + backend: Arc, + operation: Mutex<()>, +} + +impl ExternalSecretClaimService { + pub fn new( + root_entropy: &[u8], + allocator: Arc, + coins: Arc, + plans: Arc, + backend: Arc, + ) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + allocator, + coins, + plans, + backend, + operation: Mutex::new(()), + } + } + + pub async fn await_memo_sources_on_chain(&self, memo: &TransferMemo) -> Result<(), String> { + self.await_memo_sources_on_chain_with( + memo, + SEND_VERIFY_BLOCK_TIMEOUT, + std::time::Duration::from_secs(6), + ) + .await + } + + /// Timeout-parameterized body of [`Self::await_memo_sources_on_chain`] + /// (attempt count ≙ finalized blocks, interval ≙ block time). + pub async fn await_memo_sources_on_chain_with( + &self, + memo: &TransferMemo, + attempts: u32, + interval: std::time::Duration, + ) -> Result<(), String> { + if memo.entries.is_empty() { + return Err("external coin claim requires at least one secret".into()); + } + let mut publics = Vec::with_capacity(memo.entries.len()); + for (index, entry) in memo.entries.iter().enumerate() { + let public = schnorrkel::SecretKey::from_bytes(&entry.0) + .map_err(|error| format!("external coin secret {index} is invalid: {error}"))? + .to_public() + .to_bytes(); + publics.push(public); + } + for attempt in 0..attempts.max(1) { + if attempt > 0 { + crate::timer::sleep(interval).await; + } + let rows = + fetch_exact(self.backend.as_ref(), &publics, "external send detection").await?; + if rows.iter().all(Option::is_some) { + return Ok(()); + } + } + Err(format!( + "external send not detected within {} blocks", + attempts.max(1) + )) + } + + /// Validate and persist the complete destination plan without submitting + /// any transaction. Hosts call this before acknowledging custody of a memo. + pub async fn prepare_memo( + &self, + memo: &TransferMemo, + message_id: String, + ) -> Result { + let _operation = self.operation.lock().await; + self.prepare_locked(memo, message_id).await + } + + async fn prepare_locked( + &self, + memo: &TransferMemo, + message_id: String, + ) -> Result { + if memo.entries.is_empty() { + return Err("external coin claim requires at least one secret".into()); + } + let memo_key = memo.identifier(); + + // A Finished plan is authoritative: this memo was claimed on a + // prior run. Report the stored amount instead of re-executing — + // the claimed coins may since have been spent or recycled, so + // re-verification against live chain state can no longer prove + // anything and must not overwrite the terminal outcome. + let existing = self.plans.plan(&memo_key).await?; + if let Some(plan) = &existing + && plan.status == ClaimPlanStatus::Finished + { + if plan.memo_key != memo_key + || plan.total_value != memo.total_value + || plan.claimed_amount != Some(memo.total_value) + || plan.entries.len() != memo.entries.len() + { + return Err("finished external coin claim does not match its memo".into()); + } + return Ok(plan.clone()); + } + + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + + let total_value = memo.total_value; + let sources = source_entries(memo.entries.clone())?; + let plan = match existing { + Some(plan) => { + validate_existing_plan( + &plan, + &memo_key, + &message_id, + total_value, + sources.len(), + &context, + )?; + plan + } + None => { + let source_public = sources + .iter() + .map(|source| source.as_ref().expect("source installed").1) + .collect::>(); + let source_rows = fetch_exact( + self.backend.as_ref(), + &source_public, + "external source coin query", + ) + .await?; + let mut exponents = Vec::with_capacity(source_rows.len()); + let mut computed_total = 0u128; + for (index, row) in source_rows.into_iter().enumerate() { + let row = row.ok_or_else(|| { + format!("external source coin {index} is no longer on-chain") + })?; + validate_exponent(row.exponent, &context)?; + computed_total = computed_total + .checked_add(context.value_in_planks(row.exponent)) + .ok_or_else(|| "external coin claim total exceeds u128".to_string())?; + exponents.push(row.exponent); + } + if computed_total != total_value { + return Err(format!( + "external coin claim amount mismatch: memo {total_value}, chain {computed_total}" + )); + } + + let plan = self + .allocate_plan( + memo_key, + message_id.clone(), + total_value, + &source_public, + &exponents, + ) + .await?; + self.plans.save(&plan).await?; + plan + } + }; + + Ok(plan) + } + + async fn claim(&self, mut memo: TransferMemo, message_id: String) -> Result { + let _operation = self.operation.lock().await; + let plan = self.prepare_locked(&memo, message_id).await?; + if plan.status == ClaimPlanStatus::Finished { + return Ok(plan.claimed_amount.unwrap_or(plan.total_value)); + } + let memo_key = plan.memo_key; + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + let mut sources = source_entries(std::mem::take(&mut memo.entries))?; + let outcome = self.execute_plan(&plan, &context, &mut sources).await; + match outcome { + Ok(claimed) => { + self.plans + .update_status(&memo_key, ClaimPlanStatus::Finished, Some(claimed)) + .await?; + Ok(claimed) + } + Err(error) => { + let confirmed = self + .plans + .plan(&memo_key) + .await? + .and_then(|plan| plan.claimed_amount); + if let Err(status_error) = self + .plans + .update_status(&memo_key, ClaimPlanStatus::Error, confirmed) + .await + { + tracing::warn!( + %status_error, + "external coin claim error status could not be persisted" + ); + } + Err(error) + } + } + } + + async fn allocate_plan( + &self, + memo_key: [u8; 32], + message_id: String, + total_value: u128, + source_public: &[[u8; 32]], + exponents: &[i16], + ) -> Result { + let mut entries = Vec::with_capacity(exponents.len()); + let mut recipients = Vec::with_capacity(exponents.len()); + let source_set = source_public.iter().copied().collect::>(); + let mut recipient_set = HashSet::with_capacity(exponents.len()); + + for (entry_index, exponent) in exponents.iter().copied().enumerate() { + let entry_index = i16::try_from(entry_index) + .map_err(|_| "external coin claim has too many entries".to_string())?; + let destination = self.allocator.allocate(exponent).await?; + let recipient = self.key_factory.public_key(destination.derivation_index)?; + if recipient == [0; 32] + || source_set.contains(&recipient) + || !recipient_set.insert(recipient) + { + return Err("external coin claim destination key collision".into()); + } + recipients.push(recipient); + entries.push(CodableClaimPlanEntry { + entry_index, + exponent, + derivation_index: destination.derivation_index, + }); + } + + let destination_rows = fetch_exact( + self.backend.as_ref(), + &recipients, + "external destination collision query", + ) + .await?; + if destination_rows.iter().any(Option::is_some) { + return Err("external coin claim destination already exists on-chain".into()); + } + + Ok(ClaimPlan { + memo_key, + message_id: Some(message_id), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value, + }) + } + + async fn execute_plan( + &self, + plan: &ClaimPlan, + context: &DenominationBreakdownContext, + sources: &mut [SecretSource], + ) -> Result { + let entries = ordered_plan_entries(plan, sources.len())?; + let source_public = sources + .iter() + .map(|source| source.as_ref().expect("validated source").1) + .collect::>(); + let recipients = entries + .iter() + .map(|entry| self.key_factory.public_key(entry.derivation_index)) + .collect::, _>>()?; + validate_recipients(&source_public, &recipients)?; + + let mut all_keys = source_public.clone(); + all_keys.extend_from_slice(&recipients); + let mut rows = fetch_exact( + self.backend.as_ref(), + &all_keys, + "external claim recovery query", + ) + .await?; + let destination_rows = rows.split_off(source_public.len()); + let source_rows = rows; + let local_states = self + .coins + .list() + .await? + .into_iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(); + + let mut claimed = 0u128; + for (position, entry) in entries.into_iter().enumerate() { + validate_exponent(entry.exponent, context)?; + let expected_amount = context.value_in_planks(entry.exponent); + claimed = claimed + .checked_add(expected_amount) + .ok_or_else(|| "external coin claim total exceeds u128".to_string())?; + // Each sequential prefix was durably recorded only after exact + // finalized destination verification. Missing both keys without + // that evidence is ambiguous, never proof of a successful claim. + if claimed <= plan.claimed_amount.unwrap_or(0) { + continue; + } + let source = source_rows[position]; + let destination = destination_rows[position]; + let landed = match (source, destination) { + (None, Some(destination)) => { + if destination.exponent != entry.exponent { + return Err(format!( + "external claim destination {position} denomination mismatch: expected {}, found {}", + entry.exponent, destination.exponent + )); + } + destination + } + (Some(source), None) => { + if source.exponent != entry.exponent { + return Err(format!( + "external claim source {position} denomination changed: expected {}, found {}", + entry.exponent, source.exponent + )); + } + let (source_secret, source_public) = sources[position] + .take() + .expect("validated source entry consumed once"); + let recipient = recipients[position]; + self.backend + .submit_transfer(ExternalCoinTransferRequest { + source_secret, + source_public, + recipient, + exponent: entry.exponent, + asset_unit: context.asset_unit, + amount_planks: expected_amount, + }) + .await? + } + (Some(_), Some(_)) => { + return Err( + "external claim destination collision: source remains unconsumed".into(), + ); + } + (None, None) => { + return Err("external claim source and destination are both absent without finalized evidence".into()); + } + }; + if landed.exponent != entry.exponent { + return Err(format!( + "external claim finalized destination {position} denomination mismatch: expected {}, found {}", + entry.exponent, landed.exponent + )); + } + let state = local_states + .get(&entry.derivation_index) + .copied() + .unwrap_or(CoinState::Available); + self.coins + .upsert(&Coin { + exponent: entry.exponent, + derivation_index: entry.derivation_index, + age: Some(landed.age), + state, + }) + .await?; + self.plans + .update_status(&plan.memo_key, ClaimPlanStatus::Processing, Some(claimed)) + .await?; + } + if claimed != plan.total_value { + return Err(format!( + "external coin claim plan amount mismatch: plan {}, entries {claimed}", + plan.total_value + )); + } + Ok(claimed) + } +} + +#[async_trait] +impl ExternalMemoClaiming for ExternalSecretClaimService { + async fn claim_external_memo( + &self, + memo: TransferMemo, + message_id: String, + ) -> Result { + self.claim(memo, message_id).await + } +} + +/// Exact result of one local spent-coin recovery pass. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SpentCoinTransferRecoveryReport { + /// Present max-age coins restored locally plus younger coins moved to + /// fresh destinations. + pub recovered_count: u32, + pub recovered_planks: u128, + /// Subset restored locally because `Coinage.transfer` would reject a + /// coin at or beyond `COIN_MAX_AGE`. + pub restored_max_age_count: u32, + /// Subset claimed through finalized `Coinage.transfer` extrinsics. + pub transferred_count: u32, +} + +pub struct SpentCoinTransferRecoveryService { + key_factory: Arc, + coins: Arc, + backend: Arc, + claimer: Arc, + operation: Mutex<()>, +} + +impl SpentCoinTransferRecoveryService { + pub fn new( + root_entropy: &[u8], + coins: Arc, + backend: Arc, + claimer: Arc, + ) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins, + backend, + claimer, + operation: Mutex::new(()), + } + } + + pub async fn recover( + &self, + spent: Vec, + ) -> Result { + let _operation = self.operation.lock().await; + if spent.is_empty() { + return Ok(SpentCoinTransferRecoveryReport::default()); + } + let mut indices = HashSet::with_capacity(spent.len()); + for coin in &spent { + if coin.state != CoinState::Spent { + return Err(format!( + "spent recovery received non-spent coin {}", + coin.derivation_index + )); + } + if !indices.insert(coin.derivation_index) { + return Err(format!( + "spent recovery coin {} is duplicated", + coin.derivation_index + )); + } + } + + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + let public_keys = spent + .iter() + .map(|coin| self.key_factory.public_key(coin.derivation_index)) + .collect::, _>>()?; + let rows = fetch_exact( + self.backend.as_ref(), + &public_keys, + "spent Coinage recovery query", + ) + .await?; + + let mut report = SpentCoinTransferRecoveryReport::default(); + let mut transfer_entries = Vec::new(); + let mut transfer_total = 0u128; + for (coin, row) in spent.into_iter().zip(rows) { + let Some(row) = row else { + continue; + }; + validate_exponent(row.exponent, &context)?; + if row.exponent != coin.exponent { + return Err(format!( + "spent coin {} denomination mismatch: local {}, chain {}", + coin.derivation_index, coin.exponent, row.exponent + )); + } + let value = context.value_in_planks(row.exponent); + if row.age >= COIN_MAX_AGE { + self.coins + .upsert(&Coin { + exponent: row.exponent, + derivation_index: coin.derivation_index, + age: Some(row.age), + state: CoinState::Available, + }) + .await?; + report.recovered_count = report.recovered_count.saturating_add(1); + report.restored_max_age_count = report.restored_max_age_count.saturating_add(1); + report.recovered_planks = report + .recovered_planks + .checked_add(value) + .ok_or_else(|| "spent Coinage recovery total exceeds u128".to_string())?; + } else { + transfer_entries.push(MemoEntry( + self.key_factory.secret_bytes(coin.derivation_index)?, + )); + transfer_total = transfer_total + .checked_add(value) + .ok_or_else(|| "spent Coinage transfer total exceeds u128".to_string())?; + report.transferred_count = report.transferred_count.saturating_add(1); + } + } + + if !transfer_entries.is_empty() { + let memo = TransferMemo { + entries: transfer_entries, + total_value: transfer_total, + }; + let memo_key = memo.identifier(); + self.claimer + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await?; + report.recovered_count = report + .recovered_count + .checked_add(report.transferred_count) + .ok_or_else(|| "spent Coinage recovered count exceeds u32".to_string())?; + report.recovered_planks = report + .recovered_planks + .checked_add(transfer_total) + .ok_or_else(|| "spent Coinage recovery total exceeds u128".to_string())?; + } + Ok(report) + } +} + +#[async_trait] +impl SpentCoinsRecovering for SpentCoinTransferRecoveryService { + async fn recover_spent_coins(&self, spent: Vec) -> Result { + self.recover(spent) + .await + .map(|report| report.recovered_planks) + } +} + +/// The only accepted external-claim id. It is public for host composition +/// and tests, but contains no secret material. +pub fn external_claim_message_id(memo_key: &[u8; 32]) -> String { + format!("w3s-coins-{}", hex::encode(memo_key)) +} + +fn source_entries(entries: Vec) -> Result, String> { + let mut seen = HashSet::with_capacity(entries.len()); + entries + .into_iter() + .enumerate() + .map(|(index, entry)| { + let secret = schnorrkel::SecretKey::from_bytes(&entry.0) + .map_err(|error| format!("external coin secret {index} is invalid: {error}"))?; + let public = secret.to_public().to_bytes(); + if !seen.insert(public) { + return Err(format!("external coin source {index} is duplicated")); + } + Ok(Some((entry, public))) + }) + .collect() +} + +fn validate_context(context: &DenominationBreakdownContext) -> Result<(), String> { + if context.asset_unit == 0 { + return Err("Coinage asset unit must be non-zero".into()); + } + if context.max_exponent < context.min_exponent { + return Err("Coinage denomination range is inverted".into()); + } + Ok(()) +} + +fn validate_exponent(exponent: i16, context: &DenominationBreakdownContext) -> Result<(), String> { + if exponent < context.min_exponent || exponent > context.max_exponent { + return Err(format!( + "Coinage exponent {exponent} is outside {}..={}", + context.min_exponent, context.max_exponent + )); + } + // The live pallet's transfer preserves its signed i8 `value`. + i8::try_from(exponent) + .map(|_| ()) + .map_err(|_| format!("Coinage exponent {exponent} does not fit the runtime i8")) +} + +fn validate_existing_plan( + plan: &ClaimPlan, + memo_key: &[u8; 32], + message_id: &str, + total_value: u128, + entry_count: usize, + context: &DenominationBreakdownContext, +) -> Result<(), String> { + // Plans written before chat claims switched to chat message ids carry + // the memo-derived external id; accept either spelling. + let message_id_matches = plan.message_id.as_deref() == Some(message_id) + || plan.message_id.as_deref() == Some(external_claim_message_id(memo_key).as_str()); + if plan.memo_key != *memo_key || !message_id_matches || plan.total_value != total_value { + return Err("persisted external coin claim plan does not match its memo".into()); + } + if plan.entries.len() != entry_count { + return Err(format!( + "persisted external coin claim plan has {} entries; expected {entry_count}", + plan.entries.len() + )); + } + let entries = ordered_plan_entries(plan, entry_count)?; + let mut total = 0u128; + let confirmed = plan.claimed_amount.unwrap_or(0); + let mut confirmed_boundary = confirmed == 0; + for entry in entries { + validate_exponent(entry.exponent, context)?; + total = total + .checked_add(context.value_in_planks(entry.exponent)) + .ok_or_else(|| "external coin claim plan total exceeds u128".to_string())?; + confirmed_boundary |= confirmed == total; + } + if total != total_value { + return Err(format!( + "persisted external coin claim amount mismatch: memo {total_value}, plan {total}" + )); + } + if !confirmed_boundary { + return Err("persisted external claim progress is not a finalized entry boundary".into()); + } + Ok(()) +} + +fn ordered_plan_entries( + plan: &ClaimPlan, + entry_count: usize, +) -> Result, String> { + let mut ordered = vec![None; entry_count]; + for entry in &plan.entries { + let index = usize::try_from(entry.entry_index) + .map_err(|_| "external coin claim plan has a negative entry index".to_string())?; + let slot = ordered.get_mut(index).ok_or_else(|| { + format!("external coin claim plan entry index {index} is out of bounds") + })?; + if slot.replace(*entry).is_some() { + return Err(format!( + "external coin claim plan entry index {index} is duplicated" + )); + } + } + ordered + .into_iter() + .enumerate() + .map(|(index, entry)| { + entry.ok_or_else(|| format!("external coin claim plan entry index {index} is missing")) + }) + .collect() +} + +fn validate_recipients(sources: &[[u8; 32]], recipients: &[[u8; 32]]) -> Result<(), String> { + if sources.len() != recipients.len() { + return Err("external coin source/recipient count mismatch".into()); + } + let source_set = sources.iter().copied().collect::>(); + let mut recipient_set = HashSet::with_capacity(recipients.len()); + for recipient in recipients { + if *recipient == [0; 32] + || source_set.contains(recipient) + || !recipient_set.insert(*recipient) + { + return Err("external coin claim recipient key collision".into()); + } + } + Ok(()) +} + +async fn fetch_exact( + backend: &dyn ExternalCoinTransferBackend, + keys: &[[u8; 32]], + label: &str, +) -> Result>, String> { + let rows = backend.fetch_coins(keys).await?; + if rows.len() != keys.len() { + return Err(format!( + "{label} returned {} values for {} keys", + rows.len(), + keys.len() + )); + } + Ok(rows) +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex as StdMutex; + + use super::*; + use crate::index_store::InMemoryCoinageIndexStore; + use crate::repo::InMemoryCoinRepository; + + fn context() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 2, + } + } + + fn expanded_secret(seed: u8) -> MemoEntry { + MemoEntry( + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .unwrap() + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_bytes(), + ) + } + + fn public(entry: &MemoEntry) -> [u8; 32] { + schnorrkel::SecretKey::from_bytes(&entry.0) + .unwrap() + .to_public() + .to_bytes() + } + + #[derive(Default)] + struct MemoryPlans(StdMutex>); + + #[async_trait] + impl ClaimPlanStore for MemoryPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.0.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.0.lock().get(memo_key).cloned()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self.0.lock().values().cloned().collect()) + } + + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.0.lock(); + let plan = plans + .get_mut(memo_key) + .ok_or_else(|| "claim plan is missing".to_string())?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.0.lock().remove(memo_key); + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct RecordedTransfer { + source: [u8; 32], + recipient: [u8; 32], + exponent: i16, + asset_unit: u128, + amount_planks: u128, + } + + struct MockBackend { + context: DenominationBreakdownContext, + state: StdMutex>, + transfers: StdMutex>, + plans: Arc, + expected_plan: StdMutex>, + } + + #[async_trait] + impl ExternalCoinTransferBackend for MockBackend { + async fn denomination_context(&self) -> Result { + Ok(self.context.clone()) + } + + async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + ) -> Result>, String> { + let state = self.state.lock(); + Ok(public_keys + .iter() + .map(|public| state.get(public).copied()) + .collect()) + } + + async fn submit_transfer( + &self, + request: ExternalCoinTransferRequest, + ) -> Result { + if let Some(expected_plan) = *self.expected_plan.lock() { + assert!( + self.plans.0.lock().contains_key(&expected_plan), + "claim plan must be durable before the first transfer" + ); + } + let derived = public(&request.source_secret); + assert_eq!(derived, request.source_public); + let mut state = self.state.lock(); + let source = state + .remove(&request.source_public) + .ok_or_else(|| "source disappeared".to_string())?; + assert_eq!(source.exponent, request.exponent); + assert!(!state.contains_key(&request.recipient)); + let landed = OnChainCoin { + exponent: request.exponent, + age: 0, + }; + state.insert(request.recipient, landed); + self.transfers.lock().push(RecordedTransfer { + source: request.source_public, + recipient: request.recipient, + exponent: request.exponent, + asset_unit: request.asset_unit, + amount_planks: request.amount_planks, + }); + Ok(landed) + } + } + + struct Rig { + service: Arc, + plans: Arc, + coins: Arc, + backend: Arc, + } + + fn rig(source_rows: impl IntoIterator) -> Rig { + let plans = Arc::new(MemoryPlans::default()); + let coins = Arc::new(InMemoryCoinRepository::default()); + let backend = Arc::new(MockBackend { + context: context(), + state: StdMutex::new(source_rows.into_iter().collect()), + transfers: StdMutex::new(Vec::new()), + plans: Arc::clone(&plans), + expected_plan: StdMutex::new(None), + }); + let service = Arc::new(ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + Arc::clone(&coins) as Arc<_>, + Arc::clone(&plans) as Arc<_>, + Arc::clone(&backend) as Arc<_>, + )); + Rig { + service, + plans, + coins, + backend, + } + } + + #[tokio::test] + async fn await_memo_sources_waits_until_all_sources_land() { + let entry_a = expanded_secret(1); + let entry_b = expanded_secret(2); + let coin = OnChainCoin { + exponent: 1, + age: 0, + }; + // Only A is on-chain at start; B lands while the wait is polling. + let rig = rig([(public(&entry_a), coin)]); + let waiter = { + let service = Arc::clone(&rig.service); + let memo = TransferMemo { + entries: vec![entry_a.clone(), entry_b.clone()], + total_value: 20, + }; + tokio::spawn(async move { + service + .await_memo_sources_on_chain_with( + &memo, + 200, + std::time::Duration::from_millis(2), + ) + .await + }) + }; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + rig.backend.state.lock().insert(public(&entry_b), coin); + waiter + .await + .unwrap() + .expect("resolves once every source landed"); + + // A memo whose sources never land times out into an error. + let missing = TransferMemo { + entries: vec![expanded_secret(3)], + total_value: 10, + }; + let outcome = rig + .service + .await_memo_sources_on_chain_with(&missing, 3, std::time::Duration::from_millis(1)) + .await; + assert!(outcome.unwrap_err().contains("not detected")); + } + + #[tokio::test] + async fn claim_persists_plan_before_exact_denominated_transfers() { + let first = expanded_secret(1); + let second = expanded_secret(2); + let memo = TransferMemo { + entries: vec![first.clone(), second.clone()], + total_value: 60, + }; + let memo_key = memo.identifier(); + let rig = rig([ + ( + public(&first), + OnChainCoin { + exponent: 1, + age: 7, + }, + ), + ( + public(&second), + OnChainCoin { + exponent: 2, + age: 9, + }, + ), + ]); + *rig.backend.expected_plan.lock() = Some(memo_key); + + rig.service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap(); + + let transfers = rig.backend.transfers.lock().clone(); + assert_eq!(transfers.len(), 2); + assert_eq!( + transfers + .iter() + .map(|transfer| ( + transfer.exponent, + transfer.asset_unit, + transfer.amount_planks, + )) + .collect::>(), + [(1, 10, 20), (2, 10, 40)] + ); + let plan = rig.plans.0.lock().get(&memo_key).unwrap().clone(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, Some(60)); + assert_eq!(plan.entries.len(), 2); + let coins = rig.coins.list().await.unwrap(); + assert_eq!(coins.len(), 2); + assert!(coins.iter().all(|coin| coin.state == CoinState::Available)); + } + + #[tokio::test] + async fn amount_mismatch_never_allocates_or_submits() { + let source = expanded_secret(3); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 21, + }; + let memo_key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + + let error = rig + .service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap_err(); + assert!(error.contains("amount mismatch")); + assert!(rig.plans.0.lock().is_empty()); + assert!(rig.backend.transfers.lock().is_empty()); + } + + #[tokio::test] + async fn duplicate_source_fails_before_chain_mutation() { + let source = expanded_secret(4); + let duplicate = TransferMemo { + entries: vec![source.clone(), source.clone()], + total_value: 40, + }; + let duplicate_key = duplicate.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + assert!( + rig.service + .claim_external_memo(duplicate, external_claim_message_id(&duplicate_key)) + .await + .unwrap_err() + .contains("duplicated") + ); + assert!(rig.backend.transfers.lock().is_empty()); + } + + /// Chat claims pass the chat row's message id; it must be accepted and + /// recorded on the plan so startup hydration restores the status under + /// the id the chat UI actually reads. + #[tokio::test] + async fn chat_message_id_is_accepted_and_persisted_on_the_plan() { + let source = expanded_secret(5); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + + let claimed = rig + .service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .unwrap(); + assert_eq!(claimed, 20); + let plan = rig.plans.0.lock().get(&memo_key).unwrap().clone(); + assert_eq!(plan.message_id.as_deref(), Some("chat-message-1")); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + } + + #[tokio::test] + async fn existing_landed_destination_recovers_without_resubmission() { + let source = expanded_secret(6); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([]); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + rig.backend.state.lock().insert( + recipient, + OnChainCoin { + exponent: 1, + age: 3, + }, + ); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some(external_claim_message_id(&memo_key)), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Error, + claimed_amount: None, + total_value: 20, + }, + ); + + rig.service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap(); + assert!(rig.backend.transfers.lock().is_empty()); + let coins = rig.coins.list().await.unwrap(); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].age, Some(3)); + assert_eq!( + rig.plans.0.lock().get(&memo_key).unwrap().status, + ClaimPlanStatus::Finished + ); + } + + #[tokio::test] + async fn prepared_claim_is_durable_without_spending_and_survives_restart() { + let source = expanded_secret(8); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + )]); + let plan = rig + .service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + assert!(rig.backend.transfers.lock().is_empty()); + assert_eq!(rig.plans.plan(&key).await.unwrap(), Some(plan.clone())); + let restarted = ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + rig.coins.clone(), + rig.plans.clone(), + rig.backend.clone(), + ); + assert_eq!( + restarted + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(), + plan + ); + assert_eq!( + restarted + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 20 + }, + external_claim_message_id(&key), + ) + .await + .unwrap(), + 20 + ); + rig.backend.state.lock().clear(); + assert_eq!( + restarted + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 20 + ); + assert_eq!(rig.backend.transfers.lock().len(), 1); + } + + #[tokio::test] + async fn absent_source_and_destination_without_receipt_never_clear() { + let source = expanded_secret(9); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + )]); + rig.service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + rig.backend.state.lock().clear(); + assert!( + rig.service + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .is_err() + ); + let plan = rig.plans.plan(&key).await.unwrap().unwrap(); + assert_ne!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, None); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.coins.list().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn partial_finalized_prefix_survives_spent_destination_and_retry() { + let first = expanded_secret(14); + let second = expanded_secret(15); + let memo = TransferMemo { + entries: vec![first.clone(), second.clone()], + total_value: 60, + }; + let key = memo.identifier(); + let rig = rig([ + ( + public(&first), + OnChainCoin { + exponent: 1, + age: 2, + }, + ), + ( + public(&second), + OnChainCoin { + exponent: 2, + age: 2, + }, + ), + ]); + let plan = rig + .service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + let destination = CoinKeypairFactory::new(&[0x44; 16]) + .public_key(plan.entries[0].derivation_index) + .unwrap(); + { + let mut state = rig.backend.state.lock(); + state.remove(&public(&first)); + state.remove(&public(&second)); + state.insert( + destination, + OnChainCoin { + exponent: 1, + age: 0, + }, + ); + } + assert!( + rig.service + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 60 + }, + external_claim_message_id(&key), + ) + .await + .is_err() + ); + assert_eq!( + rig.plans.plan(&key).await.unwrap().unwrap().claimed_amount, + Some(20) + ); + { + let mut state = rig.backend.state.lock(); + state.remove(&destination); + state.insert( + public(&second), + OnChainCoin { + exponent: 2, + age: 2, + }, + ); + } + let restarted = ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + rig.coins.clone(), + rig.plans.clone(), + rig.backend.clone(), + ); + assert_eq!( + restarted + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 60 + ); + assert_eq!( + rig.backend + .transfers + .lock() + .iter() + .map(|transfer| transfer.source) + .collect::>(), + vec![public(&second)] + ); + assert_eq!( + rig.plans.plan(&key).await.unwrap().unwrap().status, + ClaimPlanStatus::Finished + ); + } + + /// At one finalized snapshot, both keys present is a destination collision, + /// not proof this memo's source was transferred. + #[tokio::test] + async fn landed_destination_with_unconsumed_source_never_clears() { + let source = expanded_secret(10); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + let rig = rig([ + ( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + ), + ( + recipient, + OnChainCoin { + exponent: 1, + age: 5, + }, + ), + ]); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some("chat-message-1".into()), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: 20, + }, + ); + + assert!( + rig.service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .is_err() + ); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.coins.list().await.unwrap().is_empty()); + assert_eq!( + rig.plans + .plan(&memo_key) + .await + .unwrap() + .unwrap() + .claimed_amount, + None + ); + } + + /// A plan persisted before chat claims carried chat message ids keeps + /// working when the claim now arrives under the chat row's id. + #[tokio::test] + async fn legacy_external_id_plan_accepts_the_chat_message_id() { + let source = expanded_secret(11); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([]); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + rig.backend.state.lock().insert( + recipient, + OnChainCoin { + exponent: 1, + age: 3, + }, + ); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some(external_claim_message_id(&memo_key)), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Error, + claimed_amount: None, + total_value: 20, + }, + ); + + let claimed = rig + .service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .unwrap(); + assert_eq!(claimed, 20); + assert_eq!( + rig.plans.0.lock().get(&memo_key).unwrap().status, + ClaimPlanStatus::Finished, + "a persisted error heals once the destination is proven" + ); + } + + #[tokio::test] + async fn source_and_recipient_presence_collision_fails_closed() { + let source = expanded_secret(7); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + let rig = rig([ + ( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + ), + ( + recipient, + OnChainCoin { + exponent: 1, + age: 0, + }, + ), + ]); + + let error = rig + .service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap_err(); + assert!(error.contains("destination already exists")); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.plans.0.lock().is_empty()); + } + + #[tokio::test] + async fn spent_recovery_restores_max_age_and_reuses_the_claim_transfer_lane() { + let factory = CoinKeypairFactory::new(&[0x44; 16]); + let max_age = Coin { + exponent: 1, + derivation_index: 3, + age: Some(1), + state: CoinState::Spent, + }; + let transferable = Coin { + exponent: 2, + derivation_index: 4, + age: Some(1), + state: CoinState::Spent, + }; + let absent = Coin { + exponent: 0, + derivation_index: 5, + age: Some(1), + state: CoinState::Spent, + }; + let rig = rig([ + ( + factory.public_key(max_age.derivation_index).unwrap(), + OnChainCoin { + exponent: max_age.exponent, + age: COIN_MAX_AGE, + }, + ), + ( + factory.public_key(transferable.derivation_index).unwrap(), + OnChainCoin { + exponent: transferable.exponent, + age: COIN_MAX_AGE - 1, + }, + ), + ]); + for coin in [&max_age, &transferable, &absent] { + rig.coins.upsert(coin).await.unwrap(); + } + let recovery = SpentCoinTransferRecoveryService::new( + &[0x44; 16], + Arc::clone(&rig.coins) as Arc<_>, + Arc::clone(&rig.backend) as Arc<_>, + Arc::clone(&rig.service), + ); + + let report = recovery + .recover(vec![max_age.clone(), transferable.clone(), absent]) + .await + .unwrap(); + assert_eq!( + report, + SpentCoinTransferRecoveryReport { + recovered_count: 2, + recovered_planks: 60, + restored_max_age_count: 1, + transferred_count: 1, + } + ); + assert_eq!(rig.backend.transfers.lock().len(), 1); + let coins = rig.coins.list().await.unwrap(); + assert_eq!( + coins + .iter() + .find(|coin| coin.derivation_index == max_age.derivation_index) + .unwrap() + .state, + CoinState::Available + ); + assert_eq!( + coins + .iter() + .find(|coin| coin.derivation_index == transferable.derivation_index) + .unwrap() + .state, + CoinState::Spent + ); + assert!( + coins.iter().any(|coin| coin.derivation_index == 0 + && coin.exponent == transferable.exponent + && coin.state == CoinState::Available), + "the younger source lands in the allocator's fresh destination" + ); + } + + #[tokio::test] + async fn spent_recovery_rejects_non_spent_inputs_before_querying() { + let rig = rig([]); + let recovery = SpentCoinTransferRecoveryService::new( + &[0x44; 16], + Arc::clone(&rig.coins) as Arc<_>, + Arc::clone(&rig.backend) as Arc<_>, + Arc::clone(&rig.service), + ); + let error = recovery + .recover(vec![Coin { + exponent: 0, + derivation_index: 0, + age: Some(0), + state: CoinState::Available, + }]) + .await + .unwrap_err(); + assert!(error.contains("non-spent")); + assert!(rig.backend.transfers.lock().is_empty()); + } +} + +/// Secret-preserving claim operation; implementations do not expose memo bytes +/// through a product or transport API. +#[async_trait] +pub trait ExternalMemoClaiming: Send + Sync { + /// Execute or resume a durable memo-keyed claim into this wallet. + async fn claim_external_memo( + &self, + memo: TransferMemo, + message_id: String, + ) -> Result; +} + +/// Recover locally spent coins whose finalized on-chain value remains owned. +#[async_trait] +pub trait SpentCoinsRecovering: Send + Sync { + /// Reconcile and recover the supplied spent inventory. + async fn recover_spent_coins(&self, spent: Vec) -> Result; +} diff --git a/rust/crates/truapi-coinage/src/selection.rs b/rust/crates/truapi-coinage/src/selection.rs new file mode 100644 index 000000000..990a32c2a --- /dev/null +++ b/rust/crates/truapi-coinage/src/selection.rs @@ -0,0 +1,911 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::denomination::{Denomination, DenominationBreakdownContext}; +use crate::model::{Coin, EffectivePrivacy, Voucher, VoucherRemoteState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivacyLevel { + Full, + Degraded, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoinSelectionError { + ZeroAmount, + EmptyWallet, + /// The amount does not decompose exactly into denominations at this + /// context's granularity (below `min_exponent`). + AmountNotRepresentable { + remainder: u128, + }, + /// Vouchers exist but none are ready — distinct from + /// `InsufficientFunds` so the UI can show "wait for maturity" + NoReadyVouchers, + TooManyVouchersInGroup { + count: usize, + max: usize, + }, + InsufficientFunds, +} + +impl std::fmt::Display for CoinSelectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CoinSelectionError::ZeroAmount => write!(f, "amount must be greater than zero"), + CoinSelectionError::EmptyWallet => write!(f, "no coins or vouchers available"), + CoinSelectionError::AmountNotRepresentable { remainder } => { + write!( + f, + "amount not representable ({remainder} planks below the smallest denomination)" + ) + } + CoinSelectionError::NoReadyVouchers => write!(f, "vouchers are not ready yet"), + CoinSelectionError::TooManyVouchersInGroup { count, max } => { + write!(f, "recycler group holds {count} vouchers, maximum {max}") + } + CoinSelectionError::InsufficientFunds => write!(f, "insufficient funds"), + } + } +} + +impl std::error::Error for CoinSelectionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RecyclerKey { + pub exponent: i16, + pub index: u32, +} + +/// One unload group: all selected vouchers of one recycler, with the +/// group's output split into recipient + change denominations whose +/// combined value always equals the group's total voucher input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherGroup { + pub recycler: RecyclerKey, + pub vouchers: Vec, + pub recipient_denominations: Vec, + pub change_denominations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransferStrategy { + /// Whole coins pass to the recipient via the memo; nothing on-chain. + ExactMatch { coins: Vec }, + /// `partial_coins` pass whole; `split_coin` is split into + /// `target_denominations` (recipient) + `change_denominations` + /// (sender), signed with the coin's own key (AsCoin origin). + Split { + partial_coins: Vec, + split_coin: Coin, + target_denominations: Vec, + change_denominations: Vec, + }, + /// `coins` pass whole; each group is one + /// `UnloadRecyclerIntoCoins` extrinsic (Ring-VRF origin). + UnloadIntoCoins { + coins: Vec, + groups: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoinSelectionResult { + pub strategy: TransferStrategy, + pub privacy_level: PrivacyLevel, +} + +pub fn find_exact_match( + amount: u128, + coins: &[Coin], + context: &DenominationBreakdownContext, +) -> Option> { + let (selected, remaining) = greedy_take(amount, coins, context); + (remaining == 0).then_some(selected) +} + +/// Greedy largest-first pass: takes coins whose value fits the remaining +/// amount; returns the take and what is left uncovered. +fn greedy_take( + amount: u128, + coins: &[Coin], + context: &DenominationBreakdownContext, +) -> (Vec, u128) { + let mut pool: Vec<&Coin> = coins.iter().filter(|c| c.is_selectable()).collect(); + pool.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(b.age.unwrap_or(-1).cmp(&a.age.unwrap_or(-1))) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut remaining = amount; + let mut selected = Vec::new(); + for coin in pool { + let value = context.value_in_planks(coin.exponent); + if value <= remaining { + remaining -= value; + selected.push(coin.clone()); + } + } + (selected, remaining) +} + +pub struct CoinSelector { + context: DenominationBreakdownContext, + max_consolidation: usize, +} + +impl CoinSelector { + pub fn new(context: DenominationBreakdownContext, max_consolidation: usize) -> Self { + Self { + context, + max_consolidation, + } + } + + /// Runs the three strategies in priority order. `now_ms` drives + /// voucher effective-privacy evaluation. + pub fn select( + &self, + amount: u128, + coins: &[Coin], + vouchers: &[Voucher], + now_ms: i64, + ) -> Result { + if amount == 0 { + return Err(CoinSelectionError::ZeroAmount); + } + let unloadable: Vec<&Voucher> = vouchers.iter().filter(|v| v.is_unloadable()).collect(); + let any_available_coin = coins + .iter() + .any(|c| c.state == crate::model::CoinState::Available); + if !any_available_coin && unloadable.is_empty() { + return Err(CoinSelectionError::EmptyWallet); + } + let amount_breakdown = self.context.breakdown(amount); + if !amount_breakdown.is_exact() { + return Err(CoinSelectionError::AmountNotRepresentable { + remainder: amount_breakdown.remainder, + }); + } + + if let Some(selected) = find_exact_match(amount, coins, &self.context) { + return Ok(CoinSelectionResult { + strategy: TransferStrategy::ExactMatch { coins: selected }, + privacy_level: PrivacyLevel::Full, + }); + } + + if let Some(result) = self.try_split_coin(amount, coins) { + return Ok(CoinSelectionResult { + strategy: result, + privacy_level: PrivacyLevel::Full, + }); + } + + let full_pool: Vec<&Voucher> = unloadable + .iter() + .copied() + .filter(|v| v.privacy == crate::model::VoucherPrivacyLevel::Full) + .collect(); + if let Some(strategy) = self.try_unload(amount, coins, &full_pool)? { + let privacy_level = strategy_privacy(&strategy, now_ms); + return Ok(CoinSelectionResult { + strategy, + privacy_level, + }); + } + + if unloadable.len() > full_pool.len() + && let Some(strategy) = self.try_unload(amount, coins, &unloadable)? + { + let privacy_level = strategy_privacy(&strategy, now_ms); + return Ok(CoinSelectionResult { + strategy, + privacy_level, + }); + } + + if !unloadable.is_empty() && full_pool.is_empty() { + return Err(CoinSelectionError::NoReadyVouchers); + } + Err(CoinSelectionError::InsufficientFunds) + } + + fn try_split_coin(&self, amount: u128, coins: &[Coin]) -> Option { + let mut selectable = coins + .iter() + .filter(|c| c.is_selectable()) + .cloned() + .collect::>(); + let sufficient = selectable + .iter() + .filter(|coin| self.context.value_in_planks(coin.exponent) > amount) + .min_by_key(|coin| { + ( + self.context.value_in_planks(coin.exponent), + coin.derivation_index, + ) + }) + .cloned(); + if let Some(split_coin) = sufficient { + return Some(self.split_strategy(Vec::new(), split_coin, amount)); + } + + selectable.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut partial = Vec::new(); + let mut accumulated = 0u128; + for coin in selectable { + let value = self.context.value_in_planks(coin.exponent); + let next = accumulated.saturating_add(value); + if next < amount { + partial.push(coin); + accumulated = next; + continue; + } + return Some(self.split_strategy(partial, coin, amount - accumulated)); + } + None + } + + fn split_strategy( + &self, + partial: Vec, + split_coin: Coin, + remaining: u128, + ) -> TransferStrategy { + let coin_value = self.context.value_in_planks(split_coin.exponent); + let target = self.context.breakdown(remaining); + let change = self.context.breakdown(coin_value - remaining); + debug_assert!( + target.is_exact() && change.is_exact(), + "powers of two split exactly" + ); + TransferStrategy::Split { + partial_coins: partial, + split_coin, + target_denominations: target.denominations, + change_denominations: change.denominations, + } + } + + fn try_unload( + &self, + amount: u128, + coins: &[Coin], + pool: &[&Voucher], + ) -> Result, CoinSelectionError> { + if pool.is_empty() { + return Ok(None); + } + let (partial, needed) = greedy_take(amount, coins, &self.context); + debug_assert!(needed > 0); + + let chosen: Vec<&Voucher> = match pool + .iter() + .filter(|v| self.context.value_in_planks(v.exponent) >= needed) + .min_by_key(|v| (self.context.value_in_planks(v.exponent), v.derivation_index)) + { + Some(single) => vec![single], + None => { + // Greedy largest-first accumulation until covered. + let mut sorted: Vec<&Voucher> = pool.to_vec(); + sorted.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut sum = 0u128; + let mut chosen = Vec::new(); + for voucher in sorted { + if sum >= needed { + break; + } + sum = sum.saturating_add(self.context.value_in_planks(voucher.exponent)); + chosen.push(voucher); + } + if sum < needed { + return Ok(None); + } + chosen + } + }; + + let mut groups: Vec<(RecyclerKey, Vec)> = Vec::new(); + for voucher in chosen { + let VoucherRemoteState::InRecycler { recycler_index } = voucher.remote_state else { + // is_unloadable filtered already; defensive. + continue; + }; + let key = RecyclerKey { + exponent: voucher.exponent, + index: recycler_index, + }; + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, members)) => members.push(voucher.clone()), + None => groups.push((key, vec![voucher.clone()])), + } + } + + groups.sort_by(|(left, _), (right, _)| { + right + .exponent + .cmp(&left.exponent) + .then(left.index.cmp(&right.index)) + }); + + let mut needed_left = needed; + let mut allocated = Vec::with_capacity(groups.len()); + for (recycler, members) in groups { + if members.len() >= self.max_consolidation { + return Err(CoinSelectionError::TooManyVouchersInGroup { + count: members.len(), + max: self.max_consolidation, + }); + } + let input: u128 = members.iter().fold(0u128, |acc, v| { + acc.saturating_add(self.context.value_in_planks(v.exponent)) + }); + let recipient_amount = needed_left.min(input); + needed_left -= recipient_amount; + let recipient = self.context.breakdown(recipient_amount); + let change = self.context.breakdown(input - recipient_amount); + debug_assert!(recipient.is_exact() && change.is_exact()); + allocated.push(VoucherGroup { + recycler, + vouchers: members, + recipient_denominations: recipient.denominations, + change_denominations: change.denominations, + }); + } + debug_assert_eq!(needed_left, 0); + Ok(Some(TransferStrategy::UnloadIntoCoins { + coins: partial, + groups: allocated, + })) + } +} + +fn strategy_privacy(strategy: &TransferStrategy, now_ms: i64) -> PrivacyLevel { + match strategy { + TransferStrategy::UnloadIntoCoins { groups, .. } + if groups + .iter() + .flat_map(|group| &group.vouchers) + .any(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Degraded) => + { + PrivacyLevel::Degraded + } + _ => PrivacyLevel::Full, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{CoinState, VoucherLocalState, VoucherPrivacyLevel}; + + /// Unit 10, exponents 0..=4 → coin values 10..160. + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn selector() -> CoinSelector { + CoinSelector::new(ctx(), 8) + } + + fn coin(index: u32, exponent: i16, age: Option) -> Coin { + Coin { + exponent, + derivation_index: index, + age, + state: CoinState::Available, + } + } + + fn voucher( + index: u32, + exponent: i16, + recycler: u32, + privacy: VoucherPrivacyLevel, + ready_at_ms: i64, + ) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms, + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + local_state: VoucherLocalState::Available, + privacy, + } + } + + fn full_voucher(index: u32, exponent: i16) -> Voucher { + voucher(index, exponent, 0, VoucherPrivacyLevel::Full, 0) + } + + const NOW: i64 = 1_000_000; + + #[test] + fn zero_amount_is_rejected() { + assert_eq!( + selector().select(0, &[coin(1, 0, None)], &[], NOW), + Err(CoinSelectionError::ZeroAmount) + ); + } + + #[test] + fn empty_wallet_is_rejected() { + assert_eq!( + selector().select(10, &[], &[], NOW), + Err(CoinSelectionError::EmptyWallet) + ); + // A wallet of only spent coins is empty too. + let spent = Coin { + state: CoinState::Spent, + ..coin(1, 4, None) + }; + assert_eq!( + selector().select(10, &[spent], &[], NOW), + Err(CoinSelectionError::EmptyWallet) + ); + } + + #[test] + fn sub_denomination_amount_is_rejected() { + assert_eq!( + selector().select(15, &[coin(1, 4, None)], &[], NOW), + Err(CoinSelectionError::AmountNotRepresentable { remainder: 5 }) + ); + } + + #[test] + fn exact_match_selects_minimum_coins_largest_first() { + let coins = [ + coin(1, 0, None), + coin(2, 1, None), + coin(3, 2, None), + coin(4, 4, None), + ]; + // 230 = 160 + 40 + 20 + 10 — all four; 200 = 160 + 40. + let result = selector().select(200, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!( + selected + .iter() + .map(|c| c.derivation_index) + .collect::>(), + [4, 3] + ); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + } + + #[test] + fn exact_match_prefers_older_coins_within_a_denomination() { + let coins = [coin(1, 0, Some(2)), coin(2, 0, Some(9)), coin(3, 0, None)]; + let result = selector().select(10, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!(selected[0].derivation_index, 2, "age 9 spends before age 2"); + } + + #[test] + fn expiring_coins_never_enter_selection() { + let coins = [coin(1, 0, Some(14)), coin(2, 0, Some(13))]; + // 20 would need both — the expiring one is invisible. + assert_eq!( + selector().select(20, &coins, &[], NOW), + Err(CoinSelectionError::InsufficientFunds) + ); + // 10 matches with the young coin only. + let result = selector().select(10, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!(selected[0].derivation_index, 2); + } + + #[test] + fn find_exact_match_returns_none_when_no_exact_subset_exists() { + assert!(find_exact_match(30, &[coin(1, 2, None)], &ctx()).is_none()); + assert!(find_exact_match(30, &[coin(1, 1, None), coin(2, 0, None)], &ctx()).is_some()); + } + + #[test] + fn split_picks_smallest_coin_larger_than_the_remainder() { + // Amount 30: no exact match from {160, 80}; split the 80? No — + // smallest coin larger than 30 is 80 (overshoot 50) vs 160 + // (overshoot 130) → split coin 2 (exp 3). + let coins = [coin(1, 4, None), coin(2, 3, None)]; + let result = selector().select(30, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } = result.strategy + else { + panic!("expected split"); + }; + assert!(partial_coins.is_empty()); + assert_eq!(split_coin.derivation_index, 2); + // target 30 = 20 + 10; change 50 = 40 + 10. + assert_eq!( + target_denominations, + [Denomination { exponent: 1 }, Denomination { exponent: 0 }] + ); + assert_eq!( + change_denominations, + [Denomination { exponent: 2 }, Denomination { exponent: 0 }] + ); + // Split invariant: partial + target == amount; target + change == coin. + assert_eq!(ctx().total_value(&target_denominations), 30); + assert_eq!(ctx().total_value(&change_denominations), 50); + } + + /// Smaller whole coins must not be added when one coin can fund the full + /// split. + #[test] + fn split_uses_one_smallest_coin_larger_than_the_whole_request() { + let coins = [ + coin(1, 4, None), // 160 + coin(2, 2, None), // 40 + coin(3, 0, None), // 10 + ]; + let result = selector().select(30, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + .. + } = result.strategy + else { + panic!("expected split"); + }; + assert!(partial_coins.is_empty()); + assert_eq!(split_coin.derivation_index, 2); + } + + #[test] + fn split_takes_a_partial_contribution_first() { + // Amount 50: greedy takes the 40 (exp 2), remainder 10 needs a + // split of the 20 (exp 1) into 10 + 10. + let coins = [coin(1, 2, None), coin(2, 1, None)]; + let result = selector().select(50, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } = result.strategy + else { + panic!("expected split"); + }; + assert_eq!(partial_coins[0].derivation_index, 1); + assert_eq!(split_coin.derivation_index, 2); + assert_eq!(ctx().total_value(&target_denominations), 10); + assert_eq!(ctx().total_value(&change_denominations), 10); + } + + #[test] + fn unload_prefers_single_smallest_sufficient_voucher() { + let coins: [Coin; 0] = []; + let vouchers = [full_voucher(1, 4), full_voucher(2, 2), full_voucher(3, 3)]; + // Need 40: the exp-2 voucher (value 40) is the minimal cover — + // not the 80, not the 160, not a combination. + let result = selector().select(40, &coins, &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } = result.strategy + else { + panic!("expected unload"); + }; + assert!(partial.is_empty()); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].vouchers[0].derivation_index, 2); + assert_eq!(ctx().total_value(&groups[0].recipient_denominations), 40); + assert!(groups[0].change_denominations.is_empty()); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + } + + #[test] + fn unload_groups_by_recycler_and_balances_output_to_input() { + let vouchers = [ + voucher(1, 2, 7, VoucherPrivacyLevel::Full, 0), // 40, recycler (2,7) + voucher(2, 2, 9, VoucherPrivacyLevel::Full, 0), // 40, recycler (2,9) + ]; + // Need 60 → both vouchers (no single is sufficient): group (2,7) + // gives 40 to the recipient, group (2,9) gives 20 + 20 change. + let result = selector().select(60, &[], &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { groups, .. } = result.strategy else { + panic!("expected unload"); + }; + assert_eq!(groups.len(), 2); + for group in &groups { + let input: u128 = group + .vouchers + .iter() + .map(|v| ctx().value_in_planks(v.exponent)) + .sum(); + let output = ctx().total_value(&group.recipient_denominations) + + ctx().total_value(&group.change_denominations); + assert_eq!(input, output, "pallet invariant: group output == input"); + } + let recipient_total: u128 = groups + .iter() + .map(|g| ctx().total_value(&g.recipient_denominations)) + .sum(); + assert_eq!(recipient_total, 60); + } + + #[test] + fn unload_groups_are_ordered_by_descending_exponent() { + let vouchers = [ + voucher(1, 0, 7, VoucherPrivacyLevel::Full, 0), // 10 + voucher(2, 2, 9, VoucherPrivacyLevel::Full, 0), // 40 + ]; + let result = selector().select(50, &[], &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { groups, .. } = result.strategy else { + panic!("expected unload"); + }; + assert_eq!( + groups + .iter() + .map(|group| group.recycler.exponent) + .collect::>(), + [2, 0] + ); + } + + #[test] + fn unload_uses_partial_coins_before_vouchers() { + let coins = [coin(1, 1, None)]; // 20 + let vouchers = [full_voucher(10, 2)]; // 40 + // Need 60 = coin 20 + voucher 40. + let result = selector().select(60, &coins, &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } = result.strategy + else { + panic!("expected unload"); + }; + assert_eq!(partial[0].derivation_index, 1); + assert_eq!(groups[0].vouchers[0].derivation_index, 10); + } + + #[test] + fn degraded_fallback_triggers_when_full_privacy_is_insufficient() { + let vouchers = [ + full_voucher(1, 1), // 20 full + voucher(2, 2, 0, VoucherPrivacyLevel::Degraded, 0), // 40 degraded + ]; + // 20 is coverable full-privacy → Full. + let result = selector().select(20, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + // 60 needs the degraded voucher too → Degraded. + let result = selector().select(60, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Degraded); + } + + #[test] + fn not_yet_ready_full_voucher_counts_as_degraded() { + let vouchers = [voucher(1, 1, 0, VoucherPrivacyLevel::Full, NOW + 1)]; + let result = selector().select(20, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Degraded); + } + + #[test] + fn no_ready_vouchers_error_when_pool_is_all_unready() { + let vouchers = [voucher(1, 0, 0, VoucherPrivacyLevel::Degraded, 0)]; // 10 + assert_eq!( + selector().select(160, &[], &vouchers, NOW), + Err(CoinSelectionError::NoReadyVouchers) + ); + } + + #[test] + fn too_many_vouchers_in_group_is_a_hard_stop() { + let tight = CoinSelector::new(ctx(), 2); + let vouchers: Vec = (0..3).map(|i| full_voucher(i, 0)).collect(); // 3 × 10, one recycler + assert_eq!( + tight.select(30, &[], &vouchers, NOW), + Err(CoinSelectionError::TooManyVouchersInGroup { count: 3, max: 2 }) + ); + } + + #[test] + fn voucher_group_equal_to_max_is_rejected_like_ios_v2() { + let tight = CoinSelector::new(ctx(), 2); + let vouchers = [full_voucher(1, 0), full_voucher(2, 0)]; + assert_eq!( + tight.select(20, &[], &vouchers, NOW), + Err(CoinSelectionError::TooManyVouchersInGroup { count: 2, max: 2 }) + ); + } + + #[test] + fn insufficient_funds_when_everything_together_cannot_cover() { + let coins = [coin(1, 0, None)]; + let vouchers = [full_voucher(2, 0)]; + assert_eq!( + selector().select(160, &coins, &vouchers, NOW), + Err(CoinSelectionError::InsufficientFunds) + ); + } + + /// Deterministic pseudo-random sweep: every outcome upholds the value + /// invariants, and all three strategy paths are exercised. + #[test] + fn random_sweep_covers_all_strategies_and_holds_invariants() { + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + let mut rng = StdRng::seed_from_u64(0x_C01_A6E); + let context = ctx(); + let selector = selector(); + let (mut exact, mut split, mut unload) = (0, 0, 0); + + for _ in 0..300 { + let coins: Vec = (0..rng.gen_range(0..6)) + .map(|i| { + coin( + i, + rng.gen_range(0..=4), + if rng.gen_bool(0.3) { + None + } else { + Some(rng.gen_range(0..16)) + }, + ) + }) + .collect(); + let vouchers: Vec = (0..rng.gen_range(0..5)) + .map(|i| { + voucher( + 100 + i, + rng.gen_range(0..=4), + rng.gen_range(0..3), + if rng.gen_bool(0.7) { + VoucherPrivacyLevel::Full + } else { + VoucherPrivacyLevel::Degraded + }, + if rng.gen_bool(0.8) { 0 } else { NOW + 1 }, + ) + }) + .collect(); + let amount = u128::from(rng.gen_range(1..60u32)) * 10; + + let first = selector.select(amount, &coins, &vouchers, NOW); + assert_eq!( + first, + selector.select(amount, &coins, &vouchers, NOW), + "identical snapshots must select deterministically" + ); + + match first { + Ok(result) => match result.strategy { + TransferStrategy::ExactMatch { coins: selected } => { + let unique: std::collections::HashSet<_> = + selected.iter().map(|coin| coin.derivation_index).collect(); + assert_eq!(unique.len(), selected.len(), "coin inputs are unique"); + exact += 1; + let total: u128 = selected + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + assert_eq!(total, amount); + assert!(selected.iter().all(|c| c.is_selectable())); + } + TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } => { + split += 1; + let mut unique: std::collections::HashSet<_> = partial_coins + .iter() + .map(|coin| coin.derivation_index) + .collect(); + assert!( + unique.insert(split_coin.derivation_index), + "the split coin cannot also be a partial input" + ); + assert!(partial_coins.iter().all(Coin::is_selectable)); + assert!(split_coin.is_selectable()); + let partial: u128 = partial_coins + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + assert_eq!( + partial + context.total_value(&target_denominations), + amount, + "partial + split target == amount" + ); + assert_eq!( + context.total_value(&target_denominations) + + context.total_value(&change_denominations), + context.value_in_planks(split_coin.exponent), + "split conserves the coin's value" + ); + } + TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } => { + unload += 1; + let unique_coins: std::collections::HashSet<_> = + partial.iter().map(|coin| coin.derivation_index).collect(); + assert_eq!(unique_coins.len(), partial.len(), "coin inputs are unique"); + assert!(partial.iter().all(Coin::is_selectable)); + let mut unique_vouchers = std::collections::HashSet::new(); + let partial: u128 = partial + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + let recipient: u128 = groups + .iter() + .map(|g| context.total_value(&g.recipient_denominations)) + .sum(); + assert_eq!(partial + recipient, amount, "unload covers exactly"); + for group in &groups { + let input: u128 = group + .vouchers + .iter() + .map(|v| context.value_in_planks(v.exponent)) + .sum(); + assert_eq!( + input, + context.total_value(&group.recipient_denominations) + + context.total_value(&group.change_denominations) + ); + assert!( + group.vouchers.len() < 8, + "reference max-consolidation check is strict" + ); + assert!(group.vouchers.iter().all(Voucher::is_unloadable)); + assert!( + group.vouchers.iter().all(|voucher| { + unique_vouchers.insert(voucher.derivation_index) + }), + "voucher inputs are unique across recycler groups" + ); + } + } + }, + Err(error) => { + assert!( + matches!( + error, + CoinSelectionError::EmptyWallet + | CoinSelectionError::InsufficientFunds + | CoinSelectionError::NoReadyVouchers + | CoinSelectionError::TooManyVouchersInGroup { .. } + ), + "unexpected error class: {error:?}" + ); + } + } + } + assert!(exact > 10, "exact-match path exercised ({exact})"); + assert!(split > 10, "split path exercised ({split})"); + assert!(unload > 10, "unload path exercised ({unload})"); + } +} diff --git a/rust/crates/truapi-coinage/src/sync.rs b/rust/crates/truapi-coinage/src/sync.rs new file mode 100644 index 000000000..038fc20fd --- /dev/null +++ b/rust/crates/truapi-coinage/src/sync.rs @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::AbortHandle; +use tokio::sync::{mpsc, watch}; +use tracing::warn; + +use crate::model::{Coin, CoinState, Voucher, VoucherLocalState}; +use crate::repo::{CoinRepository, VoucherRepository}; + +/// One coin's on-chain reading (`CoinsByOwner` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OnChainCoin { + pub exponent: i16, + pub age: i16, +} + +/// One (possibly partial) emission: `None` means the coin's entry is +/// absent on-chain. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CoinStateUpdate { + pub coins: Vec<(u32, Option)>, +} + +/// The chain effect: ONE batched storage subscription over the given +/// derivation indices (key derivation happens inside the impl). +/// Re-calling replaces the previous subscription. +#[async_trait] +pub trait CoinStateSubscriber: Send + Sync { + async fn subscribe( + &self, + derivation_indices: Vec, + ) -> Result, String>; +} + +pub struct CoinStateSyncService { + spawner: crate::Spawner, + coins: Arc, + chain: Arc, + subscribed: Mutex>, + task: Mutex>, +} + +impl CoinStateSyncService { + pub fn new( + coins: Arc, + chain: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + coins, + chain, + subscribed: Mutex::new(Vec::new()), + spawner, + task: Mutex::new(None), + } + } + + /// (Re)builds the batch subscription over the monitored set. Call at + /// startup and whenever local coins change; the service also resyncs + /// itself when an applied update shrinks the monitored set. + pub async fn sync(self: &Arc) -> Result<(), String> { + let monitored = self.monitored_indices().await?; + if monitored.is_empty() { + self.stop(); + return Ok(()); + } + *self.subscribed.lock() = monitored.clone(); + let receiver = self.chain.subscribe(monitored).await?; + self.replace_task(receiver); + Ok(()) + } + + pub fn stop(&self) { + if let Some(handle) = self.task.lock().take() { + handle.abort(); + } + self.subscribed.lock().clear(); + } + + async fn monitored_indices(&self) -> Result, String> { + let mut indices: Vec = self + .coins + .list() + .await? + .into_iter() + .filter(|c| c.state != CoinState::Spent && c.age.is_none()) + .map(|c| c.derivation_index) + .collect(); + indices.sort_unstable(); + Ok(indices) + } + + fn replace_task(self: &Arc, mut receiver: mpsc::Receiver) { + let service = Arc::clone(self); + let handle = crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(update) = receiver.recv().await { + match service.handle_update(update).await { + Ok(true) => { + // The monitored set changed — rebuild. + let service = Arc::clone(&service); + crate::tasks::spawn_abortable(&service.spawner.clone(), async move { + if let Err(error) = service.sync().await { + warn!(error, "coin state resync failed"); + } + }); + return; + } + Ok(false) => {} + Err(error) => warn!(error, "coin state update failed"), + } + } + }); + let previous = self.task.lock().replace(handle); + if let Some(previous) = previous { + previous.abort(); + } + } + + /// Applies one emission; resolves `true` when the monitored set + /// changed (resync needed). + async fn handle_update(&self, update: CoinStateUpdate) -> Result { + let coins = self.coins.list().await?; + for (index, on_chain) in &update.coins { + let Some(coin) = coins + .iter() + .find(|c| c.derivation_index == *index && c.state != CoinState::Spent) + else { + continue; + }; + match on_chain { + Some(reading) => { + if coin.age != Some(reading.age) { + let mut updated = coin.clone(); + updated.age = Some(reading.age); + self.coins.upsert(&updated).await?; + } + } + None => { + if coin.age.is_some() { + self.coins + .set_state(coin.derivation_index, CoinState::Spent) + .await?; + } + } + } + } + let monitored = self.monitored_indices().await?; + Ok(monitored != *self.subscribed.lock()) + } +} + +/// [`CoinRepository`] decorator bumping a watch counter on every +/// successful mutation — the reactive change stream consumers re-fetch +/// on. +pub struct NotifyingCoinRepository { + inner: Arc, + changes: watch::Sender, +} + +impl NotifyingCoinRepository { + pub fn new(inner: Arc) -> Self { + Self { + inner, + changes: watch::channel(0).0, + } + } + + pub fn subscribe_changes(&self) -> watch::Receiver { + self.changes.subscribe() + } + + fn bump(&self) { + self.changes.send_modify(|generation| *generation += 1); + } + + pub fn notify_changed(&self) { + self.bump(); + } +} + +#[async_trait] +impl CoinRepository for NotifyingCoinRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.inner.upsert(coin).await?; + self.bump(); + Ok(()) + } + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + self.inner.set_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await?; + self.bump(); + Ok(()) + } +} + +/// [`VoucherRepository`] decorator with the same change stream. +pub struct NotifyingVoucherRepository { + inner: Arc, + changes: watch::Sender, +} + +impl NotifyingVoucherRepository { + pub fn new(inner: Arc) -> Self { + Self { + inner, + changes: watch::channel(0).0, + } + } + + pub fn subscribe_changes(&self) -> watch::Receiver { + self.changes.subscribe() + } + + fn bump(&self) { + self.changes.send_modify(|generation| *generation += 1); + } + + pub fn notify_changed(&self) { + self.bump(); + } +} + +#[async_trait] +impl VoucherRepository for NotifyingVoucherRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + async fn upsert(&self, voucher: &Voucher) -> Result<(), String> { + self.inner.upsert(voucher).await?; + self.bump(); + Ok(()) + } + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String> { + self.inner.set_local_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn set_remote_state( + &self, + derivation_index: u32, + state: crate::model::VoucherRemoteState, + ) -> Result<(), String> { + self.inner.set_remote_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await?; + self.bump(); + Ok(()) + } +} + +pub struct CoinageDatabaseDependencyFactory { + coins: Arc, + vouchers: Arc, +} + +impl CoinageDatabaseDependencyFactory { + pub fn new(coins: Arc, vouchers: Arc) -> Self { + Self { + coins: Arc::new(NotifyingCoinRepository::new(coins)), + vouchers: Arc::new(NotifyingVoucherRepository::new(vouchers)), + } + } + + pub fn coin_repository(&self) -> Arc { + Arc::clone(&self.coins) + } + + pub fn voucher_repository(&self) -> Arc { + Arc::clone(&self.vouchers) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + #[derive(Default)] + struct MockSubscriber { + calls: Mutex>>, + senders: Mutex>>, + } + + impl MockSubscriber { + fn latest_sender(&self) -> mpsc::Sender { + self.senders.lock().last().expect("no subscription").clone() + } + } + + #[async_trait] + impl CoinStateSubscriber for MockSubscriber { + async fn subscribe( + &self, + derivation_indices: Vec, + ) -> Result, String> { + self.calls.lock().push(derivation_indices); + let (tx, rx) = mpsc::channel(8); + self.senders.lock().push(tx); + Ok(rx) + } + } + + fn coin(index: u32, age: Option, state: CoinState) -> Coin { + Coin { + exponent: 1, + derivation_index: index, + age, + state, + } + } + + fn rig( + coins: Vec, + ) -> ( + Arc, + Arc, + Arc, + ) { + let repo = Arc::new(InMemoryCoinRepository::with_coins(coins)); + let subscriber = Arc::new(MockSubscriber::default()); + ( + Arc::new(CoinStateSyncService::new( + Arc::clone(&repo) as Arc, + Arc::clone(&subscriber) as Arc, + crate::test_spawner(), + )), + repo, + subscriber, + ) + } + + async fn read_coin(repo: &InMemoryCoinRepository, index: u32) -> Coin { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + } + + async fn settle(mut probe: impl AsyncFnMut() -> bool) { + for _ in 0..1_000 { + if probe().await { + return; + } + tokio::task::yield_now().await; + } + panic!("never settled"); + } + + #[tokio::test] + async fn age_lands_and_the_monitored_set_shrinks() { + let (service, repo, subscriber) = rig(vec![ + coin(1, None, CoinState::Available), + coin(2, Some(4), CoinState::Available), // known age — not monitored + coin(3, None, CoinState::Spent), // spent — not monitored + ]); + service.sync().await.unwrap(); + assert_eq!(subscriber.calls.lock().as_slice(), [vec![1]]); + + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![( + 1, + Some(OnChainCoin { + exponent: 1, + age: 0, + }), + )], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.age == Some(0)).await; + // Monitored set now empty → the resync stops the subscription. + settle(async || service.task.lock().is_none()).await; + } + + #[tokio::test] + async fn absence_spends_only_previously_observed_coins() { + let (service, repo, subscriber) = rig(vec![ + coin(1, None, CoinState::Available), + coin(2, None, CoinState::Available), + ]); + service.sync().await.unwrap(); + + // Coin 1 lands with an age… + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![( + 1, + Some(OnChainCoin { + exponent: 1, + age: 2, + }), + )], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.age == Some(2)).await; + settle(async || subscriber.calls.lock().len() == 2).await; + + // …then disappears (claimed by a recipient) while coin 2 is + // still absent because it never landed. + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![(1, None), (2, None)], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.state == CoinState::Spent).await; + let unlanded = read_coin(&repo, 2).await; + assert_eq!( + unlanded.state, + CoinState::Available, + "unknown-age absence is 'not landed yet', never 'spent'" + ); + assert_eq!(unlanded.age, None); + } + + #[tokio::test] + async fn empty_monitored_set_never_subscribes() { + let (service, _repo, subscriber) = rig(vec![coin(1, Some(3), CoinState::Available)]); + service.sync().await.unwrap(); + assert!(subscriber.calls.lock().is_empty()); + assert!(service.task.lock().is_none()); + } + + #[tokio::test] + async fn factory_change_streams_fire_on_mutation() { + let factory = CoinageDatabaseDependencyFactory::new( + Arc::new(InMemoryCoinRepository::default()), + Arc::new(InMemoryVoucherRepository::default()), + ); + let coins = factory.coin_repository(); + let mut coin_changes = coins.subscribe_changes(); + coins + .upsert(&coin(1, None, CoinState::Available)) + .await + .unwrap(); + coin_changes.changed().await.unwrap(); + + let same = factory.coin_repository(); + assert_eq!( + same.list().await.unwrap().len(), + 1, + "same underlying instance" + ); + + let vouchers = factory.voucher_repository(); + let mut voucher_changes = vouchers.subscribe_changes(); + vouchers + .upsert(&Voucher { + exponent: 0, + derivation_index: 1, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: crate::model::VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: crate::model::VoucherPrivacyLevel::Degraded, + }) + .await + .unwrap(); + voucher_changes.changed().await.unwrap(); + vouchers + .set_local_state(1, VoucherLocalState::Spent) + .await + .unwrap(); + voucher_changes.changed().await.unwrap(); + } +} diff --git a/rust/crates/truapi-coinage/src/tasks.rs b/rust/crates/truapi-coinage/src/tasks.rs new file mode 100644 index 000000000..1ef42039f --- /dev/null +++ b/rust/crates/truapi-coinage/src/tasks.rs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use futures::future::{AbortHandle, Abortable}; +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +/// Launch a cancellable future on the Host's executor. +pub(crate) fn spawn_abortable(spawner: &crate::Spawner, work: F) -> AbortHandle +where + F: Future + Send + 'static, +{ + let (abort, registration) = AbortHandle::new_pair(); + spawner(Box::pin(async move { + let _ = Abortable::new(work, registration).await; + })); + abort +} + +/// Per-message task ownership with cancellation and duplicate suppression. +pub struct ActiveTaskRegistry { + spawner: crate::Spawner, + state: Mutex, +} + +#[derive(Default)] +struct RegistryState { + next_generation: u64, + tasks: HashMap, +} + +impl ActiveTaskRegistry { + /// Bind background work to the owning Host executor. + pub fn new(spawner: crate::Spawner) -> Self { + Self { + spawner, + state: Mutex::new(RegistryState::default()), + } + } + + /// Start only if this message does not already have a live task. + pub fn try_start(self: &Arc, message_id: &str, work: F) -> bool + where + F: Future + Send + 'static, + { + let mut state = self.state.lock(); + if state.tasks.contains_key(message_id) { + return false; + } + let generation = state.next_generation; + state.next_generation = generation.checked_add(1).expect("task generation overflow"); + let (abort, registration) = AbortHandle::new_pair(); + let id = message_id.to_owned(); + state.tasks.insert(id.clone(), (generation, abort)); + drop(state); + // Construct outside the future so cancellation before its first poll + // still releases the id. An older cancelled task cannot remove a restart. + let guard = RemoveOnDrop { + registry: Arc::clone(self), + id, + generation, + }; + (self.spawner)(Box::pin( + Abortable::new( + async move { + let _guard = guard; + work.await; + }, + registration, + ) + .map(|_| ()), + )); + true + } + + /// Whether work currently owns this message id. + pub fn is_tracked(&self, message_id: &str) -> bool { + self.state.lock().tasks.contains_key(message_id) + } + + /// Number of live message tasks. + pub fn len(&self) -> usize { + self.state.lock().tasks.len() + } + + /// Whether no message is currently owned. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Cancel all work and make the message ids available for restart. + pub fn cancel_all(&self) { + let handles: Vec<_> = self + .state + .lock() + .tasks + .drain() + .map(|(_, (_, handle))| handle) + .collect(); + for handle in handles { + handle.abort(); + } + } +} + +use futures::FutureExt; + +struct RemoveOnDrop { + registry: Arc, + id: String, + generation: u64, +} + +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let mut state = self.registry.state.lock(); + if state + .tasks + .get(&self.id) + .is_some_and(|(generation, _)| *generation == self.generation) + { + state.tasks.remove(&self.id); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::watch; + + use super::*; + + async fn settle(registry: &ActiveTaskRegistry, until: impl Fn(&ActiveTaskRegistry) -> bool) { + for _ in 0..1_000 { + if until(registry) { + return; + } + tokio::task::yield_now().await; + } + panic!("registry never settled"); + } + + #[tokio::test] + async fn duplicate_starts_are_rejected_until_completion() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let (release_tx, release_rx) = watch::channel(false); + let runs = Arc::new(AtomicUsize::new(0)); + + let work = { + let runs = Arc::clone(&runs); + let mut release = release_rx.clone(); + async move { + runs.fetch_add(1, Ordering::SeqCst); + while !*release.borrow() { + if release.changed().await.is_err() { + return; + } + } + } + }; + assert!(registry.try_start("m1", work)); + assert!(registry.is_tracked("m1")); + assert!( + !registry.try_start("m1", async {}), + "in-flight id rejects a second task" + ); + + release_tx.send(true).unwrap(); + settle(®istry, |r| !r.is_tracked("m1")).await; + assert_eq!(runs.load(Ordering::SeqCst), 1, "the duplicate never ran"); + assert!(registry.try_start("m1", async {}), "restart after defer"); + } + + #[tokio::test] + async fn distinct_ids_run_concurrently() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let (_release_tx, release_rx) = watch::channel(false); + for id in ["a", "b", "c"] { + let mut release = release_rx.clone(); + assert!(registry.try_start(id, async move { + let _ = release.changed().await; + })); + } + assert_eq!(registry.len(), 3); + } + + #[tokio::test] + async fn cancel_all_aborts_and_clears() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let completed = Arc::new(AtomicUsize::new(0)); + for id in ["a", "b"] { + let completed = Arc::clone(&completed); + registry.try_start(id, async move { + std::future::pending::<()>().await; + completed.fetch_add(1, Ordering::SeqCst); + }); + } + registry.cancel_all(); + settle(®istry, |r| r.is_empty()).await; + assert_eq!(completed.load(Ordering::SeqCst), 0, "aborted, not run"); + assert!(registry.try_start("a", async {}), "ids are free again"); + } + + #[tokio::test] + async fn cancelled_unpolled_task_cannot_remove_its_replacement() { + let queue = Arc::new(Mutex::new( + Vec::>::new(), + )); + let pending = Arc::clone(&queue); + let registry = Arc::new(ActiveTaskRegistry::new(Arc::new(move |future| { + pending.lock().push(future); + }))); + assert!(registry.try_start("message", std::future::pending())); + registry.cancel_all(); + assert!(registry.try_start("message", std::future::pending())); + let old = queue.lock().remove(0); + old.await; + assert!(registry.is_tracked("message")); + assert!(!registry.try_start("message", async {})); + registry.cancel_all(); + let replacement = queue.lock().remove(0); + replacement.await; + assert!(registry.is_empty()); + } +} diff --git a/rust/crates/truapi-coinage/src/timer.rs b/rust/crates/truapi-coinage/src/timer.rs new file mode 100644 index 000000000..339762505 --- /dev/null +++ b/rust/crates/truapi-coinage/src/timer.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Runtime-independent delays, including browser hosts. +pub(crate) async fn sleep(duration: std::time::Duration) { + futures_timer::Delay::new(duration).await; +} diff --git a/rust/crates/truapi-coinage/src/transfer_sender.rs b/rust/crates/truapi-coinage/src/transfer_sender.rs new file mode 100644 index 000000000..b2a188a34 --- /dev/null +++ b/rust/crates/truapi-coinage/src/transfer_sender.rs @@ -0,0 +1,1992 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::join_all; +use rand::RngCore; +use tokio::sync::{Mutex as AsyncMutex, Notify}; +use tracing::{error, warn}; + +use crate::allocator::CoinAllocator; +use crate::clock::Clock; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState, EffectivePrivacy, Voucher, VoucherRemoteState}; +use crate::repo::{CoinRepository, TransferContext, TransferStateCommitter, VoucherRepository}; +use crate::ring_proof::{PersonOriginKind, ResolvedUnloadToken, RingProofParams}; +use crate::selection::{ + CoinSelectionError, CoinSelectionResult, CoinSelector, PrivacyLevel, RecyclerKey, + TransferStrategy, +}; +use crate::wal::{ + CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore, + operation_entry_id, +}; + +const PREVIEW_LIFETIME_MS: i64 = 2 * 60 * 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferPreviewChoice { + Full, + NonDegraded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferPreviewStrategy { + ExactMatch, + Split, + UnloadIntoCoins, +} + +/// Secret-free preview fields suitable for an FFI record. `preview_id` is +/// opaque and meaningful only to the session-scoped service that created it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpaqueTransferPreview { + pub preview_id: String, + pub session_generation: u64, + pub full_amount: u128, + /// Maximum source value that may leave the purse, in raw planks. + /// Unload bounds conservatively include all selected voucher value. + pub max_debit_amount: u128, + pub non_degraded_amount: u128, + pub is_degraded: bool, + pub strategy: TransferPreviewStrategy, + pub expires_at_ms: i64, +} + +/// Public-only voucher snapshot for selection diagnostics: exposes selection +/// state without exposing any voucher secret key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherSelectionDiagnostic { + pub derivation_index: u32, + pub exponent: i16, + pub local_state: crate::model::VoucherLocalState, + pub remote_state: crate::model::VoucherRemoteState, + pub stored_privacy: crate::model::VoucherPrivacyLevel, + pub effective_privacy: EffectivePrivacy, + pub ready_at_ms: i64, +} + +pub fn voucher_selection_diagnostics( + vouchers: &[Voucher], + now_ms: i64, +) -> Vec { + vouchers + .iter() + .map(|voucher| VoucherSelectionDiagnostic { + derivation_index: voucher.derivation_index, + exponent: voucher.exponent, + local_state: voucher.local_state, + remote_state: voucher.remote_state, + stored_privacy: voucher.privacy, + effective_privacy: voucher.effective_privacy(now_ms), + ready_at_ms: voucher.ready_at_ms, + }) + .collect() +} + +/// Redacted aggregate emitted when authoritative preview selection fails. +/// Values are represented only by decimal digit counts, so production logs +/// reveal neither exact balances nor any coin/voucher key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectorSnapshotDiagnostic { + pub selectable_coins: usize, + pub expiring_coins: usize, + pub locked_coins: usize, + pub unloadable_vouchers: usize, + pub locked_vouchers: usize, + pub full_vouchers: usize, + pub degraded_vouchers: usize, + pub selectable_coin_value_digits: usize, + pub unloadable_voucher_value_digits: usize, +} + +pub fn selector_snapshot_diagnostic( + coins: &[Coin], + vouchers: &[Voucher], + context: &DenominationBreakdownContext, + now_ms: i64, +) -> SelectorSnapshotDiagnostic { + let selectable_coin_value = coins + .iter() + .filter(|coin| coin.is_selectable()) + .fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }); + let unloadable_voucher_value = vouchers + .iter() + .filter(|voucher| voucher.is_unloadable()) + .fold(0u128, |sum, voucher| { + sum.saturating_add(context.value_in_planks(voucher.exponent)) + }); + SelectorSnapshotDiagnostic { + selectable_coins: coins.iter().filter(|coin| coin.is_selectable()).count(), + expiring_coins: coins + .iter() + .filter(|coin| { + coin.state == crate::model::CoinState::Available && coin.is_expiring_soon() + }) + .count(), + locked_coins: coins.iter().filter(|coin| !coin.is_selectable()).count(), + unloadable_vouchers: vouchers + .iter() + .filter(|voucher| voucher.is_unloadable()) + .count(), + locked_vouchers: vouchers + .iter() + .filter(|voucher| !voucher.is_unloadable()) + .count(), + full_vouchers: vouchers + .iter() + .filter(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Full) + .count(), + degraded_vouchers: vouchers + .iter() + .filter(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Degraded) + .count(), + selectable_coin_value_digits: decimal_digits(selectable_coin_value), + unloadable_voucher_value_digits: decimal_digits(unloadable_voucher_value), + } +} + +fn decimal_digits(value: u128) -> usize { + value.to_string().len() +} + +fn selection_error_kind(error: &CoinSelectionError) -> &'static str { + match error { + CoinSelectionError::ZeroAmount => "zero_amount", + CoinSelectionError::EmptyWallet => "empty_wallet", + CoinSelectionError::AmountNotRepresentable { .. } => "amount_not_representable", + CoinSelectionError::NoReadyVouchers => "vouchers_not_ready", + CoinSelectionError::TooManyVouchersInGroup { .. } => "too_many_vouchers", + CoinSelectionError::InsufficientFunds => "insufficient_funds", + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegularTransferError { + Selection(CoinSelectionError), + PreviewNotFound, + PreviewExpired, + BalanceChanged, + NonDegradedAmountUnavailable, + OperationNotFound, + InvalidOperationId, + Planning(String), + Reservation(String), + Journal(String), + HandoffRejected, +} + +impl std::fmt::Display for RegularTransferError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Selection(error) => error.fmt(formatter), + Self::PreviewNotFound => formatter.write_str("transfer preview was not found"), + Self::PreviewExpired => formatter.write_str("transfer preview expired"), + Self::BalanceChanged => { + formatter.write_str("CASH balance changed; preview the payment again") + } + Self::NonDegradedAmountUnavailable => { + formatter.write_str("no non-degraded CASH amount is available") + } + Self::OperationNotFound => formatter.write_str("transfer operation was not found"), + Self::InvalidOperationId => { + formatter.write_str("transfer operation identifier is empty") + } + Self::Planning(error) => write!(formatter, "transfer planning failed: {error}"), + Self::Reservation(error) => write!(formatter, "transfer reservation failed: {error}"), + Self::Journal(error) => write!(formatter, "transfer journal failed: {error}"), + Self::HandoffRejected => { + formatter.write_str("CASH message was rejected before durable acceptance") + } + } + } +} + +impl std::error::Error for RegularTransferError {} + +impl From for RegularTransferError { + fn from(value: CoinSelectionError) -> Self { + Self::Selection(value) + } +} + +/// One split submission after recipient/change indices have been allocated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitTransferSubmission { + pub overflow_coin: Coin, + pub recipient_coins: Vec, + pub change_coins: Vec, + pub wal_entry_id: String, +} + +/// One unload group before the host resolves the shared finalized state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadGroupDraft { + pub recycler: RecyclerKey, + pub vouchers: Vec, + pub recipient_coins: Vec, + pub change_coins: Vec, + pub wal_entry_id: String, +} + +/// Chain-origin inputs resolved at one finalized snapshot. Proof bytes are +/// deliberately absent: they are created only after the final transaction +/// implication exists inside the submitter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadOriginPreparation { + pub recycler_ring: RingProofParams, + pub person_origin: PersonOriginKind, + pub people_ring: RingProofParams, + pub token: ResolvedUnloadToken, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedUnloadGroup { + pub draft: UnloadGroupDraft, + pub readiness_block_hash: [u8; 32], + pub recycler_revision: u32, + pub origin: UnloadOriginPreparation, +} + +/// Production implementations must update the named WAL checkpoint after +/// transaction creation and before broadcast. A successful return certifies +/// that the submitted input consumption and exact requested recipient/change +/// allocation finalized successfully. This evidence permits retiring the +/// child WAL even if the recipient consumes an output before the next query. +#[async_trait] +pub trait RegularTransferSubmitter: Send + Sync { + async fn prepare_unload_groups( + &self, + groups: &[UnloadGroupDraft], + ) -> Result, String>; + + async fn submit_split(&self, submission: &SplitTransferSubmission) -> Result<(), String>; + + async fn submit_unload_group(&self, submission: &PreparedUnloadGroup) -> Result<(), String>; +} + +pub struct RegularCoinTransferParts { + /// Executor owned by the active signing authority. + pub spawner: crate::Spawner, + pub coins: Arc, + pub vouchers: Arc, + pub wal: Arc, + pub allocator: Arc, + pub submitter: Arc, + pub denominations: DenominationBreakdownContext, + pub max_consolidation: usize, + pub clock: Arc, + pub session_generation: u64, + pub committer: Option>, +} + +#[derive(Clone)] +struct CachedPreview { + descriptor: OpaqueTransferPreview, + full: CoinSelectionResult, + non_degraded: Option, + coins: Vec, + vouchers: Vec, +} + +#[derive(Default)] +struct Confirmation { + result: Mutex>>, + notify: Notify, +} + +impl Confirmation { + async fn wait(&self) -> Result<(), RegularTransferError> { + loop { + let notified = self.notify.notified(); + if let Some(result) = self.result.lock().clone() { + return result; + } + notified.await; + } + } + + fn finish(&self, result: Result<(), RegularTransferError>) { + *self.result.lock() = Some(result); + self.notify.notify_waiters(); + } +} + +pub struct RegularCoinTransferService { + spawner: crate::Spawner, + key_factory: Arc, + coins: Arc, + vouchers: Arc, + wal: Arc, + allocator: Arc, + submitter: Arc, + denominations: DenominationBreakdownContext, + max_consolidation: usize, + clock: Arc, + session_generation: u64, + committer: Option>, + previews: AsyncMutex>, + confirmations: AsyncMutex>>, + execution_lock: AsyncMutex<()>, +} + +impl RegularCoinTransferService { + pub fn new(root_entropy: &[u8], parts: RegularCoinTransferParts) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins: parts.coins, + vouchers: parts.vouchers, + wal: parts.wal, + allocator: parts.allocator, + submitter: parts.submitter, + denominations: parts.denominations, + max_consolidation: parts.max_consolidation.max(1), + clock: parts.clock, + session_generation: parts.session_generation, + committer: parts.committer, + previews: AsyncMutex::new(HashMap::new()), + confirmations: AsyncMutex::new(HashMap::new()), + execution_lock: AsyncMutex::new(()), + spawner: parts.spawner, + } + } + + /// The native send UI speaks whole CASH cents; selection and memo values + /// speak raw chain planks. Keep their only conversion at this service + /// boundary so callers cannot accidentally preview cents as planks. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + self.denominations.cash_cents_to_planks(cents) + } + + pub fn cash_cents_from_planks(&self, planks: u128) -> Option { + self.denominations.cash_cents_from_planks(planks) + } + + pub fn planks_per_cash_cent(&self) -> u128 { + self.denominations.asset_unit + } + + pub async fn preview( + &self, + amount: u128, + ) -> Result { + let coins = self + .coins + .list() + .await + .map_err(RegularTransferError::Planning)?; + let vouchers = self + .vouchers + .list() + .await + .map_err(RegularTransferError::Planning)?; + let now_ms = self.clock.now_ms(); + let full = match CoinSelector::new(self.denominations.clone(), self.max_consolidation) + .select(amount, &coins, &vouchers, now_ms) + { + Ok(selection) => selection, + Err(selection_error) => { + let snapshot = + selector_snapshot_diagnostic(&coins, &vouchers, &self.denominations, now_ms); + warn!( + error = selection_error_kind(&selection_error), + strategy = "none", + requested_value_digits = decimal_digits(amount), + selectable_coins = snapshot.selectable_coins, + expiring_coins = snapshot.expiring_coins, + locked_coins = snapshot.locked_coins, + unloadable_vouchers = snapshot.unloadable_vouchers, + locked_vouchers = snapshot.locked_vouchers, + full_vouchers = snapshot.full_vouchers, + degraded_vouchers = snapshot.degraded_vouchers, + selectable_coin_value_digits = snapshot.selectable_coin_value_digits, + unloadable_voucher_value_digits = snapshot.unloadable_voucher_value_digits, + "coinage selector snapshot" + ); + return Err(selection_error.into()); + } + }; + let (non_degraded, non_degraded_amount) = + non_degraded_selection(&full, &self.denominations, now_ms); + let strategy = match full.strategy { + TransferStrategy::ExactMatch { .. } => TransferPreviewStrategy::ExactMatch, + TransferStrategy::Split { .. } => TransferPreviewStrategy::Split, + TransferStrategy::UnloadIntoCoins { .. } => TransferPreviewStrategy::UnloadIntoCoins, + }; + let preview_id = random_id("cash-preview"); + let descriptor = OpaqueTransferPreview { + preview_id: preview_id.clone(), + session_generation: self.session_generation, + full_amount: amount, + max_debit_amount: selection_max_debit(&full, &self.denominations), + non_degraded_amount, + is_degraded: full.privacy_level == PrivacyLevel::Degraded, + strategy, + expires_at_ms: now_ms.saturating_add(PREVIEW_LIFETIME_MS), + }; + let mut previews = self.previews.lock().await; + previews.retain(|_, preview| preview.descriptor.expires_at_ms >= now_ms); + previews.insert( + preview_id, + CachedPreview { + descriptor: descriptor.clone(), + full, + non_degraded, + coins, + vouchers, + }, + ); + Ok(descriptor) + } + + pub async fn preview_descriptor( + &self, + preview_id: &str, + ) -> Result { + let descriptor = match self + .previews + .lock() + .await + .get(preview_id) + .map(|preview| preview.descriptor.clone()) + { + Some(descriptor) => descriptor, + None => { + return Err(RegularTransferError::PreviewNotFound); + } + }; + if self.clock.now_ms() > descriptor.expires_at_ms { + return Err(RegularTransferError::PreviewExpired); + } + Ok(descriptor) + } + + /// Confirms exactly the cached selector result. Calls racing for the same + /// preview join one result; only the first closure can receive the memo. + /// `handoff` may return `Err(())` only if it certifies that no recipient or + /// transport accepted the secret. Ambiguous delivery must retain the WAL + /// by returning `Ok(())` and reporting transport uncertainty separately. + pub async fn confirm( + self: &Arc, + preview_id: &str, + choice: TransferPreviewChoice, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let (confirmation, leader) = { + let mut confirmations = self.confirmations.lock().await; + match confirmations.get(preview_id) { + Some(existing) => (Arc::clone(existing), false), + None => { + let confirmation = Arc::new(Confirmation::default()); + confirmations.insert(preview_id.to_owned(), Arc::clone(&confirmation)); + (confirmation, true) + } + } + }; + if leader { + let service = Arc::clone(self); + let preview_id = preview_id.to_owned(); + let running = Arc::clone(&confirmation); + crate::tasks::spawn_abortable(&self.spawner, async move { + let result = service + .confirm_operation(&preview_id, &preview_id, choice, handoff) + .await; + running.finish(result); + }); + } + confirmation.wait().await + } + + /// Confirms a host-owned durable operation. The host must serialize wallet + /// recovery with this call and run it on its cancellation-safe executor. + /// The callback may await durable transport persistence before returning. + /// `Err` from the callback certifies no acceptance; uncertain acceptance + /// must return `Ok` and retain the transport's idempotent operation key. + /// + /// All chain work finishes (or leaves recoverable WAL) before returning. + /// Once transport accepts, chain/persistence failures never become `Err`. + pub async fn confirm_operation( + self: &Arc, + operation_id: &str, + preview_id: &str, + choice: TransferPreviewChoice, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + if operation_id.is_empty() { + return Err(RegularTransferError::InvalidOperationId); + } + let _execution = self.execution_lock.lock().await; + let existing = self.operation_wal(operation_id).await?; + if !existing.is_empty() { + return self.resume_entries(existing, handoff).await; + } + let cached = self + .previews + .lock() + .await + .get(preview_id) + .cloned() + .ok_or(RegularTransferError::PreviewNotFound)?; + if self.clock.now_ms() > cached.descriptor.expires_at_ms { + return Err(RegularTransferError::PreviewExpired); + } + let current_coins = self + .coins + .list() + .await + .map_err(RegularTransferError::Planning)?; + let current_vouchers = self + .vouchers + .list() + .await + .map_err(RegularTransferError::Planning)?; + if current_coins != cached.coins || current_vouchers != cached.vouchers { + return Err(RegularTransferError::BalanceChanged); + } + let expected_amount = match choice { + TransferPreviewChoice::Full => cached.descriptor.full_amount, + TransferPreviewChoice::NonDegraded => cached.descriptor.non_degraded_amount, + }; + let result = match choice { + TransferPreviewChoice::Full => cached.full, + TransferPreviewChoice::NonDegraded => cached + .non_degraded + .ok_or(RegularTransferError::NonDegradedAmountUnavailable)?, + }; + let amount = selection_value(&result, &self.denominations); + if amount == 0 + || amount != expected_amount + || selection_max_debit(&result, &self.denominations) + > cached.descriptor.max_debit_amount + { + return Err(RegularTransferError::BalanceChanged); + } + let mut plan = self.create_plan(operation_id, result, amount).await?; + let context = self.transfer_context(); + context + .reserve(&plan.reserved_coins, &plan.reserved_vouchers) + .await + .map_err(RegularTransferError::Reservation)?; + if let Err(error) = self.wal.save_all(&plan.journals).await { + // A failed acknowledgement can still have committed the atomic + // batch. Never partially delete it: restart would lose the plan. + // Recovery restores orphan reservations if no batch committed, + // otherwise the complete Prepared operation remains resumable. + return Err(RegularTransferError::Journal(error)); + } + let mut parent = plan + .journals + .iter() + .find(|entry| entry.operation.is_transfer_receipt()) + .cloned() + .expect("operation plan includes its durable receipt"); + let memo = plan.memo.take().expect("plan memo consumed once"); + if handoff(memo).await.is_err() { + self.reject_operation(&mut parent, &plan.journals).await?; + return Err(RegularTransferError::HandoffRejected); + } + if let Err(error) = self + .finish_accepted(&mut parent, &plan.journals, plan.chain) + .await + { + error!(%error, "accepted CASH operation remains journaled for recovery"); + } + Ok(()) + } + + pub async fn operation_wal( + &self, + operation_id: &str, + ) -> Result, RegularTransferError> { + self.wal + .load_operation(operation_id) + .await + .map_err(RegularTransferError::Journal) + } + + /// Resumes only existing allocations, never selects or debits new inputs. + /// Invoke after recovery and renewed user approval, never from passive + /// reconciliation. For Prepared receipts, `handoff` must resolve/replay the + /// SAME host transport operation, since acceptance may precede a crash. + /// Accepted/Completed receipts never invoke it. Known checkpoints are not + /// broadcast again; recovery must first prove their mortality or fork. + pub async fn resume_operation( + self: &Arc, + operation_id: &str, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + let _execution = self.execution_lock.lock().await; + self.resume_entries(self.operation_wal(operation_id).await?, handoff) + .await + } + + async fn resume_entries( + &self, + entries: Vec, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + let mut parent = entries + .iter() + .find(|entry| entry.operation.is_transfer_receipt()) + .cloned() + .ok_or(RegularTransferError::OperationNotFound)?; + match parent.operation { + WalOperation::TransferCompleted => return Ok(()), + WalOperation::TransferRejected => { + self.reject_operation(&mut parent, &entries).await?; + return Err(RegularTransferError::HandoffRejected); + } + WalOperation::TransferPrepared => { + let coins = parent + .payload + .output_coins + .iter() + .map(referenced_coin) + .collect::>(); + let amount = coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(self.denominations.value_in_planks(coin.exponent)) + }); + let memo = self.build_memo(&coins, amount)?; + if handoff(memo).await.is_err() { + self.reject_operation(&mut parent, &entries).await?; + return Err(RegularTransferError::HandoffRejected); + } + } + WalOperation::TransferAccepted => {} + _ => return Err(RegularTransferError::OperationNotFound), + } + // From here the memo may already be owned by the recipient. + let completion = async { + parent.operation = WalOperation::TransferAccepted; + self.wal.save(&parent).await?; + let chain = self.restore_chain_plan(&entries).await?; + self.finish_accepted(&mut parent, &entries, chain).await + } + .await; + if let Err(error) = completion { + error!(%error, "accepted CASH operation remains journaled for recovery"); + } + Ok(()) + } + + fn transfer_context(&self) -> Arc { + let mut context = TransferContext::new(Arc::clone(&self.coins), Arc::clone(&self.vouchers)); + if let Some(committer) = &self.committer { + context = context.with_committer(Arc::clone(committer)); + } + Arc::new(context) + } + + async fn reject_operation( + &self, + parent: &mut TransferWalEntry, + entries: &[TransferWalEntry], + ) -> Result<(), RegularTransferError> { + parent.operation = WalOperation::TransferRejected; + self.wal + .save(parent) + .await + .map_err(RegularTransferError::Journal)?; + for entry in entries + .iter() + .filter(|entry| !entry.operation.is_transfer_receipt()) + { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Available) + .await + .map_err(RegularTransferError::Reservation)?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state( + input.derivation_index, + crate::model::VoucherLocalState::Available, + ) + .await + .map_err(RegularTransferError::Reservation)?; + } + self.wal + .delete(&entry.entry_id) + .await + .map_err(RegularTransferError::Journal)?; + } + Ok(()) + } + + async fn finish_accepted( + &self, + parent: &mut TransferWalEntry, + entries: &[TransferWalEntry], + chain: ChainPlan, + ) -> Result<(), String> { + parent.operation = WalOperation::TransferAccepted; + self.wal.save(parent).await?; + let context = self.transfer_context(); + for entry in entries + .iter() + .filter(|entry| entry.operation == WalOperation::SecretHandoff) + { + let indices = entry + .payload + .input_coins + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + context.process_outputs(&indices, &[], &[], &[]).await?; + self.wal.delete(&entry.entry_id).await?; + } + self.run_chain_plan(chain, context).await?; + let children_remain = self.wal.load_all().await?.iter().any(|entry| { + entry.entry_id != parent.entry_id + && entry.operation_parent_id().as_deref() == Some(parent.entry_id.as_str()) + }); + if !children_remain { + parent.operation = WalOperation::TransferCompleted; + self.wal.save(parent).await?; + } + Ok(()) + } + + async fn restore_chain_plan(&self, entries: &[TransferWalEntry]) -> Result { + let mut drafts = Vec::new(); + let vouchers = if entries.iter().any(|entry| { + entry.operation == WalOperation::IntoCoins + && entry.checkpoint == CheckpointBlock::Pending + }) { + self.vouchers.list().await? + } else { + Vec::new() + }; + for entry in entries.iter().filter(|entry| { + entry.checkpoint == CheckpointBlock::Pending + && matches!( + entry.operation, + WalOperation::Split | WalOperation::IntoCoins + ) + }) { + let recipient_coins = entry + .payload + .destination_coins + .iter() + .map(referenced_coin) + .collect::>(); + let change_coins = entry + .payload + .output_coins + .iter() + .filter(|coin| !entry.payload.destination_coins.contains(coin)) + .map(referenced_coin) + .collect::>(); + match entry.operation { + WalOperation::Split => { + if entry.payload.input_coins.len() != 1 { + return Err("split journal does not have exactly one input".into()); + } + return Ok(ChainPlan::Split(SplitTransferSubmission { + overflow_coin: referenced_coin(&entry.payload.input_coins[0]), + recipient_coins, + change_coins, + wal_entry_id: entry.entry_id.clone(), + })); + } + WalOperation::IntoCoins => { + let members = entry + .payload + .input_vouchers + .iter() + .map(|reference| { + vouchers + .iter() + .find(|voucher| { + voucher.derivation_index == reference.derivation_index + && voucher.exponent == reference.exponent + }) + .cloned() + .ok_or_else(|| { + "unload journal input is not available locally".to_string() + }) + }) + .collect::, _>>()?; + let first = members.first().ok_or("unload journal has no vouchers")?; + let VoucherRemoteState::InRecycler { recycler_index } = first.remote_state + else { + return Err("unload journal awaits voucher location reconciliation".into()); + }; + let recycler = RecyclerKey { + exponent: first.exponent, + index: recycler_index, + }; + if members.iter().any(|voucher| { + voucher.exponent != recycler.exponent + || voucher.remote_state != first.remote_state + }) { + return Err("unload journal spans multiple recyclers".into()); + } + drafts.push(UnloadGroupDraft { + recycler, + vouchers: members, + recipient_coins, + change_coins, + wal_entry_id: entry.entry_id.clone(), + }); + } + _ => {} + } + } + if drafts.is_empty() { + return Ok(ChainPlan::None); + } + let prepared = self.submitter.prepare_unload_groups(&drafts).await?; + if prepared.len() != drafts.len() + || prepared + .iter() + .zip(&drafts) + .any(|(prepared, draft)| &prepared.draft != draft) + { + return Err("unload recovery changed the durable allocation".into()); + } + Ok(ChainPlan::Unload(prepared)) + } + + async fn create_plan( + &self, + operation_id: &str, + result: CoinSelectionResult, + amount: u128, + ) -> Result { + let mut journals = Vec::new(); + let mut reserved_coins = Vec::new(); + let mut reserved_vouchers = Vec::new(); + let (pass_through, recipient, chain) = match result.strategy { + TransferStrategy::ExactMatch { coins } => { + reserved_coins.extend(coins.iter().map(|coin| coin.derivation_index)); + (coins.clone(), coins, ChainPlan::None) + } + TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } => { + let recipient = self.allocate_coins(&target_denominations).await?; + let change = self.allocate_coins(&change_denominations).await?; + reserved_coins.extend(partial_coins.iter().map(|coin| coin.derivation_index)); + reserved_coins.push(split_coin.derivation_index); + let wal_entry_id = operation_entry_id(operation_id, "split"); + let submission = SplitTransferSubmission { + overflow_coin: split_coin.clone(), + recipient_coins: recipient.clone(), + change_coins: change.clone(), + wal_entry_id: wal_entry_id.clone(), + }; + journals.push(chain_journal( + wal_entry_id, + WalOperation::Split, + &[split_coin], + &[], + &recipient, + &change, + self.clock.now_ms(), + )); + let mut memo_coins = partial_coins.clone(); + memo_coins.extend(recipient); + (partial_coins, memo_coins, ChainPlan::Split(submission)) + } + TransferStrategy::UnloadIntoCoins { coins, groups } => { + reserved_coins.extend(coins.iter().map(|coin| coin.derivation_index)); + let mut drafts = Vec::with_capacity(groups.len()); + let mut memo_coins = coins.clone(); + for (group_index, group) in groups.into_iter().enumerate() { + let recipient = self.allocate_coins(&group.recipient_denominations).await?; + let change = self.allocate_coins(&group.change_denominations).await?; + reserved_vouchers.extend( + group + .vouchers + .iter() + .map(|voucher| voucher.derivation_index), + ); + memo_coins.extend(recipient.clone()); + drafts.push(UnloadGroupDraft { + recycler: group.recycler, + vouchers: group.vouchers, + recipient_coins: recipient, + change_coins: change, + wal_entry_id: operation_entry_id( + operation_id, + &format!("unload-{group_index}"), + ), + }); + } + let prepared = self + .submitter + .prepare_unload_groups(&drafts) + .await + .map_err(|error| RegularTransferError::Planning(error))?; + if prepared.len() != drafts.len() + || prepared + .iter() + .zip(&drafts) + .any(|(prepared, draft)| &prepared.draft != draft) + { + return Err(RegularTransferError::Planning( + "unload preparation did not preserve every recycler group".into(), + )); + } + for group in &prepared { + journals.push(chain_journal( + group.draft.wal_entry_id.clone(), + WalOperation::IntoCoins, + &[], + &group.draft.vouchers, + &group.draft.recipient_coins, + &group.draft.change_coins, + self.clock.now_ms(), + )); + } + (coins, memo_coins, ChainPlan::Unload(prepared)) + } + }; + + let memo = self.build_memo(&recipient, amount)?; + if !pass_through.is_empty() { + let id = operation_entry_id(operation_id, "handoff"); + journals.push(TransferWalEntry { + entry_id: id.clone(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: coin_refs(&pass_through), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }); + } + journals.push(TransferWalEntry { + entry_id: operation_entry_id(operation_id, "parent"), + operation: WalOperation::TransferPrepared, + payload: WalPayload { + output_coins: coin_refs(&recipient), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }); + Ok(TransferPlan { + memo: Some(memo), + reserved_coins, + reserved_vouchers, + journals, + chain, + }) + } + + async fn allocate_coins( + &self, + denominations: &[crate::denomination::Denomination], + ) -> Result, RegularTransferError> { + let mut coins = Vec::with_capacity(denominations.len()); + for denomination in denominations { + coins.push( + self.allocator + .allocate(denomination.exponent) + .await + .map_err(RegularTransferError::Planning)?, + ); + } + Ok(coins) + } + + fn build_memo( + &self, + coins: &[Coin], + expected: u128, + ) -> Result { + let total = coins.iter().fold(0u128, |total, coin| { + total.saturating_add(self.denominations.value_in_planks(coin.exponent)) + }); + if total != expected { + return Err(RegularTransferError::Planning(format!( + "memo value {total} does not equal selected amount {expected}" + ))); + } + let entries = coins + .iter() + .map(|coin| { + self.key_factory + .secret_bytes(coin.derivation_index) + .map(MemoEntry) + .map_err(RegularTransferError::Planning) + }) + .collect::, _>>()?; + Ok(TransferMemo { + entries, + total_value: total, + }) + } + + async fn run_chain_plan( + &self, + chain: ChainPlan, + context: Arc, + ) -> Result<(), String> { + match chain { + ChainPlan::None => Ok(()), + ChainPlan::Split(submission) => match self.submitter.submit_split(&submission).await { + Ok(()) => { + context + .process_outputs( + &[submission.overflow_coin.derivation_index], + &[], + &submission.change_coins, + &submission.recipient_coins, + ) + .await?; + self.wal.delete(&submission.wal_entry_id).await + } + // Even a pre-broadcast failure must retain the fixed outputs: + // their secrets already belong to the recipient. + Err(error) => Err(error), + }, + ChainPlan::Unload(groups) => { + let outcomes = join_all(groups.into_iter().map(|group| { + let submitter = Arc::clone(&self.submitter); + async move { + let result = submitter.submit_unload_group(&group).await; + (group, result) + } + })) + .await; + let mut errors = Vec::new(); + for (group, outcome) in outcomes { + let voucher_ids = group + .draft + .vouchers + .iter() + .map(|voucher| voucher.derivation_index) + .collect::>(); + match outcome { + Ok(()) => { + if let Err(error) = context + .process_outputs( + &[], + &voucher_ids, + &group.draft.change_coins, + &group.draft.recipient_coins, + ) + .await + { + errors.push(error); + continue; + } + if let Err(error) = self.wal.delete(&group.draft.wal_entry_id).await { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + } + } +} + +struct TransferPlan { + memo: Option, + reserved_coins: Vec, + reserved_vouchers: Vec, + journals: Vec, + chain: ChainPlan, +} + +enum ChainPlan { + None, + Split(SplitTransferSubmission), + Unload(Vec), +} + +fn random_id(prefix: &str) -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("{prefix}-{}", hex::encode(bytes)) +} + +fn coin_refs(coins: &[Coin]) -> Vec { + coins + .iter() + .map(|coin| WalCoinRef { + derivation_index: coin.derivation_index, + exponent: coin.exponent, + }) + .collect() +} + +fn voucher_refs(vouchers: &[Voucher]) -> Vec { + vouchers + .iter() + .map(|voucher| WalCoinRef { + derivation_index: voucher.derivation_index, + exponent: voucher.exponent, + }) + .collect() +} + +fn referenced_coin(reference: &WalCoinRef) -> Coin { + Coin { + derivation_index: reference.derivation_index, + exponent: reference.exponent, + age: None, + state: CoinState::PendingTransfer, + } +} + +fn chain_journal( + entry_id: String, + operation: WalOperation, + input_coins: &[Coin], + input_vouchers: &[Voucher], + destination: &[Coin], + change: &[Coin], + created_at_ms: i64, +) -> TransferWalEntry { + let mut output_coins = coin_refs(destination); + output_coins.extend(coin_refs(change)); + TransferWalEntry { + entry_id, + operation, + payload: WalPayload { + input_coins: coin_refs(input_coins), + input_vouchers: voucher_refs(input_vouchers), + output_coins, + output_vouchers: Vec::new(), + destination_coins: coin_refs(destination), + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms, + } +} + +fn selection_value(result: &CoinSelectionResult, context: &DenominationBreakdownContext) -> u128 { + match &result.strategy { + TransferStrategy::ExactMatch { coins } => coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }), + TransferStrategy::Split { + partial_coins, + target_denominations, + .. + } => partial_coins + .iter() + .fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }) + .saturating_add(context.total_value(target_denominations)), + TransferStrategy::UnloadIntoCoins { coins, groups } => groups.iter().fold( + coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }), + |sum, group| sum.saturating_add(context.total_value(&group.recipient_denominations)), + ), + } +} + +fn selection_max_debit( + result: &CoinSelectionResult, + context: &DenominationBreakdownContext, +) -> u128 { + match &result.strategy { + TransferStrategy::UnloadIntoCoins { coins, groups } => coins + .iter() + .map(|coin| coin.exponent) + .chain( + groups + .iter() + .flat_map(|group| group.vouchers.iter().map(|voucher| voucher.exponent)), + ) + .fold(0u128, |total, exponent| { + total.saturating_add(context.value_in_planks(exponent)) + }), + // Whole coins and splits retain their planned change exactly. + _ => selection_value(result, context), + } +} + +fn non_degraded_selection( + full: &CoinSelectionResult, + context: &DenominationBreakdownContext, + now_ms: i64, +) -> (Option, u128) { + if full.privacy_level == PrivacyLevel::Full { + return (Some(full.clone()), selection_value(full, context)); + } + let TransferStrategy::UnloadIntoCoins { coins, groups } = &full.strategy else { + return (Some(full.clone()), selection_value(full, context)); + }; + let groups = groups + .iter() + .filter(|group| { + group + .vouchers + .iter() + .all(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Full) + }) + .cloned() + .collect::>(); + let result = CoinSelectionResult { + strategy: if groups.is_empty() { + TransferStrategy::ExactMatch { + coins: coins.clone(), + } + } else { + TransferStrategy::UnloadIntoCoins { + coins: coins.clone(), + groups, + } + }, + privacy_level: PrivacyLevel::Full, + }; + let amount = selection_value(&result, context); + ((amount > 0).then_some(result), amount) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + use crate::balance::compute_balance; + use crate::clock::FixedClock; + use crate::index_store::InMemoryCoinageIndexStore; + use crate::model::{CoinState, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + #[derive(Default)] + struct MemWal(Mutex>); + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.0.lock(); + entries.retain(|existing| existing.entry_id != entry.entry_id); + entries.push(entry.clone()); + Ok(()) + } + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + let mut stored = self.0.lock(); + stored.retain(|existing| { + !entries + .iter() + .any(|entry| entry.entry_id == existing.entry_id) + }); + stored.extend_from_slice(entries); + Ok(()) + } + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.0.lock(); + entries + .iter_mut() + .find(|entry| entry.entry_id == entry_id) + .ok_or_else(|| "missing WAL".to_string())? + .checkpoint = checkpoint; + Ok(()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.0.lock().clone()) + } + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.0.lock().retain(|entry| entry.entry_id != entry_id); + Ok(()) + } + } + + struct MockSubmitter { + wal: Arc, + fail_unload: HashSet, + } + + #[async_trait] + impl RegularTransferSubmitter for MockSubmitter { + async fn prepare_unload_groups( + &self, + groups: &[UnloadGroupDraft], + ) -> Result, String> { + Ok(groups + .iter() + .cloned() + .map(|draft| PreparedUnloadGroup { + recycler_revision: 1, + readiness_block_hash: [7; 32], + origin: UnloadOriginPreparation { + recycler_ring: RingProofParams { + ring_exponent: 9, + ring_index: draft.recycler.index, + ring_revision: 1, + ring_members: vec![[1; 32]], + }, + person_origin: PersonOriginKind::Lite, + people_ring: RingProofParams { + ring_exponent: 9, + ring_index: 2, + ring_revision: 1, + ring_members: vec![[2; 32]], + }, + token: ResolvedUnloadToken { + period: 3, + counter: draft.recycler.index, + }, + }, + draft, + }) + .collect()) + } + + async fn submit_split(&self, submission: &SplitTransferSubmission) -> Result<(), String> { + self.wal + .update_checkpoint( + &submission.wal_entry_id, + CheckpointBlock::Known { + number: 10, + hash: [9; 32], + }, + ) + .await + } + + async fn submit_unload_group( + &self, + submission: &PreparedUnloadGroup, + ) -> Result<(), String> { + self.wal + .update_checkpoint( + &submission.draft.wal_entry_id, + CheckpointBlock::Known { + number: 10, + hash: [9; 32], + }, + ) + .await?; + if self.fail_unload.contains(&submission.draft.recycler.index) { + Err("scripted ambiguous unload failure".into()) + } else { + Ok(()) + } + } + } + + fn context() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 1, + max_exponent: 10, + min_exponent: 0, + precision: 2, + } + } + + fn voucher(index: u32, exponent: i16, recycler: u32) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + #[test] + fn selector_snapshot_is_aggregate_only_and_uses_value_digit_counts() { + let coins = vec![ + Coin { + exponent: 2, + derivation_index: 99, + age: Some(1), + state: CoinState::Available, + }, + Coin { + exponent: 1, + derivation_index: 100, + age: Some(14), + state: CoinState::Available, + }, + Coin { + exponent: 0, + derivation_index: 101, + age: Some(1), + state: CoinState::PendingTransfer, + }, + ]; + let mut ready = voucher(7, 3, 4); + ready.ready_at_ms = 0; + let mut locked = voucher(8, 1, 4); + locked.local_state = VoucherLocalState::PendingTransfer; + locked.privacy = VoucherPrivacyLevel::Degraded; + let diagnostic = selector_snapshot_diagnostic(&coins, &[ready, locked], &context(), 1_000); + assert_eq!(diagnostic.selectable_coins, 1); + assert_eq!(diagnostic.expiring_coins, 1); + assert_eq!(diagnostic.locked_coins, 2); + assert_eq!(diagnostic.unloadable_vouchers, 1); + assert_eq!(diagnostic.locked_vouchers, 1); + assert_eq!(diagnostic.full_vouchers, 1); + assert_eq!(diagnostic.degraded_vouchers, 1); + assert_eq!(diagnostic.selectable_coin_value_digits, 1); // value 4 + assert_eq!(diagnostic.unloadable_voucher_value_digits, 1); // value 8 + } + + fn service( + vouchers: Vec, + fail_unload: HashSet, + ) -> ( + Arc, + Arc, + Arc, + Arc, + ) { + service_with_context(vouchers, fail_unload, context()) + } + + fn service_with_context( + vouchers: Vec, + fail_unload: HashSet, + denominations: DenominationBreakdownContext, + ) -> ( + Arc, + Arc, + Arc, + Arc, + ) { + let coins = Arc::new(InMemoryCoinRepository::default()); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers(vouchers)); + let wal = Arc::new(MemWal::default()); + let submitter = Arc::new(MockSubmitter { + wal: Arc::clone(&wal), + fail_unload, + }); + let service = Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers: Arc::clone(&vouchers) as Arc<_>, + wal: Arc::clone(&wal) as Arc<_>, + allocator: Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + submitter, + denominations, + max_consolidation: 100, + clock: Arc::new(FixedClock(1_000)), + session_generation: 4, + committer: None, + }, + )); + (service, coins, vouchers, wal) + } + + /// Native amounts stay in CASH cents while the selector stays in raw + /// asset planks. This regression covers real-scale CASH amounts and + /// proves the 10,000x boundary is applied before all three selector + /// strategies run. + #[tokio::test] + async fn native_cash_amounts_preview_at_six_decimal_asset_precision() { + let denominations = DenominationBreakdownContext { + asset_unit: 10_000, + max_exponent: 14, + min_exponent: 0, + precision: 6, + }; + // 2^14 + 2^11 + 2^10 + 2^9 + 2^5 = 20,000 cents = 200 CASH. + let inventory = vec![ + voucher(1, 14, 1), + voucher(2, 11, 2), + voucher(3, 10, 3), + voucher(4, 9, 4), + voucher(5, 5, 5), + ]; + let (service, _, _, _) = service_with_context(inventory, HashSet::new(), denominations); + + for (cash, cents, expected_planks) in [ + (1, 100, 1_000_000), + (10, 1_000, 10_000_000), + (100, 10_000, 100_000_000), + (200, 20_000, 200_000_000), + ] { + let planks = service.cash_cents_to_planks(cents).unwrap(); + assert_eq!(planks, expected_planks, "{cash} CASH conversion"); + let preview = service.preview(planks).await.unwrap(); + assert_eq!(preview.full_amount, expected_planks, "{cash} CASH preview"); + assert_eq!( + service.cash_cents_from_planks(preview.full_amount), + Some(cents), + "{cash} CASH projection" + ); + } + } + + #[tokio::test] + async fn six_voucher_balance_previews_and_confirms_one_cash() { + let inventory = vec![ + voucher(1, 10, 1), // 1024 + voucher(2, 4, 2), // 16 + voucher(3, 4, 3), // 16 + voucher(4, 3, 4), // 8 + voucher(5, 1, 5), // 2 + voucher(6, 1, 6), // 2 + ]; + assert_eq!( + compute_balance(&[], &inventory, &context(), 1_000).total_planks(), + 1_068 + ); + let (service, _coins, vouchers, wal) = service(inventory, HashSet::new()); + let preview = service.preview(100).await.unwrap(); + assert_eq!(preview.strategy, TransferPreviewStrategy::UnloadIntoCoins); + assert_eq!(preview.full_amount, 100); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |memo| async move { + assert_eq!(memo.total_value, 100); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(vouchers.list().await.unwrap().len(), 5); + assert_eq!( + wal.load_operation(&preview.preview_id).await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } + + #[tokio::test] + async fn split_persists_change_retires_destination_and_preserves_receipt() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([Coin { + exponent: 3, + derivation_index: 100, + age: Some(1), + state: crate::CoinState::Available, + }])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let wal = Arc::new(MemWal::default()); + let service = Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers, + wal: Arc::clone(&wal) as Arc<_>, + allocator: Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + submitter: Arc::new(MockSubmitter { + wal: Arc::clone(&wal), + fail_unload: HashSet::new(), + }), + denominations: context(), + max_consolidation: 100, + clock: Arc::new(FixedClock(1_000)), + session_generation: 4, + committer: None, + }, + )); + let preview = service.preview(3).await.unwrap(); + assert_eq!(preview.strategy, TransferPreviewStrategy::Split); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |memo| async move { + assert_eq!(memo.total_value, 3); + Ok(()) + }, + ) + .await + .unwrap(); + let rows = coins.list().await.unwrap(); + assert_eq!( + rows.iter() + .find(|coin| coin.derivation_index == 100) + .unwrap() + .state, + crate::CoinState::Spent + ); + assert!( + rows.iter() + .any(|coin| coin.state == crate::CoinState::Spent && coin.derivation_index != 100) + ); + assert!( + rows.iter() + .any(|coin| coin.state == crate::CoinState::Available) + ); + assert_eq!( + wal.load_operation(&preview.preview_id).await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } + + #[tokio::test] + async fn multi_group_unload_commits_success_and_keeps_ambiguous_group_reserved() { + let (service, _, vouchers, wal) = + service(vec![voucher(1, 1, 1), voucher(2, 0, 2)], HashSet::from([2])); + let preview = service.preview(3).await.unwrap(); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Ok(()) }, + ) + .await + .unwrap(); + let remaining = vouchers.list().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].derivation_index, 2); + assert_eq!(remaining[0].local_state, VoucherLocalState::PendingTransfer); + let entries = wal.load_operation(&preview.preview_id).await.unwrap(); + assert!( + entries + .iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + let unresolved = entries + .iter() + .find(|entry| entry.operation == WalOperation::IntoCoins) + .unwrap(); + assert_eq!(unresolved.payload.input_vouchers[0].derivation_index, 2); + assert!(matches!( + unresolved.checkpoint, + CheckpointBlock::Known { .. } + )); + } + + #[test] + fn spendable_voucher_snapshot_never_returns_false_empty_wallet() { + let inventory = vec![ + voucher(1, 10, 1), + voucher(2, 4, 2), + voucher(3, 4, 3), + voucher(4, 3, 4), + voucher(5, 1, 5), + voucher(6, 1, 6), + ]; + let selector = CoinSelector::new(context(), 100); + for amount in 1..=1_068 { + assert_ne!( + selector.select(amount, &[], &inventory, 1_000), + Err(CoinSelectionError::EmptyWallet), + "false empty wallet at amount {amount}" + ); + } + } + + /// Property sweep for the opaque boundary: confirmation must execute the + /// exact amount retained by preview, across every representable amount in + /// the source voucher. It must never reselect a different total. + #[tokio::test] + async fn preview_to_confirm_preserves_every_representable_amount() { + for amount in 1..=64_u128 { + let (service, _, _, _) = service(vec![voucher(1, 6, 1)], HashSet::new()); + let preview = service.preview(amount).await.unwrap(); + assert_eq!(preview.full_amount, amount); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + assert_eq!(memo.total_value, amount); + Ok(()) + }, + ) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn preview_rejects_a_changed_repository_snapshot() { + let inventory = vec![voucher(1, 10, 1)]; + let (service, _coins, vouchers, _) = service(inventory, HashSet::new()); + let preview = service.preview(100).await.unwrap(); + vouchers + .set_local_state(1, VoucherLocalState::PendingTransfer) + .await + .unwrap(); + assert_eq!( + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Ok(()) } + ) + .await, + Err(RegularTransferError::BalanceChanged) + ); + } + + #[tokio::test] + async fn same_preview_confirmations_are_coalesced() { + let (service, _, _, _) = service(vec![voucher(1, 10, 1)], HashSet::new()); + let preview = service.preview(100).await.unwrap(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first = { + let service = Arc::clone(&service); + let calls = Arc::clone(&calls); + let id = preview.preview_id.clone(); + tokio::spawn(async move { + service + .confirm(&id, TransferPreviewChoice::Full, move |_| async move { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tokio::task::yield_now().await; + Ok(()) + }) + .await + }) + }; + let second = service.confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { panic!("coalesced confirmation must not receive the memo") }, + ); + assert!(first.await.unwrap().is_ok()); + assert!(second.await.is_ok()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[test] + fn diagnostics_contain_only_public_selection_state() { + let fixture = [ + voucher(7, 10, 9), + voucher(8, 4, 9), + voucher(9, 4, 9), + voucher(10, 3, 9), + voucher(11, 1, 9), + voucher(12, 1, 9), + ]; + let rows = voucher_selection_diagnostics(&fixture, 0); + assert_eq!(rows.len(), 6); + assert_eq!(rows[0].derivation_index, 7); + assert_eq!( + rows[0].remote_state, + VoucherRemoteState::InRecycler { recycler_index: 9 } + ); + assert_eq!(rows[0].effective_privacy, EffectivePrivacy::Full); + } + + fn restart(service: &RegularCoinTransferService) -> Arc { + Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&service.coins), + vouchers: Arc::clone(&service.vouchers), + wal: Arc::clone(&service.wal), + allocator: Arc::clone(&service.allocator), + submitter: Arc::clone(&service.submitter), + denominations: service.denominations.clone(), + max_consolidation: service.max_consolidation, + clock: Arc::clone(&service.clock), + session_generation: service.session_generation + 1, + committer: service.committer.clone(), + }, + )) + } + + #[tokio::test] + async fn restart_after_transport_acceptance_replays_same_allocations_once() { + let (service, coins, vouchers, wal) = service(vec![voucher(1, 3, 1)], HashSet::new()); + let preview = service.preview(3).await.unwrap(); + assert_eq!(preview.max_debit_amount, 8); + let (accepted, received) = tokio::sync::oneshot::channel(); + let transfer = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .confirm_operation( + "host:payment-1", + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + accepted.send(memo.identifier()).unwrap(); + // Transport durably accepted but its acknowledgement + // has not returned when the process disappears. + std::future::pending::>().await + }, + ) + .await + }) + }; + let accepted_identifier = received.await.unwrap(); + let before = wal.load_operation("host:payment-1").await.unwrap(); + assert!( + before + .iter() + .any(|entry| entry.operation == WalOperation::TransferPrepared) + ); + assert!( + before + .iter() + .any(|entry| entry.operation == WalOperation::IntoCoins) + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::PendingTransfer + ); + transfer.abort(); + let _ = transfer.await; + let restarted = restart(&service); + restarted + .resume_operation("host:payment-1", move |memo| async move { + assert_eq!(memo.identifier(), accepted_identifier); + assert_eq!(memo.total_value, 3); + Ok(()) + }) + .await + .unwrap(); + assert!(vouchers.list().await.unwrap().is_empty()); + let after = coins.list().await.unwrap(); + assert_eq!( + after + .iter() + .filter(|coin| coin.state == CoinState::Available) + .map(|coin| context().value_in_planks(coin.exponent)) + .sum::(), + 5 + ); + let receipt = wal.load_operation("host:payment-1").await.unwrap(); + assert_eq!(receipt.len(), 1); + assert_eq!(receipt[0].operation, WalOperation::TransferCompleted); + assert_eq!( + receipt[0].payload.output_coins, + before + .iter() + .find(|entry| entry.operation == WalOperation::TransferPrepared) + .unwrap() + .payload + .output_coins + ); + restarted + .confirm_operation( + "host:payment-1", + "no-preview-after-restart", + TransferPreviewChoice::Full, + |_| async { panic!("completed operation must not hand off or debit again") }, + ) + .await + .unwrap(); + assert_eq!(coins.list().await.unwrap(), after); + } + + #[tokio::test] + async fn rejected_operation_keeps_identity_but_releases_only_its_inputs() { + let (service, _, vouchers, wal) = service(vec![voucher(1, 3, 1)], HashSet::new()); + let preview = service.preview(3).await.unwrap(); + assert_eq!( + service + .confirm_operation( + "rejected", + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Err(()) } + ) + .await, + Err(RegularTransferError::HandoffRejected) + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + let restarted = restart(&service); + assert_eq!( + restarted + .resume_operation("rejected", |_| async { + panic!("rejected operation identity must not start a new debit") + }) + .await, + Err(RegularTransferError::HandoffRejected) + ); + let entries = wal.load_operation("rejected").await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].operation, WalOperation::TransferRejected); + } + + #[tokio::test] + async fn exact_operation_preserves_receipt_without_reexporting_coin_secrets() { + let (service, coins, _, wal) = service(Vec::new(), HashSet::new()); + coins + .upsert(&Coin { + derivation_index: 90, + exponent: 2, + age: Some(1), + state: CoinState::Available, + }) + .await + .unwrap(); + let preview = service.preview(4).await.unwrap(); + let observed = Arc::clone(&wal); + service + .confirm_operation( + "exact", + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + let entries = observed.load_operation("exact").await.unwrap(); + let handoff = entries + .iter() + .find(|entry| entry.operation == WalOperation::SecretHandoff) + .unwrap(); + assert_eq!(handoff.payload.input_coins[0].derivation_index, 90); + assert_eq!(memo.total_value, 4); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(coins.list().await.unwrap()[0].state, CoinState::Spent); + restart(&service) + .resume_operation("exact", |_| async { + panic!("accepted exact-coin secret must not be handed out again") + }) + .await + .unwrap(); + assert_eq!( + wal.load_operation("exact").await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } +} diff --git a/rust/crates/truapi-coinage/src/tx_extensions.rs b/rust/crates/truapi-coinage/src/tx_extensions.rs new file mode 100644 index 000000000..017518148 --- /dev/null +++ b/rust/crates/truapi-coinage/src/tx_extensions.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-chain/src/tx_extensions.rs. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Exact SCALE encoding of Coinage transaction-origin extensions. +use parity_scale_codec::{Decode, Encode}; + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AsCoinage(pub Option); + +impl AsCoinage { + pub const IDENTIFIER: &'static str = "AsCoinage"; + + pub const fn as_coin() -> Self { + Self(Some(AsCoinageInfo::AsCoin)) + } + + pub const fn infallible_unpaid_signed(nonce: u32) -> Self { + Self(Some(AsCoinageInfo::InfallibleUnpaidSigned(nonce))) + } + + pub fn unload_token_people( + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + ) -> Self { + Self(Some(AsCoinageInfo::AsUnloadTokenPeople { + proof, + period, + counter, + alias_proofs, + })) + } + + pub fn unload_token_lite_people( + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + ) -> Self { + Self(Some(AsCoinageInfo::AsUnloadTokenLitePeople { + proof, + period, + counter, + alias_proofs, + })) + } +} + +/// The People-ring half of the Coinage unload-token proof +/// (`indiv_pallet_people::types::MembershipProof`). Live metadata declares +/// the proof as a bounded byte vector; `revision` (2026-08 wipe, spec +/// 1000032) is the ring revision the proof was built against. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CoinagePeopleProof { + pub proof: Vec, + pub ring: u32, + pub revision: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum AsCoinageInfo { + /// Dispatch as the sr25519 coin which signed the extrinsic. + #[codec(index = 0)] + AsCoin, + /// Full-person free unload token. + #[codec(index = 1)] + AsUnloadTokenPeople { + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Lite-person free unload token. + #[codec(index = 2)] + AsUnloadTokenLitePeople { + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Paid unload-token ring proof. + #[codec(index = 3)] + AsUnloadTokenPaid { + proof: Vec, + period: u32, + paid_token_ring_index: u32, + paid_token_ring_revision: u32, + alias_proofs: Vec>, + }, + /// A fee-recycler output used as the unload token. `retry_counter` + /// joined in the 2026-08 runtime (spec 1000032). + #[codec(index = 4)] + AsUnloadTokenFromOutput { + fee_recycler_value: i8, + fee_recycler_index: u32, + fee_recycler_revision: u32, + retry_counter: u8, + alias_proofs: Vec>, + }, + #[codec(index = 5)] + InfallibleUnpaidSigned(u32), +} + +#[cfg(test)] +mod tests { + use super::*; + /// `AsCoinage(None)` is the metadata default. Coin-origin calls opt in + /// with `Some(AsCoin)`: option tag 1 followed by enum variant 0. + #[test] + fn as_coinage_as_coin_encoding_is_pinned() { + assert_eq!(AsCoinage(None).encode(), vec![0]); + assert_eq!(AsCoinage::as_coin().encode(), vec![1, 0]); + assert_eq!( + AsCoinage::decode(&mut AsCoinage::as_coin().encode().as_slice()).unwrap(), + AsCoinage::as_coin() + ); + } + + #[test] + fn as_coinage_infallible_unpaid_signed_encoding_is_pinned() { + assert_eq!( + AsCoinage::infallible_unpaid_signed(7).encode(), + vec![1, 5, 7, 0, 0, 0] + ); + assert_eq!( + AsCoinage::decode(&mut AsCoinage::infallible_unpaid_signed(7).encode().as_slice()) + .unwrap(), + AsCoinage::infallible_unpaid_signed(7) + ); + } + + #[test] + fn as_coinage_people_unload_encoding_is_pinned() { + let extension = AsCoinage::unload_token_people( + CoinagePeopleProof { + proof: vec![0xAA, 0xBB], + ring: 7, + revision: 3, + }, + 9, + 2, + vec![vec![0x11], vec![0x22, 0x33]], + ); + // Option::Some, variant 1, compact proof len, proof, ring, revision, + // period, counter, compact alias-proof count and one compact length + // each. + assert_eq!( + extension.encode(), + vec![ + 1, 1, 8, 0xAA, 0xBB, 7, 0, 0, 0, 3, 0, 0, 0, 9, 0, 0, 0, 2, 0, 0, 0, 8, 4, 0x11, 8, + 0x22, 0x33, + ] + ); + assert_eq!( + AsCoinage::decode(&mut extension.encode().as_slice()).unwrap(), + extension + ); + } + + /// `retry_counter` sits between the recycler revision and the alias + /// proofs (2026-08 runtime). + #[test] + fn as_coinage_from_output_encoding_is_pinned() { + let extension = AsCoinage(Some(AsCoinageInfo::AsUnloadTokenFromOutput { + fee_recycler_value: -2, + fee_recycler_index: 5, + fee_recycler_revision: 9, + retry_counter: 4, + alias_proofs: vec![vec![0x77]], + })); + assert_eq!( + extension.encode(), + vec![1, 4, 0xFE, 5, 0, 0, 0, 9, 0, 0, 0, 4, 4, 4, 0x77] + ); + assert_eq!( + AsCoinage::decode(&mut extension.encode().as_slice()).unwrap(), + extension + ); + } +} diff --git a/rust/crates/truapi-coinage/src/voucher_location.rs b/rust/crates/truapi-coinage/src/voucher_location.rs new file mode 100644 index 000000000..cbd6a9dad --- /dev/null +++ b/rust/crates/truapi-coinage/src/voucher_location.rs @@ -0,0 +1,847 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::AbortHandle; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use crate::model::{Voucher, VoucherPrivacyLevel, VoucherRemoteState, ring_readiness_upgraded}; +use crate::repo::VoucherRepository; + +/// A member's on-chain ring position (`Members.Members` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RingPosition { + /// Membership submitted, no ring slot yet. + Onboarding, + /// Assigned to ring `ring_index` at slot `included_at`. + Included { ring_index: u32, included_at: u32 }, +} + +/// A ring's key status (`Members.RingKeysStatus` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RingStatus { + /// How many member keys the ring has actually included. + pub included_members: u32, +} + +/// One partial emission from the batched subscription. Both vectors are +/// keyed by voucher derivation index; a `None` position means the member +/// entry disappeared. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct VoucherLocationUpdate { + pub ring_positions: Vec<(u32, Option)>, + pub ring_statuses: Vec<(u32, RingStatus)>, +} + +/// The chain-subscription effect: ONE batched storage subscription +/// covering the three request families. Calling it again replaces the +/// previous subscription (the returned receiver of the old call simply +/// stops emitting once dropped). +#[async_trait] +pub trait VoucherLocationSubscriber: Send + Sync { + /// `pending` — derivation indices still needing a location (not in a + /// recycler); `included` — accumulated included positions as + /// `(derivation_index, ring_index)`; `degraded` — degraded + /// in-recycler vouchers as `(derivation_index, recycler_index)`. + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String>; +} + +#[derive(Default)] +struct LocationState { + /// The baseline: what the LIVE subscription covers. + subscribed_indices: HashSet, + /// Included positions seen so far (non-included ones are removed). + positions: HashMap, + /// Ring statuses seen so far. + statuses: HashMap, +} + +/// The service. `sync` (re)builds the subscription from current local +/// vouchers; call it at startup and after voucher saves. `stop` tears +/// the subscription down. +pub struct VoucherLocationService { + spawner: crate::Spawner, + vouchers: Arc, + chain: Arc, + state: Mutex, + task: Mutex>, +} + +impl VoucherLocationService { + pub fn new( + vouchers: Arc, + chain: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + vouchers, + chain, + state: Mutex::new(LocationState::default()), + spawner, + task: Mutex::new(None), + } + } + + /// (Re)builds the batched subscription: prune accumulated state to + /// subscribe the three families. With nothing to watch, the live + /// subscription is torn down. + pub async fn sync(self: &Arc) -> Result<(), String> { + let vouchers = self.vouchers.list().await?; + let pending: Vec = vouchers + .iter() + .filter(|v| !v.remote_state.is_in_recycler()) + .map(|v| v.derivation_index) + .collect(); + let degraded: Vec<(u32, u32)> = vouchers + .iter() + .filter_map(|v| match v.remote_state { + VoucherRemoteState::InRecycler { recycler_index } + if v.privacy == VoucherPrivacyLevel::Degraded => + { + Some((v.derivation_index, recycler_index)) + } + _ => None, + }) + .collect(); + + let included: Vec<(u32, u32)> = { + let mut state = self.state.lock(); + let current: HashSet = pending.iter().copied().collect(); + state.positions.retain(|index, _| current.contains(index)); + state.statuses.retain(|index, _| current.contains(index)); + // The baseline reflects exactly what this batch subscribes. + state.subscribed_indices = state.positions.keys().copied().collect(); + state + .positions + .iter() + .filter_map(|(index, position)| match position { + RingPosition::Included { ring_index, .. } => Some((*index, *ring_index)), + RingPosition::Onboarding => None, + }) + .collect() + }; + + if pending.is_empty() && degraded.is_empty() { + self.stop(); + return Ok(()); + } + + let receiver = self.chain.subscribe(pending, included, degraded).await?; + self.replace_task(receiver); + Ok(()) + } + + /// Tears down the live subscription and resets accumulated state. + pub fn stop(&self) { + if let Some(handle) = self.task.lock().take() { + handle.abort(); + } + *self.state.lock() = LocationState::default(); + } + + fn replace_task(self: &Arc, mut receiver: mpsc::Receiver) { + let service = Arc::clone(self); + let handle = crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(update) = receiver.recv().await { + match service.handle_update(update).await { + Ok(true) => { + let service = Arc::clone(&service); + crate::tasks::spawn_abortable(&service.spawner.clone(), async move { + if let Err(error) = service.sync().await { + warn!(error, "voucher location resubscription failed"); + } + }); + return; + } + Ok(false) => {} + Err(error) => warn!(error, "voucher location update failed"), + } + } + }); + let previous = self.task.lock().replace(handle); + if let Some(previous) = previous { + previous.abort(); + } + } + + /// One emission. Resolves `true` when a resubscription is needed + /// (and the emission was deliberately not applied). + async fn handle_update(&self, update: VoucherLocationUpdate) -> Result { + let requires_resubscription = { + let mut state = self.state.lock(); + for (index, position) in &update.ring_positions { + match position { + Some(position @ RingPosition::Included { .. }) => { + state.positions.insert(*index, *position); + } + _ => { + state.positions.remove(index); + } + } + } + for (index, status) in &update.ring_statuses { + state.statuses.insert(*index, *status); + } + let accumulated: HashSet = state.positions.keys().copied().collect(); + accumulated != state.subscribed_indices + }; + + let voucher_map: HashMap = self + .vouchers + .list() + .await? + .into_iter() + .map(|v| (v.derivation_index, v)) + .collect(); + let mut updates: HashMap = HashMap::new(); + + let (positions, statuses) = { + let state = self.state.lock(); + (state.positions.clone(), state.statuses.clone()) + }; + let waiting_for_status = positions + .keys() + .filter(|index| !statuses.contains_key(index)) + .count(); + let waiting_for_coverage = positions + .iter() + .filter(|(index, position)| { + let RingPosition::Included { included_at, .. } = position else { + return false; + }; + statuses + .get(index) + .is_some_and(|status| status.included_members <= *included_at) + }) + .count(); + if requires_resubscription { + log_reconciliation_summary( + &voucher_map, + positions.len(), + waiting_for_status, + waiting_for_coverage, + 0, + 0, + true, + ); + return Ok(true); + } + + // Pass A — positions from THIS emission, statuses possibly + // accumulated earlier. + for (index, position) in &update.ring_positions { + let Some(voucher) = updates + .get(index) + .cloned() + .or_else(|| voucher_map.get(index).cloned()) + else { + continue; + }; + match position { + Some(RingPosition::Included { + ring_index, + included_at, + }) => { + // Deferred until the ring status covers the slot. + let Some(status) = statuses.get(index) else { + continue; + }; + if status.included_members > *included_at { + updates.insert( + *index, + committed(voucher, *ring_index, status.included_members), + ); + } + } + // Present but not included (or gone) → onboarding. + _ => { + let mut voucher = voucher; + voucher.remote_state = VoucherRemoteState::Onboarding; + updates.insert(*index, voucher); + } + } + } + + // Pass B — statuses from THIS emission, positions possibly + // accumulated earlier; also upgrades degraded in-recycler + for (index, status) in &update.ring_statuses { + let Some(voucher) = updates + .get(index) + .cloned() + .or_else(|| voucher_map.get(index).cloned()) + else { + continue; + }; + match (voucher.remote_state, positions.get(index)) { + // Pending voucher whose position accumulated earlier. + ( + _, + Some(RingPosition::Included { + ring_index, + included_at, + }), + ) if !voucher.remote_state.is_in_recycler() => { + if status.included_members > *included_at { + updates.insert( + *index, + committed(voucher, *ring_index, status.included_members), + ); + } + } + // Degraded in-recycler voucher: readiness upgrade only. + (VoucherRemoteState::InRecycler { .. }, _) + if voucher.privacy == VoucherPrivacyLevel::Degraded + && ring_readiness_upgraded(status.included_members) => + { + let mut voucher = voucher; + voucher.privacy = VoucherPrivacyLevel::Full; + updates.insert(*index, voucher); + } + _ => {} + } + } + + let mut location_updates = 0usize; + let mut privacy_updates = 0usize; + for (index, voucher) in &updates { + // Only remote_state/privacy changed above; skip no-ops so a + // repeated emission does not rewrite rows. + let Some(previous) = voucher_map.get(index) else { + continue; + }; + if previous == voucher { + continue; + } + if previous.privacy == voucher.privacy { + self.vouchers + .set_remote_state(*index, voucher.remote_state) + .await?; + location_updates += 1; + } else { + // Ring readiness changes privacy as well as location. + self.vouchers.upsert(voucher).await?; + if previous.remote_state != voucher.remote_state { + location_updates += 1; + } + if previous.privacy != voucher.privacy { + privacy_updates += 1; + } + } + } + log_reconciliation_summary( + &voucher_map, + positions.len(), + waiting_for_status, + waiting_for_coverage, + location_updates, + privacy_updates, + false, + ); + Ok(false) + } +} + +fn log_reconciliation_summary( + vouchers: &HashMap, + tracked_positions: usize, + waiting_for_status: usize, + waiting_for_coverage: usize, + location_changes: usize, + privacy_changes: usize, + resubscription_required: bool, +) { + let tracked_total = vouchers.len(); + let in_recycler_total = vouchers + .values() + .filter(|voucher| voucher.remote_state.is_in_recycler()) + .count(); + let pending_location_total = tracked_total.saturating_sub(in_recycler_total); + let degraded_total = vouchers + .values() + .filter(|voucher| voucher.privacy == VoucherPrivacyLevel::Degraded) + .count(); + debug!( + tracked_total, + in_recycler_total, + pending_location_total, + degraded_total, + waiting_for_status, + waiting_for_coverage, + location_changes, + privacy_changes, + tracked_positions, + resubscription_required, + changes_scope = "current_emission", + "coinage voucher location emission reconciled" + ); +} + +fn committed(mut voucher: Voucher, ring_index: u32, included_members: u32) -> Voucher { + voucher.remote_state = VoucherRemoteState::InRecycler { + recycler_index: ring_index, + }; + if voucher.privacy == VoucherPrivacyLevel::Degraded && ring_readiness_upgraded(included_members) + { + voucher.privacy = VoucherPrivacyLevel::Full; + } + voucher +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::balance::compute_balance; + use crate::constants::MINIMUM_RING_SIZE; + use crate::denomination::DenominationBreakdownContext; + use crate::model::VoucherLocalState; + use crate::repo::InMemoryVoucherRepository; + + /// One recorded `subscribe` call's arguments. + type SubscribeCall = (Vec, Vec<(u32, u32)>, Vec<(u32, u32)>); + + /// Scripted subscriber: records each subscribe call's arguments and + /// hands out a fresh channel per call. + #[derive(Default)] + struct MockSubscriber { + calls: Mutex>, + senders: Mutex>>, + } + + impl MockSubscriber { + fn latest_sender(&self) -> mpsc::Sender { + self.senders + .lock() + .last() + .expect("no subscription yet") + .clone() + } + } + + #[async_trait] + impl VoucherLocationSubscriber for MockSubscriber { + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + self.calls.lock().push((pending, included, degraded)); + let (tx, rx) = mpsc::channel(8); + self.senders.lock().push(tx); + Ok(rx) + } + } + + fn onboarding_voucher(index: u32) -> Voucher { + Voucher { + exponent: 1, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + fn degraded_in_recycler(index: u32, recycler: u32) -> Voucher { + Voucher { + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + privacy: VoucherPrivacyLevel::Degraded, + ..onboarding_voucher(index) + } + } + + fn service( + vouchers: Arc, + ) -> (Arc, Arc) { + let subscriber = Arc::new(MockSubscriber::default()); + ( + Arc::new(VoucherLocationService::new( + vouchers, + Arc::clone(&subscriber) as Arc, + crate::test_spawner(), + )), + subscriber, + ) + } + + async fn voucher_state(repo: &InMemoryVoucherRepository, index: u32) -> Voucher { + repo.list() + .await + .unwrap() + .into_iter() + .find(|v| v.derivation_index == index) + .unwrap() + } + + async fn settle(mut probe: impl AsyncFnMut() -> Option) -> T { + for _ in 0..1_000 { + if let Some(value) = probe().await { + return value; + } + tokio::task::yield_now().await; + } + panic!("never settled"); + } + + #[tokio::test] + async fn two_phase_accumulation_commits_only_when_both_halves_agree() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(1), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + assert_eq!(subscriber.calls.lock().len(), 1); + assert_eq!( + subscriber.calls.lock()[0].0, + vec![1], + "pending voucher subscribed" + ); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 1, + Some(RingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )], + ring_statuses: vec![], + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + assert_eq!( + subscriber.calls.lock()[1].1, + vec![(1, 4)], + "resubscription carries the accumulated included position" + ); + assert_eq!( + voucher_state(&repo, 1).await.remote_state, + VoucherRemoteState::Onboarding, + "no commit before the ring status arrives" + ); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: 3, + }, + )], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 1).await; + voucher.remote_state.is_in_recycler().then_some(voucher) + }) + .await; + assert_eq!( + committed.remote_state, + VoucherRemoteState::InRecycler { recycler_index: 4 } + ); + } + + /// Position and status arriving in the same emission still forces a + /// resubscription, since the baseline only tracks previously-known + /// included indices; the commit lands once the status is re-delivered + /// on the new subscription. + #[tokio::test] + async fn position_and_status_in_one_emission_commit_after_resync() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(7), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 7, + Some(RingPosition::Included { + ring_index: 2, + included_at: 0, + }), + )], + ring_statuses: vec![( + 7, + RingStatus { + included_members: 1, + }, + )], + }) + .await + .unwrap(); + // The first emission triggers resubscription; re-deliver the + // status on the new batch (position stays accumulated). + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 7, + RingStatus { + included_members: 1, + }, + )], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 7).await; + voucher.remote_state.is_in_recycler().then_some(voucher) + }) + .await; + assert_eq!( + committed.remote_state, + VoucherRemoteState::InRecycler { recycler_index: 2 } + ); + } + + #[tokio::test] + async fn ring_size_upgrade_boundary() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + degraded_in_recycler(1, 9), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + assert_eq!( + subscriber.calls.lock()[0].2, + vec![(1, 9)], + "degraded in-recycler voucher gets a ring-status request" + ); + + // One below the threshold: NOT upgraded. + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: MINIMUM_RING_SIZE - 1, + }, + )], + }) + .await + .unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!( + voucher_state(&repo, 1).await.privacy, + VoucherPrivacyLevel::Degraded + ); + + // Exactly the threshold: upgraded. + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: MINIMUM_RING_SIZE, + }, + )], + }) + .await + .unwrap(); + let upgraded = settle(async || { + let voucher = voucher_state(&repo, 1).await; + (voucher.privacy == VoucherPrivacyLevel::Full).then_some(voucher) + }) + .await; + assert!(upgraded.remote_state.is_in_recycler(), "location untouched"); + } + + #[test] + fn minimum_ring_size_is_ten() { + assert_eq!(MINIMUM_RING_SIZE, 10); + } + + #[tokio::test] + async fn stale_accumulated_state_is_pruned_on_sync() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(1), + onboarding_voucher(2), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 2, + Some(RingPosition::Included { + ring_index: 1, + included_at: 0, + }), + )], + ring_statuses: vec![], + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + + // Voucher 2 disappears locally; the next sync prunes it. + repo.remove(2).await.unwrap(); + service.sync().await.unwrap(); + let calls = subscriber.calls.lock(); + let last = calls.last().unwrap(); + assert_eq!(last.0, vec![1], "only the live voucher is pending"); + assert!(last.1.is_empty(), "stale accumulated position pruned"); + } + + /// A not-included member entry maps the voucher to `Onboarding`, and + /// an all-quiet wallet tears the subscription down. + #[tokio::test] + async fn not_included_position_marks_onboarding() { + let mut unlocated = onboarding_voucher(3); + unlocated.remote_state = VoucherRemoteState::Unlocated; + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([unlocated])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![(3, Some(RingPosition::Onboarding))], + ring_statuses: vec![], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 3).await; + (voucher.remote_state == VoucherRemoteState::Onboarding).then_some(()) + }) + .await; + let () = committed; + + // Nothing left to watch once the voucher graduates fully. + repo.remove(3).await.unwrap(); + service.sync().await.unwrap(); + assert!(service.task.lock().is_none(), "subscription torn down"); + } + + #[tokio::test] + async fn six_vouchers_converge_across_reordered_partial_emissions() { + let recyclers = [2, 2, 3, 4, 4, 5]; + let original = (1u32..=6) + .map(|index| Voucher { + exponent: i16::try_from(index - 1).unwrap(), + derivation_index: index, + allocated_at_ms: 1_000 + i64::from(index), + ready_at_ms: 2_000 + i64::from(index), + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + }) + .collect::>(); + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers(original.clone())); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: (1u32..=6) + .map(|index| { + ( + index, + Some(RingPosition::Included { + ring_index: recyclers[usize::try_from(index - 1).unwrap()], + included_at: index - 1, + }), + ) + }) + .collect(), + ring_statuses: Vec::new(), + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + let mut included = subscriber.calls.lock()[1].1.clone(); + included.sort_unstable(); + assert_eq!( + included, + (1u32..=6) + .map(|index| (index, recyclers[usize::try_from(index - 1).unwrap()])) + .collect::>(), + "the replacement batch covers every newly included voucher" + ); + + for index in [6u32, 2, 5, 1, 4, 3] { + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: Vec::new(), + ring_statuses: vec![( + index, + RingStatus { + included_members: MINIMUM_RING_SIZE, + }, + )], + }) + .await + .unwrap(); + } + + let converged = settle(async || { + let rows = repo.list().await.unwrap(); + rows.iter() + .all(|voucher| voucher.remote_state.is_in_recycler()) + .then_some(rows) + }) + .await; + for (before, after) in original.iter().zip(&converged) { + assert_eq!(after.derivation_index, before.derivation_index); + assert_eq!(after.exponent, before.exponent); + assert_eq!(after.allocated_at_ms, before.allocated_at_ms); + assert_eq!(after.ready_at_ms, before.ready_at_ms); + assert_eq!(after.local_state, before.local_state); + assert_eq!(after.privacy, before.privacy); + assert_eq!( + after.remote_state, + VoucherRemoteState::InRecycler { + recycler_index: recyclers + [usize::try_from(before.derivation_index - 1).unwrap()] + } + ); + } + + let balance = compute_balance( + &[], + &converged, + &DenominationBreakdownContext { + asset_unit: 1, + max_exponent: 10, + min_exponent: 0, + precision: 2, + }, + 10_000, + ); + assert_eq!(balance.full_privacy_planks, 63); + assert_eq!(balance.degraded_planks, 0); + } +} diff --git a/rust/crates/truapi-coinage/src/wal.rs b/rust/crates/truapi-coinage/src/wal.rs new file mode 100644 index 000000000..3e9844f96 --- /dev/null +++ b/rust/crates/truapi-coinage/src/wal.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode, Error as CodecError, Input}; + +use crate::constants::WAL_MORTALITY_BLOCKS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalOperation { + /// Voucher unload into coins — recovery probes the expected output + /// coins on-chain. + IntoCoins, + /// Voucher offboard into an external asset — recovery checks the + /// input vouchers were consumed. + IntoExternalAsset, + /// Coin recycling — recovery checks the input coin was consumed and + /// the surplus voucher appeared. + RecycleIntoVoucher, + /// Whole coin secrets were (or may have been) handed off out of band. + /// Unlike an extrinsic, a memo has no mortal era. Recovery therefore + /// retains its input reservation while any input is still on-chain and + /// retires it only once every input is absent. The live sender deletes + /// this entry only when a handoff reports an explicit pre-acceptance + /// rejection. + SecretHandoff, + /// A regular Coinage split. It has coin inputs and expected coin + /// outputs like `IntoCoins`, but remains distinct so recovery and + /// diagnostics never infer that vouchers were involved. + Split, + /// Durable operation receipt. Prepared does not prove whether the + /// idempotent transport accepted the memo before a process stopped. + TransferPrepared, + TransferAccepted, + /// All outgoing allocations finalized successfully or were observed with + /// exact outputs and consumed inputs during recovery. Pass-through inputs + /// were retired locally. This does not itself prove recipient claim. + TransferCompleted, + /// Transport definitively rejected the memo before accepting it. + TransferRejected, +} + +impl WalOperation { + pub fn as_raw(self) -> i64 { + match self { + WalOperation::IntoCoins => 0, + WalOperation::IntoExternalAsset => 1, + WalOperation::RecycleIntoVoucher => 2, + WalOperation::SecretHandoff => 3, + WalOperation::Split => 4, + WalOperation::TransferPrepared => 5, + WalOperation::TransferAccepted => 6, + WalOperation::TransferCompleted => 7, + WalOperation::TransferRejected => 8, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => WalOperation::IntoCoins, + 1 => WalOperation::IntoExternalAsset, + 2 => WalOperation::RecycleIntoVoucher, + 3 => WalOperation::SecretHandoff, + 4 => WalOperation::Split, + 5 => WalOperation::TransferPrepared, + 6 => WalOperation::TransferAccepted, + 7 => WalOperation::TransferCompleted, + 8 => WalOperation::TransferRejected, + _ => return None, + }) + } + + pub fn is_transfer_receipt(self) -> bool { + matches!( + self, + Self::TransferPrepared + | Self::TransferAccepted + | Self::TransferCompleted + | Self::TransferRejected + ) + } +} + +/// One derived asset referenced from a WAL payload: enough to re-derive +/// its key (index) and materialize it locally (exponent) at recovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct WalCoinRef { + pub derivation_index: u32, + pub exponent: i16, +} + +/// The SCALE payload of a WAL entry (`transfer_wal_entries.payload`): +/// inputs consumed and outputs expected by the journaled extrinsic. +#[derive(Debug, Clone, PartialEq, Eq, Default, Encode)] +pub struct WalPayload { + pub input_coins: Vec, + pub input_vouchers: Vec, + pub output_coins: Vec, + pub output_vouchers: Vec, + /// Subset of `output_coins` delivered in the recipient memo. Recovery + /// materializes these as locally `Spent`, while change remains + /// `Available`. Appending the field preserves the v1 prefix layout. + pub destination_coins: Vec, +} + +impl Decode for WalPayload { + fn decode(input: &mut I) -> Result { + let input_coins = Vec::::decode(input)?; + let input_vouchers = Vec::::decode(input)?; + let output_coins = Vec::::decode(input)?; + let output_vouchers = Vec::::decode(input)?; + // Rows written before destination/change distinction end after the + // fourth vector. Treat that exact legacy shape as all-change. + let destination_coins = match input.remaining_len()? { + Some(0) => Vec::new(), + _ => Vec::::decode(input)?, + }; + Ok(Self { + input_coins, + input_vouchers, + output_coins, + output_vouchers, + destination_coins, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckpointBlock { + Pending, + Known { number: u64, hash: [u8; 32] }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferWalEntry { + pub entry_id: String, + pub operation: WalOperation, + pub payload: WalPayload, + pub checkpoint: CheckpointBlock, + pub created_at_ms: i64, +} + +/// Hex encoding makes the operation/child boundary unambiguous even when a +/// host identifier contains punctuation. All children share this namespace. +pub fn operation_entry_id(operation_id: &str, child: &str) -> String { + format!( + "cash-operation:{}:{child}", + hex::encode(operation_id.as_bytes()) + ) +} + +impl TransferWalEntry { + pub fn belongs_to_operation(&self, operation_id: &str) -> bool { + self.entry_id + .starts_with(&operation_entry_id(operation_id, "")) + } + + /// The durable receipt identifier for any correlated child or receipt. + pub fn operation_parent_id(&self) -> Option { + let suffix = self.entry_id.strip_prefix("cash-operation:")?; + let (operation, child) = suffix.split_once(':')?; + if operation.is_empty() || child.is_empty() { + return None; + } + Some(format!("cash-operation:{operation}:parent")) + } + + pub fn is_expired(&self, finalized_block: u64) -> bool { + match self.checkpoint { + CheckpointBlock::Pending => true, + CheckpointBlock::Known { number, .. } => { + finalized_block > number.saturating_add(WAL_MORTALITY_BLOCKS) + } + } + } + + pub fn is_forked(&self, canonical_hash_at_checkpoint: Option<&[u8; 32]>) -> bool { + match (&self.checkpoint, canonical_hash_at_checkpoint) { + (CheckpointBlock::Known { hash, .. }, Some(canonical)) => hash != canonical, + _ => false, + } + } +} + +/// Durable WAL persistence. `save_all` must commit the whole batch atomically; +/// checkpoint writes must be durable before an adapter broadcasts. +#[async_trait] +pub trait WalStore: Send + Sync { + /// Inserts or durably replaces the entry with the same identifier. + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String>; + + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String>; + + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String>; + + async fn load_all(&self) -> Result, String>; + + /// Includes the permanent parent receipt even after every child settles. + async fn load_operation(&self, operation_id: &str) -> Result, String> { + let prefix = operation_entry_id(operation_id, ""); + Ok(self + .load_all() + .await? + .into_iter() + .filter(|entry| entry.entry_id.starts_with(&prefix)) + .collect()) + } + + async fn delete(&self, entry_id: &str) -> Result<(), String>; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(checkpoint: CheckpointBlock) -> TransferWalEntry { + TransferWalEntry { + entry_id: "e1".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload::default(), + checkpoint, + created_at_ms: 0, + } + } + + #[test] + fn mortality_boundary_is_checkpoint_plus_300() { + let known = entry(CheckpointBlock::Known { + number: 1_000, + hash: [1; 32], + }); + assert!(!known.is_expired(1_300), "at the boundary: still alive"); + assert!(known.is_expired(1_301), "one past the boundary: dead"); + let near_max = entry(CheckpointBlock::Known { + number: u64::MAX - 10, + hash: [1; 32], + }); + assert!(!near_max.is_expired(u64::MAX), "saturating add, no wrap"); + } + + #[test] + fn pending_checkpoint_is_immediately_expired() { + assert!(entry(CheckpointBlock::Pending).is_expired(0)); + } + + #[test] + fn fork_detection_compares_canonical_hash() { + let known = entry(CheckpointBlock::Known { + number: 5, + hash: [1; 32], + }); + assert!(known.is_forked(Some(&[2; 32]))); + assert!(!known.is_forked(Some(&[1; 32]))); + assert!(!known.is_forked(None), "unavailable hash is not a fork"); + assert!(!entry(CheckpointBlock::Pending).is_forked(Some(&[2; 32]))); + } + + #[test] + fn operation_raw_round_trips() { + for op in [ + WalOperation::IntoCoins, + WalOperation::IntoExternalAsset, + WalOperation::RecycleIntoVoucher, + WalOperation::SecretHandoff, + WalOperation::Split, + WalOperation::TransferPrepared, + WalOperation::TransferAccepted, + WalOperation::TransferCompleted, + WalOperation::TransferRejected, + ] { + assert_eq!(WalOperation::from_raw(op.as_raw()), Some(op)); + } + assert_eq!(WalOperation::from_raw(9), None); + } + + #[test] + fn payload_scale_round_trips() { + let payload = WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 4, + }], + input_vouchers: vec![], + output_coins: vec![ + WalCoinRef { + derivation_index: 9, + exponent: 2, + }, + WalCoinRef { + derivation_index: 10, + exponent: -1, + }, + ], + output_vouchers: vec![], + destination_coins: vec![WalCoinRef { + derivation_index: 9, + exponent: 2, + }], + }; + let encoded = payload.encode(); + assert_eq!(WalPayload::decode(&mut &encoded[..]).unwrap(), payload); + } + + #[test] + fn legacy_payload_without_destination_suffix_still_decodes() { + let legacy = ( + vec![WalCoinRef { + derivation_index: 1, + exponent: 0, + }], + Vec::::new(), + vec![WalCoinRef { + derivation_index: 2, + exponent: 1, + }], + Vec::::new(), + ) + .encode(); + let decoded = WalPayload::decode(&mut &legacy[..]).unwrap(); + assert!(decoded.destination_coins.is_empty()); + assert_eq!(decoded.output_coins[0].derivation_index, 2); + } + #[test] + fn operation_namespace_cannot_match_a_different_host_identifier() { + let mut child = entry(CheckpointBlock::Pending); + child.entry_id = operation_entry_id("wallet:payment", "unload-0"); + assert!(child.belongs_to_operation("wallet:payment")); + assert!(!child.belongs_to_operation("wallet")); + assert!(!child.belongs_to_operation("wallet:payment:unload")); + assert_eq!( + child.operation_parent_id(), + Some(operation_entry_id("wallet:payment", "parent")) + ); + child.entry_id = "legacy-split".into(); + assert_eq!(child.operation_parent_id(), None); + } +} diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 5b901fb24..2e9a946e7 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -3,14 +3,14 @@ name = "truapi-host-cli" version = "0.17.0" edition.workspace = true description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" -license = "MIT" +license = "MIT AND AGPL-3.0-only" [[bin]] name = "truapi-host" path = "src/main.rs" [target.'cfg(unix)'.dependencies] -rustix = { version = "1", features = ["process"] } +rustix = { version = "1", features = ["process", "fs"] } [lints.rust] unsafe_code = "forbid" @@ -31,6 +31,7 @@ flate2 = "1" futures = "0.3" futures-util = "0.3" fs2 = "0.4" +getrandom = "0.3" hex = "0.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } parity-scale-codec = { version = "3", features = ["derive"] } @@ -55,3 +56,4 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } unicode-width = "0.2" zeroize = { version = "1", features = ["derive"] } + diff --git a/rust/crates/truapi-host-cli/LICENSE b/rust/crates/truapi-host-cli/LICENSE new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/rust/crates/truapi-host-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 b/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-host-cli/NOTICE b/rust/crates/truapi-host-cli/NOTICE new file mode 100644 index 000000000..c5dde8f4f --- /dev/null +++ b/rust/crates/truapi-host-cli/NOTICE @@ -0,0 +1,17 @@ +Original Host CLI code retains its MIT license; see LICENSE. +The CLI links the combined signing runtime, including AGPL-3.0-only code, +and is not an MIT-only distribution. See LICENSE-AGPL-3.0. + +Coinage is a modified extraction from paritytech/brevity-dozer, revision +d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. +Detailed provenance accompanies the truapi-coinage crate in its NOTICE. +Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that +same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. +Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, +based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, +including local native-attachment codec and secret-zeroization modifications. + +Corresponding Source must include the exact Host source revision, all local +modifications, dependency provenance/license notices and build instructions. +Source repository: https://github.com/paritytech/host-rust-core +A repository URL alone does not provide unpublished local modifications. diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index c7ea00ff5..30b8ce9a7 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -231,7 +231,7 @@ Commands always start with `/`: | `/devices --remove --force` | Attempt to disconnect one paired device, then remove its local pairing even if notification fails. | | `/approval` | Show whether signing-host confirmations are manual or automatic. | | `/approval manual` | Prompt for every future signing-host confirmation. | -| `/approval automatic` | Approve every future signing-host confirmation automatically. | +| `/approval automatic` | Approve future non-payment confirmations automatically; main-purse payments still require review. | | `/script` | Reopen the session's last TypeScript scratch script (or create one), then run it. | | `/script ` | Remember and run an existing JS/TS product script through the public frame endpoint. | | `/login` | Start pairing for the selected product, show its QR code, and copy its deeplink to the clipboard. | @@ -652,6 +652,9 @@ In non-interactive `exec` mode, a TTY gets a plain yes/no prompt and non-TTY stdin safely rejects instead of hanging. Same-product Ring-VRF requests do not prompt, matching the iOS signing host. Pass `--auto-accept` for unattended runs; every auto-approved decision is still printed. +Main-purse Chat payments are excluded from automatic approval. Each exact +payment still prompts, including its Host-selected asset, recipient, amount and +debit ceiling; without an interactive terminal it is rejected. The interactive signing host can inspect or change its running policy with `/approval`, `/approval manual`, and `/approval automatic`. A change applies to @@ -769,6 +772,15 @@ discovery (see SPEC.md §21). Both also accept `--frame-listen
` to opt into a TCP product-frame WebSocket; without it, the CLI creates and cleans up a unique temporary Unix socket. +For instance-scoped Coinage runtimes, `signing-host` and `dev` require an +operator-selected asset instance for Coinage operations: pass +`--coinage-instance-id ` or set `TRUAPI_COINAGE_INSTANCE_ID`. +The flag overrides the environment variable. Zero is a valid explicit value; +there is no inferred/default asset instance. Obtain it from the trusted network +configuration, not the purse derivation identifier. Omission preserves legacy +Coinage support and makes instance-scoped Coinage operations fail closed. The +selection is retained when switching, importing, or promoting signer sessions. + ## Serving a dev server (one process, no terminal) `truapi-host dev` is the shorthand for this when the thing being supervised is a @@ -814,10 +826,10 @@ reports `No connected user` here as it does in the terminal. Stopping it: Ctrl-C is handled, so the host logs its own shutdown. `SIGTERM` ends the process, which is what a supervising dev server sends. -`--auto-accept` is effectively required, because a process with no terminal has -nowhere to prompt: confirmations are denied instead, and the startup line says -so. `--serve` cannot be combined with `--script` or `exec`, which are the -one-shot modes. +`--auto-accept` is effectively required for unattended non-payment work, because +a process with no terminal has nowhere to prompt. Main-purse payments are +always excluded and are denied in this mode. `--serve` cannot be combined with +`--script` or `exec`, which are the one-shot modes. ## Scope / gaps diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index 72bbe8181..c3ec694b7 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -18,8 +18,10 @@ use futures_util::{StreamExt as _, TryStreamExt as _, stream}; use serde::Deserialize; use serde_json::{Value, json}; use std::collections::BTreeSet; -use std::sync::Mutex; +use std::sync::{Arc, Mutex, Weak}; use tracing::{debug, warn}; +use truapi::latest::GenericError; +use truapi_platform::IdentityBackendHost; use truapi_server::host_logic::attestation::build_lite_registration; use truapi_server::host_logic::dotns_gateway::{ MAX_BASE_LABEL_LEN, MIN_PERSON_LABEL_LEN, is_registrable_full_label, @@ -28,6 +30,7 @@ use truapi_server::host_logic::product_account::{ derive_identity_keypair, derive_root_keypair_from_entropy, identity_product_id, product_public_key_to_address, }; +use truapi_server::{LocalIdentityContext, SigningHostRuntime}; use crate::dotns_read::AssetHubReader; use crate::network::NetworkConfig; @@ -62,6 +65,73 @@ struct BackendToken { auth_client_id: [u8; 32], } +/// The same backend handshake serves one-shot commands and a fenced Host session. +enum BackendAuth<'a> { + Entropy { + entropy: &'a [u8], + network_suffix: &'a str, + }, + Session { + runtime: &'a SigningHostRuntime, + context: LocalIdentityContext, + }, +} + +impl BackendAuth<'_> { + fn ensure_current(&self) -> Result<()> { + if let Self::Session { runtime, context } = self { + let current = runtime + .local_identity_context() + .map_err(|err| anyhow::anyhow!(err.reason))?; + if current.activation_id != context.activation_id + || current.identity_account_id != context.identity_account_id + { + bail!("identity backend session changed"); + } + } + Ok(()) + } + + fn client_id(&self) -> Result<[u8; 32]> { + self.ensure_current()?; + match self { + Self::Entropy { + entropy, + network_suffix, + } => derive_identity_keypair(entropy, network_suffix) + .map(|key| key.public.to_bytes()) + .map_err(|err| anyhow::anyhow!("backend auth identity derivation failed: {err}")), + Self::Session { context, .. } => { + decode_backend_account_id(&context.identity_account_id) + } + } + } + + fn sign_challenge(&self, challenge: &[u8]) -> Result<([u8; 32], [u8; 64])> { + match self { + Self::Entropy { + entropy, + network_suffix, + } => truapi_server::host_logic::attestation::sign_backend_challenge( + entropy, + network_suffix, + challenge, + b"{}", + ) + .context("backend auth identity derivation failed"), + Self::Session { runtime, context } => { + let proof = runtime + .local_identity_auth_proof(&context.activation_id, challenge) + .map_err(|err| anyhow::anyhow!(err.reason))?; + if proof.len() != 96 { + bail!("identity backend auth proof has invalid length"); + } + Ok((proof[..32].try_into()?, proof[32..].try_into()?)) + } + } + } +} + /// Bearer token for the identity backend's username routes. /// /// The explicit env token wins. Otherwise the CLI completes the backend's @@ -72,13 +142,9 @@ struct BackendToken { async fn backend_token( client: &reqwest::Client, backend_base: &str, - auth_entropy: &[u8], - network_suffix: &str, + auth: &BackendAuth<'_>, ) -> Result { - let auth_client_id = derive_identity_keypair(auth_entropy, network_suffix) - .map_err(|err| anyhow::anyhow!("backend auth identity derivation failed: {err}"))? - .public - .to_bytes(); + let auth_client_id = auth.client_id()?; if let Ok(token) = std::env::var(IDENTITY_BACKEND_TOKEN_ENV) && !token.trim().is_empty() { @@ -101,7 +167,8 @@ async fn backend_token( auth_client_id, }); } - let token = mint_backend_token(client, backend_base, auth_entropy, network_suffix).await?; + let token = mint_backend_token(client, backend_base, auth).await?; + auth.ensure_current()?; let token = { let mut tokens = BACKEND_TOKENS .lock() @@ -141,8 +208,7 @@ fn evict_rejected_backend_token(backend_base: &str, auth_client_id: &[u8; 32], r async fn mint_backend_token( client: &reqwest::Client, backend_base: &str, - auth_entropy: &[u8], - network_suffix: &str, + auth: &BackendAuth<'_>, ) -> Result { let url = format!("{backend_base}/auth/challenges"); let body: Value = client @@ -165,13 +231,7 @@ async fn mint_backend_token( .context("challenge is not valid base64")?; let payload = b"{}"; - let (client_id, proof) = truapi_server::host_logic::attestation::sign_backend_challenge( - auth_entropy, - network_suffix, - &challenge_bytes, - payload, - ) - .context("backend auth identity derivation failed")?; + let (client_id, proof) = auth.sign_challenge(&challenge_bytes)?; let url = format!("{backend_base}/auth/token"); let response = client @@ -205,15 +265,16 @@ async fn mint_backend_token( async fn send_with_backend_auth( client: &reqwest::Client, backend_base: &str, - auth_entropy: &[u8], - network_suffix: &str, + auth: &BackendAuth<'_>, request: F, ) -> Result where F: Fn(&str) -> reqwest::RequestBuilder, { - let token = backend_token(client, backend_base, auth_entropy, network_suffix).await?; + let token = backend_token(client, backend_base, auth).await?; + auth.ensure_current()?; let response = request(&token.value).send().await?; + auth.ensure_current()?; if response.status() != reqwest::StatusCode::UNAUTHORIZED || token.source == BackendTokenSource::Environment { @@ -222,10 +283,13 @@ where warn!(backend = %backend_base, "identity backend rejected cached token; authenticating again"); evict_rejected_backend_token(backend_base, &token.auth_client_id, &token.value); - let refreshed = backend_token(client, backend_base, auth_entropy, network_suffix) + let refreshed = backend_token(client, backend_base, auth) .await .context("refresh identity backend token after 401 Unauthorized")?; - request(&refreshed.value).send().await.map_err(Into::into) + auth.ensure_current()?; + let response = request(&refreshed.value).send().await?; + auth.ensure_current()?; + Ok(response) } /// Inputs for one attestation run. @@ -262,8 +326,10 @@ pub async fn lite_username_available( let response = send_with_backend_auth( &client, backend_base, - auth_entropy, - network.network_suffix, + &BackendAuth::Entropy { + entropy: auth_entropy, + network_suffix: network.network_suffix, + }, |token| client.post(&url).bearer_auth(token).json(&body), ) .await @@ -383,6 +449,7 @@ pub async fn lookup_registered_username( #[serde(rename_all = "camelCase")] struct UsernameSearchPage { usernames: Vec, + #[serde(deserialize_with = "Option::::deserialize")] next_cursor: Option, } @@ -394,6 +461,148 @@ struct UsernameSearchItem { status: String, } +/// A runtime-local provider: secrets stay in the active signing Host, and a +/// weak reference avoids a provider/runtime ownership cycle. +pub struct CliIdentityBackendHost { + runtime: Weak, + network: NetworkConfig, + client: reqwest::Client, +} + +impl CliIdentityBackendHost { + pub fn new(runtime: &Arc, network: NetworkConfig) -> Result { + Ok(Self { + runtime: Arc::downgrade(runtime), + network, + client: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + }) + } + + async fn candidates(&self, username: &str, people_genesis: [u8; 32]) -> Result> { + const LIMIT: usize = 32; + if people_genesis != self.network.people_genesis { + bail!("identity backend is not configured for the requested People network"); + } + if self.network.identity_backend_base.trim().is_empty() { + bail!("identity backend is not configured"); + } + if !is_registrable_full_label(username) { + bail!("identity backend requires an exact full username"); + } + let runtime = self + .runtime + .upgrade() + .context("identity backend Host is unavailable")?; + let context = runtime + .local_identity_context() + .map_err(|err| anyhow::anyhow!(err.reason))?; + let auth = BackendAuth::Session { + runtime: &runtime, + context, + }; + let mut cursor = None; + let mut seen_cursors = BTreeSet::new(); + let mut candidates = BTreeSet::new(); + for _ in 0..LIMIT { + let page = search_backend_page( + &self.client, + self.network.identity_backend_base, + &auth, + username, + "32", + cursor.as_deref(), + ) + .await?; + if page.usernames.len() > LIMIT { + bail!("identity backend exceeded the username search page limit"); + } + if page.usernames.is_empty() && page.next_cursor.is_some() { + bail!("identity backend returned an incomplete username search page"); + } + for item in page.usernames { + let account = decode_backend_account_id(&item.account_id)?; + if item.status == "ASSIGNED" && item.username == username { + candidates.insert(account); + if candidates.len() > LIMIT { + bail!("identity backend exceeded the username candidate limit"); + } + } + } + auth.ensure_current()?; + match page.next_cursor { + None => return Ok(candidates.into_iter().collect()), + Some(next) => { + if next.trim().is_empty() || !seen_cursors.insert(next.clone()) { + bail!( + "identity backend returned an empty or repeated username search cursor" + ); + } + cursor = Some(next); + } + } + } + bail!("identity backend username search is incomplete after 32 pages") + } +} + +#[async_trait::async_trait] +impl IdentityBackendHost for CliIdentityBackendHost { + async fn identity_username_candidates( + &self, + username: String, + people_chain_genesis_hash: [u8; 32], + ) -> Result, GenericError> { + self.candidates(&username, people_chain_genesis_hash) + .await + .map_err(|err| GenericError { + reason: format!("{err:#}"), + }) + } +} + +fn decode_backend_account_id(account: &str) -> Result<[u8; 32]> { + let encoded = account + .strip_prefix("0x") + .context("identity backend AccountId32 must be 0x-prefixed hexadecimal")?; + let mut bytes = [0; 32]; + hex::decode_to_slice(encoded, &mut bytes) + .context("identity backend returned a malformed AccountId32")?; + Ok(bytes) +} + +async fn search_backend_page( + client: &reqwest::Client, + backend_base: &str, + auth: &BackendAuth<'_>, + prefix: &str, + limit: &str, + cursor: Option<&str>, +) -> Result { + let url = format!("{backend_base}/usernames/search"); + let mut query = vec![("prefix", prefix), ("limit", limit)]; + if let Some(cursor) = cursor { + query.push(("cursor", cursor)); + } + let response = send_with_backend_auth(client, backend_base, auth, |token| { + client.get(&url).bearer_auth(token).query(&query) + }) + .await + .with_context(|| format!("GET {url} for prefix {prefix:?}"))?; + let status = response.status(); + if !status.is_success() { + bail!("identity backend username search failed ({status})"); + } + let page = response + .json() + .await + .context("decode identity backend username search response")?; + auth.ensure_current()?; + Ok(page) +} + /// Reverse-resolve an assigned username from the identity backend. /// /// The backend does not currently expose an account-indexed route. Its search @@ -446,32 +655,24 @@ async fn search_backend_prefix( const PAGE_LIMIT: &str = "1000"; let backend_base = network.identity_backend_base; - let url = format!("{backend_base}/usernames/search"); + let auth = BackendAuth::Entropy { + entropy: auth_entropy, + network_suffix: network.network_suffix, + }; let prefix = initial.to_string(); let mut cursor = None; let mut seen_cursors = BTreeSet::new(); let mut matches = Vec::new(); loop { - let mut query = vec![("prefix", prefix.as_str()), ("limit", PAGE_LIMIT)]; - if let Some(cursor) = cursor.as_deref() { - query.push(("cursor", cursor)); - } - let response = send_with_backend_auth( + let page = search_backend_page( client, backend_base, - auth_entropy, - network.network_suffix, - |token| client.get(&url).bearer_auth(token).query(&query), + &auth, + &prefix, + PAGE_LIMIT, + cursor.as_deref(), ) - .await - .with_context(|| format!("GET {url} for prefix {prefix:?}"))?; - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - if !status.is_success() { - bail!("identity backend username search failed ({status}): {text}"); - } - let page: UsernameSearchPage = serde_json::from_str(&text) - .with_context(|| format!("decode identity backend username search response: {text}"))?; + .await?; matches.extend(matching_assigned_usernames( page.usernames, candidate_account_id, @@ -585,8 +786,10 @@ async fn submit_registration( let response = send_with_backend_auth( client, backend_base, - &config.entropy, - &config.network_suffix, + &BackendAuth::Entropy { + entropy: &config.entropy, + network_suffix: &config.network_suffix, + }, |token| client.post(&url).bearer_auth(token).json(&body), ) .await @@ -883,7 +1086,11 @@ mod tests { .timeout(Duration::from_secs(30)) .build()?; let url = format!("{backend_base}/protected"); - let response = send_with_backend_auth(&client, &backend_base, &entropy, "paseo", |token| { + let auth = BackendAuth::Entropy { + entropy: &entropy, + network_suffix: "paseo", + }; + let response = send_with_backend_auth(&client, &backend_base, &auth, |token| { client.post(&url).bearer_auth(token).body("{}") }) .await?; diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index 016d5b92b..4d6eb5f86 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -12,18 +12,19 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use futures::stream::BoxStream; use futures_util::{SinkExt, StreamExt}; -use tokio::sync::{broadcast, mpsc}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, mpsc}; use tokio_stream::wrappers::BroadcastStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, warn}; use truapi::latest as api; -use truapi_platform::{ChainProvider, JsonRpcConnection}; +use truapi_platform::{ChainProvider, HopProvider, JsonRpcConnection}; -use crate::network::ChainEndpoint; +use crate::network::{ChainEndpoint, HopEndpoint}; /// Broadcast backlog for inbound JSON-RPC frames per connection. const INBOUND_CHANNEL_CAPACITY: usize = 1024; +const MAX_HOP_CONNECTIONS: usize = 8; /// Chain provider that maps a requested genesis hash to a WebSocket endpoint. /// @@ -34,6 +35,8 @@ const INBOUND_CHANNEL_CAPACITY: usize = 1024; pub struct WsChainProvider { fallback_url: String, by_genesis: HashMap<[u8; 32], String>, + hop_by_genesis: HashMap<[u8; 32], Vec>, + hop_connections: Arc, } impl WsChainProvider { @@ -58,9 +61,29 @@ impl WsChainProvider { Self { fallback_url: fallback_url.into(), by_genesis, + hop_by_genesis: HashMap::new(), + hop_connections: Arc::new(Semaphore::new(MAX_HOP_CONNECTIONS)), } } + pub fn with_hop_endpoints( + mut self, + bulletin_genesis: [u8; 32], + endpoints: &[HopEndpoint], + ) -> Self { + self.hop_by_genesis.clear(); + for endpoint in endpoints { + // A URL trusted for another Bulletin is not trusted for this one. + if endpoint.bulletin_genesis == bulletin_genesis { + self.hop_by_genesis + .entry(bulletin_genesis) + .or_default() + .push(endpoint.ws.to_string()); + } + } + self + } + /// Whether a genesis is mapped rather than answered by the fallback. /// /// Test-only because production has no reason to care: `url_for` resolves either @@ -95,7 +118,46 @@ impl ChainProvider for WsChainProvider { ) -> Result, api::GenericError> { let url = self.url_for(&genesis_hash); debug!(genesis = %hex::encode(genesis_hash), %url, "chain connect"); - let connection = WsJsonRpcConnection::connect(url) + let connection = WsJsonRpcConnection::connect(url, None) + .await + .map_err(|reason| api::GenericError { reason })?; + Ok(Box::new(connection)) + } +} + +#[async_trait] +impl HopProvider for WsChainProvider { + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, api::GenericError> { + Ok(self + .hop_by_genesis + .get(&bulletin_genesis_hash) + .cloned() + .unwrap_or_default()) + } + + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, api::GenericError> { + let allowed = self + .hop_by_genesis + .get(&bulletin_genesis_hash) + .ok_or_else(|| api::GenericError { + reason: "HOP provider unavailable for this Bulletin chain".to_string(), + })?; + truapi_platform::ensure_allowed_hop_endpoint(&endpoint, allowed)?; + let permit = self + .hop_connections + .clone() + .try_acquire_owned() + .map_err(|_| api::GenericError { + reason: "HOP connection limit reached".to_string(), + })?; + let connection = WsJsonRpcConnection::connect(&endpoint, Some(permit)) .await .map_err(|reason| api::GenericError { reason })?; Ok(Box::new(connection)) @@ -106,65 +168,113 @@ impl ChainProvider for WsChainProvider { /// task, inbound frames are broadcast to every `responses()` stream. pub struct WsJsonRpcConnection { outbound: mpsc::UnboundedSender, - inbound: broadcast::Sender, + state: Arc, /// Receiver created before the reader task starts. The first response /// stream takes it so an immediate RPC response cannot race subscription /// setup and disappear while the broadcast channel has no receivers. initial_inbound: Mutex>>, - closed: Arc, +} + +struct WsConnectionState { + inbound: Mutex>>, + closed: AtomicBool, + tasks: Mutex>, + hop_permit: Mutex>, +} + +impl WsConnectionState { + fn register_task(&self, task: tokio::task::AbortHandle) { + let mut tasks = self.tasks.lock().expect("websocket task mutex poisoned"); + if self.closed.load(Ordering::Acquire) { + task.abort(); + } else { + tasks.push(task); + } + } + + fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + for task in self + .tasks + .lock() + .expect("websocket task mutex poisoned") + .drain(..) + { + task.abort(); + } + self.inbound + .lock() + .expect("websocket inbound mutex poisoned") + .take(); + self.hop_permit + .lock() + .expect("HOP lease mutex poisoned") + .take(); + } } impl WsJsonRpcConnection { - async fn connect(url: &str) -> Result { + async fn connect(url: &str, hop_permit: Option) -> Result { let (stream, _response) = connect_async(url) .await - .map_err(|err| format!("statement-store websocket connect failed: {err}"))?; + .map_err(|err| format!("JSON-RPC websocket connect failed: {err}"))?; let (mut write, mut read) = stream.split(); let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); - let (inbound_tx, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); - let closed = Arc::new(AtomicBool::new(false)); + let (inbound, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let state = Arc::new(WsConnectionState { + inbound: Mutex::new(Some(inbound)), + closed: AtomicBool::new(false), + tasks: Mutex::new(Vec::with_capacity(2)), + hop_permit: Mutex::new(hop_permit), + }); - tokio::spawn(async move { + let writer_state = state.clone(); + let writer = tokio::spawn(async move { while let Some(message) = outbound_rx.recv().await { if write.send(message).await.is_err() { break; } } - let _ = write.close().await; + writer_state.close(); }); + state.register_task(writer.abort_handle()); - let reader_inbound = inbound_tx.clone(); - let reader_closed = closed.clone(); - tokio::spawn(async move { + let reader_state = state.clone(); + let reader = tokio::spawn(async move { while let Some(message) = read.next().await { - match message { - Ok(Message::Text(text)) => { - let _ = reader_inbound.send(text.to_string()); - } - Ok(Message::Binary(bytes)) => { - if let Ok(text) = String::from_utf8(bytes.to_vec()) { - let _ = reader_inbound.send(text); - } - } + let response = match message { + Ok(Message::Text(text)) => Some(text.to_string()), + Ok(Message::Binary(bytes)) => String::from_utf8(bytes.to_vec()).ok(), Ok(Message::Close(_)) | Err(_) => break, - Ok(_) => {} + Ok(_) => None, + }; + if let Some(response) = response + && let Some(inbound) = reader_state + .inbound + .lock() + .expect("websocket inbound mutex poisoned") + .as_ref() + { + let _ = inbound.send(response); } } - reader_closed.store(true, Ordering::Release); + reader_state.close(); }); + state.register_task(reader.abort_handle()); Ok(Self { outbound: outbound_tx, - inbound: inbound_tx, + state, initial_inbound: Mutex::new(Some(initial_inbound)), - closed, }) } } impl JsonRpcConnection for WsJsonRpcConnection { fn send(&self, request: String) { - if self.closed.load(Ordering::Acquire) { + if self.state.closed.load(Ordering::Acquire) { return; } let _ = self.outbound.send(Message::Text(request)); @@ -176,7 +286,17 @@ impl JsonRpcConnection for WsJsonRpcConnection { .lock() .expect("initial chain response receiver mutex poisoned") .take() - .unwrap_or_else(|| self.inbound.subscribe()); + .or_else(|| { + self.state + .inbound + .lock() + .expect("websocket inbound mutex poisoned") + .as_ref() + .map(broadcast::Sender::subscribe) + }); + let Some(receiver) = receiver else { + return futures::stream::empty().boxed(); + }; BroadcastStream::new(receiver) .filter_map(|item| async move { match item { @@ -193,13 +313,20 @@ impl JsonRpcConnection for WsJsonRpcConnection { } fn close(&self) { - self.closed.store(true, Ordering::Release); + self.state.close(); + } +} + +impl Drop for WsJsonRpcConnection { + fn drop(&mut self) { + self.close(); } } #[cfg(test)] mod tests { use clap::ValueEnum; + use futures::FutureExt; use super::*; use crate::network::Network; @@ -210,9 +337,13 @@ mod tests { let (inbound, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); let connection = WsJsonRpcConnection { outbound, - inbound: inbound.clone(), + state: Arc::new(WsConnectionState { + inbound: Mutex::new(Some(inbound.clone())), + closed: AtomicBool::new(false), + tasks: Mutex::new(Vec::new()), + hop_permit: Mutex::new(None), + }), initial_inbound: Mutex::new(Some(initial_inbound)), - closed: Arc::new(AtomicBool::new(false)), }; inbound @@ -224,6 +355,35 @@ mod tests { assert_eq!(frame, r#"{"jsonrpc":"2.0","id":1,"result":"ready"}"#); } + #[test] + fn closing_rpc_ends_responses_and_releases_hop_capacity() { + let capacity = Arc::new(Semaphore::new(1)); + let permit = capacity.clone().try_acquire_owned().unwrap(); + let (outbound, mut outbound_rx) = mpsc::unbounded_channel(); + let (inbound, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let connection = WsJsonRpcConnection { + outbound, + state: Arc::new(WsConnectionState { + inbound: Mutex::new(Some(inbound)), + closed: AtomicBool::new(false), + tasks: Mutex::new(Vec::new()), + hop_permit: Mutex::new(Some(permit)), + }), + initial_inbound: Mutex::new(Some(initial_inbound)), + }; + let mut responses = connection.responses(); + assert!(capacity.clone().try_acquire_owned().is_err()); + connection.close(); + assert!(capacity.try_acquire_owned().is_ok()); + assert_eq!( + responses.next().now_or_never(), + Some(None), + "close must end an already-taken response stream immediately" + ); + connection.send("must not be sent after close".to_string()); + assert!(outbound_rx.try_recv().is_err()); + } + /// Every role the host says it serves has to route to that role's own chain /// without the test switch. `url_for` answers an unmapped genesis with the /// fallback URL, so a served role that the routing filter drops would connect diff --git a/rust/crates/truapi-host-cli/src/chat_files.rs b/rust/crates/truapi-host-cli/src/chat_files.rs new file mode 100644 index 000000000..5b0e28ab4 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chat_files.rs @@ -0,0 +1,737 @@ +//! Host-private, bounded Chat file custody. Paths never leave the trusted CLI UI. + +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; +use tempfile::NamedTempFile; +use truapi::latest::{ + GenericError, HostNativeChatAttachmentKind, HostNativeChatAttachmentMetadata, +}; +use truapi_platform::NativeChatPickedFile; + +const MAX_READ: u32 = 2_000_000; +const BLOCK_SIZE: u64 = 65_536; +const MAGIC: &[u8; 8] = b"CHATFILE"; +const HEADER_SIZE: u64 = 12; +pub(crate) const DIRECTORY: &str = "chat-files"; + +pub(crate) fn error(reason: &'static str) -> GenericError { + GenericError { + reason: reason.to_string(), + } +} + +async fn blocking( + operation: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(operation) + .await + .map_err(|_| error("Chat file operation interrupted"))? +} + +fn opaque_id() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| error("Chat file randomness unavailable"))?; + Ok(hex::encode(bytes)) +} + +fn valid_id(id: &str) -> Result<(), GenericError> { + if id.len() != 64 + || !id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(error("Invalid Chat file handle")); + } + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<(), GenericError> { + #[cfg(unix)] + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| error("Could not synchronize Chat file directory"))?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn private_directory(root: &Path) -> Result { + let directory = root.join(DIRECTORY); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&directory) { + Ok(()) => sync_directory(root)?, + Err(cause) if cause.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(error("Could not create private Chat file storage")), + } + let metadata = fs::symlink_metadata(&directory) + .map_err(|_| error("Private Chat file storage unavailable"))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(error("Invalid private Chat file storage")); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)) + .map_err(|_| error("Could not protect private Chat file storage"))?; + } + Ok(directory) +} + +fn open_regular(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32, + ); + } + let file = options + .open(path) + .map_err(|_| error("Could not open Chat file"))?; + if !file + .metadata() + .map_err(|_| error("Could not inspect Chat file"))? + .is_file() + { + return Err(error("Chat selection must be a regular file")); + } + Ok(file) +} + +fn source_path(root: &Path, source_id: &str) -> Result { + valid_id(source_id)?; + Ok(private_directory(root)?.join(source_id)) +} + +fn data_start(size: u32) -> u64 { + HEADER_SIZE + u64::from(size).div_ceil(BLOCK_SIZE) * 32 +} + +fn remove_snapshot(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(std::io::Error::from(std::io::ErrorKind::InvalidData)); + } + let mut permissions = metadata.permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions)?; + } + fs::remove_file(path) +} + +// Own completed imports until the async callback actually receives its result. +// Dropping a cancelled callback or a failed multi-selection removes its imports. +struct Imports { + directory: PathBuf, + files: Vec, +} + +impl Drop for Imports { + fn drop(&mut self) { + for file in &self.files { + let _ = remove_snapshot(&self.directory.join(&file.source_id)); + } + } +} + +fn import_one(directory: &Path, path: &Path) -> Result { + let mut input = open_regular(path)?; + let before = input + .metadata() + .map_err(|_| error("Could not inspect selected Chat file"))?; + let size = u32::try_from(before.len()) + .map_err(|_| error("Chat files must fit in a u32 byte count"))?; + let mut snapshot = + NamedTempFile::new_in(directory).map_err(|_| error("Could not create Chat snapshot"))?; + snapshot + .seek(SeekFrom::Start(data_start(size))) + .map_err(|_| error("Could not prepare Chat snapshot"))?; + let mut hashes = Vec::with_capacity(u64::from(size).div_ceil(BLOCK_SIZE) as usize * 32); + let mut buffer = vec![0_u8; BLOCK_SIZE as usize]; + let mut remaining = u64::from(size); + while remaining > 0 { + let count = remaining.min(BLOCK_SIZE) as usize; + input + .read_exact(&mut buffer[..count]) + .map_err(|_| error("Selected Chat file changed or could not be read"))?; + snapshot + .write_all(&buffer[..count]) + .map_err(|_| error("Could not write Chat snapshot"))?; + hashes.extend_from_slice(&Sha256::digest(&buffer[..count])); + remaining -= count as u64; + } + let after = input + .metadata() + .map_err(|_| error("Could not inspect selected Chat file"))?; + if input + .read(&mut buffer[..1]) + .map_err(|_| error("Could not read selected Chat file"))? + != 0 + || before.len() != after.len() + || before.modified().ok() != after.modified().ok() + { + return Err(error("Selected Chat file changed while importing")); + } + snapshot + .seek(SeekFrom::Start(0)) + .and_then(|_| snapshot.write_all(MAGIC)) + .and_then(|_| snapshot.write_all(&size.to_le_bytes())) + .and_then(|_| snapshot.write_all(&hashes)) + .map_err(|_| error("Could not seal Chat snapshot"))?; + let mut permissions = snapshot + .as_file() + .metadata() + .map_err(|_| error("Could not inspect Chat snapshot"))? + .permissions(); + permissions.set_readonly(true); + snapshot + .as_file() + .set_permissions(permissions) + .and_then(|_| snapshot.as_file().sync_all()) + .map_err(|_| error("Could not protect and synchronize Chat snapshot"))?; + let source_id = opaque_id()?; + snapshot + .persist_noclobber(directory.join(&source_id)) + .map_err(|_| error("Could not publish Chat snapshot"))?; + Ok(NativeChatPickedFile { + source_id, + metadata: HostNativeChatAttachmentMetadata { + mime_type: "application/octet-stream".to_string(), + size_bytes: size, + kind: HostNativeChatAttachmentKind::File, + }, + }) +} + +fn import_files(root: &Path, paths: Vec) -> Result { + let directory = private_directory(root)?; + let mut imports = Imports { + directory, + files: Vec::with_capacity(paths.len()), + }; + for path in paths { + imports.files.push(import_one(&imports.directory, &path)?); + } + sync_directory(&imports.directory)?; + Ok(imports) +} + +fn read_file( + root: &Path, + source_id: &str, + offset: u64, + length: u32, +) -> Result, GenericError> { + if length > MAX_READ { + return Err(error("Chat file reads are limited to 2000000 bytes")); + } + let end = offset + .checked_add(u64::from(length)) + .ok_or_else(|| error("Chat file range overflow"))?; + let mut file = open_regular(&source_path(root, source_id)?)?; + let actual = file + .metadata() + .map_err(|_| error("Could not inspect Chat snapshot"))?; + let mut header = [0_u8; HEADER_SIZE as usize]; + file.read_exact(&mut header) + .map_err(|_| error("Invalid Chat snapshot"))?; + if &header[..8] != MAGIC || !actual.permissions().readonly() { + return Err(error("Chat snapshot is not immutable")); + } + let size = u32::from_le_bytes(header[8..12].try_into().expect("fixed size field")); + let start = data_start(size); + if actual.len() != start + u64::from(size) || end > u64::from(size) { + return Err(error("Chat file range or snapshot size is invalid")); + } + let mut result = Vec::with_capacity(length as usize); + if length == 0 { + return Ok(result); + } + let mut buffer = vec![0_u8; BLOCK_SIZE as usize]; + for block in offset / BLOCK_SIZE..=(end - 1) / BLOCK_SIZE { + let block_start = block * BLOCK_SIZE; + let count = (u64::from(size) - block_start).min(BLOCK_SIZE) as usize; + let mut expected = [0_u8; 32]; + file.seek(SeekFrom::Start(HEADER_SIZE + block * 32)) + .and_then(|_| file.read_exact(&mut expected)) + .and_then(|_| file.seek(SeekFrom::Start(start + block_start))) + .and_then(|_| file.read_exact(&mut buffer[..count])) + .map_err(|_| error("Could not read Chat snapshot"))?; + if Sha256::digest(&buffer[..count])[..] != expected { + return Err(error("Chat snapshot integrity check failed")); + } + let from = offset.saturating_sub(block_start) as usize; + let to = (end - block_start).min(count as u64) as usize; + result.extend_from_slice(&buffer[from..to]); + } + Ok(result) +} + +struct Export { + root: PathBuf, + destination: PathBuf, + temporary: Option, + size: u64, + written: u64, +} + +impl Export { + fn prepare(root: PathBuf, destination: PathBuf, size: u32) -> Result { + let name = destination + .file_name() + .ok_or_else(|| error("Chat export needs a file name"))?; + let parent = destination + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent = + fs::canonicalize(parent).map_err(|_| error("Chat export directory is unavailable"))?; + let destination = parent.join(name); + match fs::symlink_metadata(&destination) { + Err(cause) if cause.kind() == std::io::ErrorKind::NotFound => {} + _ => { + return Err(error( + "Chat export destination already exists or is unavailable; choose a new file name", + )); + } + } + let temporary = + NamedTempFile::new_in(&parent).map_err(|_| error("Could not create Chat export"))?; + Ok(Self { + root, + destination, + temporary: Some(temporary), + size: u64::from(size), + written: 0, + }) + } + + fn write(&mut self, offset: u64, data: &[u8]) -> Result<(), GenericError> { + let end = offset + .checked_add(data.len() as u64) + .ok_or_else(|| error("Chat export range overflow"))?; + if data.len() > MAX_READ as usize || offset != self.written || end > self.size { + return Err(error( + "Chat export writes must be bounded, contiguous and within the declared size", + )); + } + let file = self + .temporary + .as_mut() + .ok_or_else(|| error("Chat export is already published"))?; + // A failed write may have written a prefix. Retrying the same offset + // overwrites that prefix instead of silently appending duplicate bytes. + file.seek(SeekFrom::Start(offset)) + .and_then(|_| file.write_all(data)) + .map_err(|_| error("Could not write Chat export"))?; + self.written = end; + Ok(()) + } + + fn finish(&mut self) -> Result<(), GenericError> { + if self.written != self.size { + return Err(error("Chat export is incomplete")); + } + if let Some(file) = &self.temporary { + if file + .as_file() + .metadata() + .map_err(|_| error("Could not inspect Chat export"))? + .len() + != self.size + { + return Err(error("Chat export size is invalid")); + } + file.as_file() + .sync_all() + .map_err(|_| error("Could not synchronize Chat export"))?; + } + if let Some(file) = self.temporary.take() { + if let Err(cause) = file.persist_noclobber(&self.destination) { + self.temporary = Some(cause.file); + return Err(error( + "Could not publish Chat export without overwriting an existing file", + )); + } + } + // If this sync fails, retain the published state: cancellation may only + // remove a staging file, never the completed user destination. + sync_directory( + self.destination + .parent() + .expect("canonical destination has a parent"), + ) + } +} + +#[derive(Clone, Default)] +pub(crate) struct ChatFiles { + exports: Arc>>, +} + +impl ChatFiles { + pub(crate) async fn import( + root: PathBuf, + paths: Vec, + ) -> Result, GenericError> { + let mut imports = blocking(move || import_files(&root, paths)).await?; + Ok(std::mem::take(&mut imports.files)) + } + + pub(crate) async fn read( + root: PathBuf, + source_id: String, + offset: u64, + length: u32, + ) -> Result, GenericError> { + blocking(move || read_file(&root, &source_id, offset, length)).await + } + + pub(crate) async fn release(root: PathBuf, source_id: String) -> Result<(), GenericError> { + blocking(move || { + let path = source_path(&root, &source_id)?; + match remove_snapshot(&path) { + Ok(()) => sync_directory(path.parent().expect("snapshot has a parent")), + Err(cause) if cause.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(error("Could not release Chat snapshot")), + } + }) + .await + } + + pub(crate) async fn begin_export( + &self, + root: PathBuf, + destination: PathBuf, + size: u32, + ) -> Result { + let (id, export) = + blocking(move || Ok((opaque_id()?, Export::prepare(root, destination, size)?))).await?; + self.exports + .lock() + .map_err(|_| error("Chat exports unavailable"))? + .insert(id.clone(), export); + Ok(id) + } + + pub(crate) async fn write_export( + &self, + root: PathBuf, + id: String, + offset: u64, + data: Vec, + ) -> Result<(), GenericError> { + valid_id(&id)?; + let exports = self.exports.clone(); + blocking(move || { + let mut exports = exports + .lock() + .map_err(|_| error("Chat exports unavailable"))?; + let export = exports + .get_mut(&id) + .filter(|export| export.root == root) + .ok_or_else(|| error("Unknown Chat export"))?; + export.write(offset, &data) + }) + .await + } + + pub(crate) async fn finish_export( + &self, + root: PathBuf, + id: String, + ) -> Result<(), GenericError> { + valid_id(&id)?; + let exports = self.exports.clone(); + blocking(move || { + let mut exports = exports + .lock() + .map_err(|_| error("Chat exports unavailable"))?; + let export = exports + .get_mut(&id) + .filter(|export| export.root == root) + .ok_or_else(|| error("Unknown Chat export"))?; + export.finish()?; + exports.remove(&id); + Ok(()) + }) + .await + } + + pub(crate) async fn cancel_export( + &self, + root: PathBuf, + id: String, + ) -> Result<(), GenericError> { + valid_id(&id)?; + let exports = self.exports.clone(); + blocking(move || { + let mut exports = exports + .lock() + .map_err(|_| error("Chat exports unavailable"))?; + if exports.get(&id).is_some_and(|export| export.root != root) { + return Err(error("Unknown Chat export")); + } + exports.remove(&id); + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn snapshots_survive_original_changes_and_backend_restart_until_release() { + let root = tempfile::tempdir().unwrap(); + let original = root.path().join("selected"); + let bytes = vec![42; BLOCK_SIZE as usize + 7]; + fs::write(&original, &bytes).unwrap(); + let selected = ChatFiles::import(root.path().to_path_buf(), vec![original.clone()]) + .await + .unwrap(); + let source = selected[0].source_id.clone(); + fs::write(original, b"changed").unwrap(); + // Reads require no process-local registry: only the durable source ID. + let range = ChatFiles::read(root.path().to_path_buf(), source.clone(), BLOCK_SIZE - 2, 9) + .await + .unwrap(); + assert_eq!(range, bytes[BLOCK_SIZE as usize - 2..]); + assert!( + ChatFiles::read(root.path().to_path_buf(), source.clone(), 0, MAX_READ + 1) + .await + .is_err() + ); + assert!( + ChatFiles::read(root.path().to_path_buf(), source.clone(), u64::MAX, 2) + .await + .is_err() + ); + assert!( + ChatFiles::read( + root.path().to_path_buf(), + source.clone(), + bytes.len() as u64, + 1 + ) + .await + .is_err() + ); + assert_eq!( + ChatFiles::read( + root.path().to_path_buf(), + source.clone(), + bytes.len() as u64, + 0 + ) + .await + .unwrap(), + Vec::::new() + ); + ChatFiles::release(root.path().to_path_buf(), source.clone()) + .await + .unwrap(); + ChatFiles::release(root.path().to_path_buf(), source.clone()) + .await + .unwrap(); + assert!( + ChatFiles::read(root.path().to_path_buf(), source, 0, 1) + .await + .is_err() + ); + assert!( + ChatFiles::read(root.path().to_path_buf(), "../selected".to_string(), 0, 1) + .await + .is_err() + ); + } + + #[test] + fn snapshot_corruption_is_rejected_even_when_size_does_not_change() { + let root = tempfile::tempdir().unwrap(); + let original = root.path().join("selected"); + fs::write(&original, b"original").unwrap(); + let imports = import_files(root.path(), vec![original]).unwrap(); + let id = &imports.files[0].source_id; + let path = source_path(root.path(), id).unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + } + #[cfg(not(unix))] + permissions.set_readonly(false); + fs::set_permissions(&path, permissions.clone()).unwrap(); + let mut file = OpenOptions::new().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(data_start(8))).unwrap(); + file.write_all(b"tampered").unwrap(); + permissions.set_readonly(true); + file.set_permissions(permissions).unwrap(); + drop(file); + assert!(read_file(root.path(), id, 0, 8).is_err()); + } + + #[tokio::test] + async fn export_enforces_order_size_and_no_clobber_and_cancel_keeps_completed_file() { + let root = tempfile::tempdir().unwrap(); + let scope = root.path().to_path_buf(); + let destination = root.path().join("download"); + let store = ChatFiles::default(); + let id = store + .begin_export(scope.clone(), destination.clone(), 4) + .await + .unwrap(); + assert!( + store + .write_export(scope.clone(), id.clone(), 1, vec![1]) + .await + .is_err() + ); + assert!( + store + .write_export(scope.clone(), id.clone(), 0, vec![0; 5]) + .await + .is_err() + ); + store + .write_export(scope.clone(), id.clone(), 0, vec![1, 2]) + .await + .unwrap(); + assert!( + store + .finish_export(scope.clone(), id.clone()) + .await + .is_err() + ); + assert!(!destination.exists()); + store + .write_export(scope.clone(), id.clone(), 2, vec![3, 4]) + .await + .unwrap(); + fs::write(&destination, b"existing").unwrap(); + assert!( + store + .finish_export(scope.clone(), id.clone()) + .await + .is_err() + ); + assert_eq!(fs::read(&destination).unwrap(), b"existing"); + fs::remove_file(&destination).unwrap(); + store + .finish_export(scope.clone(), id.clone()) + .await + .unwrap(); + store + .cancel_export(scope.clone(), id.clone()) + .await + .unwrap(); + store.cancel_export(scope.clone(), id).await.unwrap(); + assert_eq!(fs::read(&destination).unwrap(), [1, 2, 3, 4]); + let partial = root.path().join("partial"); + let id = store + .begin_export(scope.clone(), partial.clone(), 1) + .await + .unwrap(); + store + .cancel_export(scope.clone(), id.clone()) + .await + .unwrap(); + store.cancel_export(scope, id).await.unwrap(); + assert!(!partial.exists()); + } + + #[test] + fn failed_multi_selection_does_not_retain_partial_snapshots() { + let root = tempfile::tempdir().unwrap(); + let selected = root.path().join("selected"); + fs::write(&selected, b"data").unwrap(); + assert!(import_files(root.path(), vec![selected, root.path().join("missing")]).is_err()); + assert_eq!( + fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), + 0 + ); + } + + #[test] + fn oversized_native_files_are_rejected_before_importing_bytes() { + let root = tempfile::tempdir().unwrap(); + let selected = root.path().join("oversized"); + File::create(&selected) + .unwrap() + .set_len(u64::from(u32::MAX) + 1) + .unwrap(); + assert!(import_files(root.path(), vec![selected]).is_err()); + assert_eq!( + fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), + 0 + ); + } + + #[tokio::test] + async fn exact_read_limit_and_identity_scope_are_enforced() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let selected = first.path().join("selected"); + let bytes = vec![17; MAX_READ as usize]; + fs::write(&selected, &bytes).unwrap(); + let imported = ChatFiles::import(first.path().to_path_buf(), vec![selected]) + .await + .unwrap(); + let source = imported[0].source_id.clone(); + assert_eq!( + ChatFiles::read(first.path().to_path_buf(), source.clone(), 0, MAX_READ) + .await + .unwrap(), + bytes + ); + assert!( + ChatFiles::read(second.path().to_path_buf(), source, 0, 1) + .await + .is_err() + ); + let store = ChatFiles::default(); + let destination = first.path().join("download"); + let id = store + .begin_export(first.path().to_path_buf(), destination.clone(), 0) + .await + .unwrap(); + assert!( + store + .finish_export(second.path().to_path_buf(), id.clone()) + .await + .is_err() + ); + assert!( + store + .cancel_export(second.path().to_path_buf(), id.clone()) + .await + .is_err() + ); + store + .finish_export(first.path().to_path_buf(), id.clone()) + .await + .unwrap(); + store + .cancel_export(first.path().to_path_buf(), id) + .await + .unwrap(); + assert_eq!(fs::read(destination).unwrap(), Vec::::new()); + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index aaf46375d..9b01817eb 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -17,6 +17,7 @@ mod attestation; mod bootstrap; mod chain; mod chat; +mod chat_files; mod dotns_read; mod frame_server; mod network; @@ -357,7 +358,7 @@ struct PairingHostArgs { /// Network preset that supplies all RPC/backend/genesis config. #[arg(long, value_enum, default_value = "paseo-next-v2")] network: Network, - /// Approve every confirmation without prompting on the CLI. + /// Automatically approve non-payment confirmations. Main-purse payments still require review. #[arg(long)] auto_accept: bool, } @@ -383,6 +384,9 @@ struct DevArgs { /// Network preset that supplies all RPC/backend/genesis config. #[arg(long, value_enum, default_value = "paseo-next-v2")] network: Network, + /// Trusted Coinage asset instance. Required for instance-scoped Coinage runtimes. + #[arg(long, env = "TRUAPI_COINAGE_INSTANCE_ID")] + coinage_instance_id: Option, /// Persistent signing-host session to restore or create. #[arg(long)] session: Option, @@ -446,11 +450,14 @@ struct SigningHostArgs { /// Network preset that supplies all RPC/backend/genesis config. #[arg(long, value_enum, default_value = "paseo-next-v2")] network: Network, + /// Trusted Coinage asset instance. Required for instance-scoped Coinage runtimes. + #[arg(long, env = "TRUAPI_COINAGE_INSTANCE_ID")] + coinage_instance_id: Option, /// TCP address to serve product WebSocket frames on. When omitted, use a /// private per-process Unix-domain socket. #[arg(long)] frame_listen: Option, - /// Approve every confirmation without prompting on the CLI. + /// Automatically approve non-payment confirmations. Main-purse payments still require review. #[arg(long)] auto_accept: bool, /// Serve product frames without a terminal UI and stay up until stopped. @@ -458,6 +465,7 @@ struct SigningHostArgs { /// endpoint and every lifecycle event are logged one line at a time, and /// the signer is ready once "Signing host ready" is printed. Pair it with /// `--auto-accept`, because a process with no terminal cannot prompt. + /// Main-purse payments cannot be approved in unattended serve mode. #[arg(long)] serve: bool, /// Local product config declaring `trustedProducts`, as the publisher will @@ -1009,8 +1017,7 @@ async fn report_slot_scan( Ok(()) } -/// Map the `--auto-accept` flag to an approval policy: auto-accept, or prompt -/// each confirmation on the CLI. +/// Select the default confirmation policy; main-purse payments always prompt. fn approval_policy(auto_accept: bool) -> ApprovalPolicy { if auto_accept { ApprovalPolicy::AutoAccept @@ -1336,6 +1343,7 @@ struct SigningHostSession { catalog: SessionCatalog, profile: Option, network: NetworkConfig, + coinage_instance_id: Option, mnemonic: Option, default_account: Option, lite_username_prefix: Option, @@ -1501,6 +1509,7 @@ async fn start_signing_host( let pocket = args.execution_kind.pocket_host(); let (runtime, platform) = build_signing_runtime( network, + args.coinage_instance_id, storage_profile.path, storage_profile.product_storage_dir, approval, @@ -1561,6 +1570,7 @@ async fn start_signing_host( catalog, profile, network, + coinage_instance_id: args.coinage_instance_id, mnemonic, default_account, lite_username_prefix: normalized(args.lite_username_prefix.clone()), @@ -1573,6 +1583,7 @@ async fn start_signing_host( fn build_signing_runtime( network: NetworkConfig, + coinage_instance_id: Option, storage_path: PathBuf, product_storage_dir: PathBuf, approval: ApprovalPolicy, @@ -1586,7 +1597,7 @@ fn build_signing_runtime( approval, ui, ); - let config = SigningHostConfig::new( + let mut config = SigningHostConfig::new( host_info("Headless Signing Host"), platform_info(), network.people_genesis, @@ -1595,6 +1606,7 @@ fn build_signing_runtime( network.network_suffix.to_string(), ) .context("invalid signing host config")?; + config.coinage_instance_id = coinage_instance_id; let status_host = platform.clone() as Arc; let runtime = Arc::new(SigningHostRuntime::with_chat_platform( platform.clone(), @@ -1602,6 +1614,9 @@ fn build_signing_runtime( tokio_spawner(), chat.map(|chat| chat as Arc), )); + runtime.set_identity_backend_host(Arc::new(attestation::CliIdentityBackendHost::new( + &runtime, network, + )?)); runtime.set_permission_status_host(status_host); if let Some(pocket) = pocket { runtime.set_pocket_platform(pocket); @@ -1925,6 +1940,7 @@ async fn run_dev( let signing = SigningHostArgs { product_id, network: args.network, + coinage_instance_id: args.coinage_instance_id, session: args.session, mnemonic: args.mnemonic, base_path: args.base_path, @@ -2134,6 +2150,7 @@ fn promote_current_profile(session: &mut SigningHostSession) -> Result<()> { let last_script = session.catalog.last_script(&promoted)?; let (runtime, platform) = build_signing_runtime( session.network, + session.coinage_instance_id, promoted.path.clone(), promoted.product_storage_dir.clone(), session.platform.approval_policy(), @@ -2894,6 +2911,7 @@ async fn switch_session(session: &mut SigningHostSession, name: String) -> Resul let last_script = session.catalog.last_script(&profile)?; let (runtime, platform) = build_signing_runtime( session.network, + session.coinage_instance_id, profile.path.clone(), profile.product_storage_dir.clone(), session.platform.approval_policy(), @@ -2984,6 +3002,7 @@ async fn import_mnemonic_session( let last_script = session.catalog.last_script(&profile)?; let (runtime, platform) = build_signing_runtime( session.network, + session.coinage_instance_id, profile.path.clone(), profile.product_storage_dir.clone(), session.platform.approval_policy(), @@ -3324,7 +3343,7 @@ async fn signing_interactive_loop( ui.success( "Approval mode set to automatic", Some( - "Future product confirmations will be approved automatically.".to_string(), + "Future product confirmations will be approved automatically, except main-purse payments, which always require review.".to_string(), ), ); } @@ -4267,24 +4286,6 @@ test -s "$TRUAPI_DEV_COMMAND_TEST_READY_PATH" ); } - #[test] - fn serve_ready_names_the_endpoint_and_the_approval_policy() { - let prompting = terminal_ui::SystemEvent::ServeReady { - url: "ws://127.0.0.1:9955".to_string(), - auto_accept: false, - } - .human(); - assert!(prompting.contains("ws://127.0.0.1:9955")); - assert!(prompting.contains("--auto-accept")); - - let accepting = terminal_ui::SystemEvent::ServeReady { - url: "ws://127.0.0.1:9955".to_string(), - auto_accept: true, - } - .human(); - assert!(accepting.contains("approved automatically")); - } - #[test] fn signing_host_accepts_a_startup_session() { let cli = Cli::try_parse_from([ diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index ee965c289..747c2e1fc 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -45,6 +45,7 @@ impl Network { bulletin_genesis: PASEO_BULLETIN.genesis, asset_hub_genesis: PASEO_ASSET_HUB.genesis, live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + hop_endpoints: PASEO_HOP_ENDPOINTS, }, Self::Previewnet => NetworkConfig { id: "previewnet", @@ -57,6 +58,7 @@ impl Network { bulletin_genesis: PREVIEWNET_BULLETIN.genesis, asset_hub_genesis: PREVIEWNET_ASSET_HUB.genesis, live_chain_endpoints: PREVIEWNET_CHAIN_ENDPOINTS, + hop_endpoints: PREVIEWNET_HOP_ENDPOINTS, }, } } @@ -135,6 +137,36 @@ const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = const PREVIEWNET_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[PREVIEWNET_ASSET_HUB, PREVIEWNET_PEOPLE, PREVIEWNET_BULLETIN]; +// Trusted endpoint/identity pairs from brevity-chain presets.rs and +// remote_config.json (chains_v2) at d504259b60b88ca42f70a8378186a714887ef19f. +// Do not infer a HOP endpoint from a chain RPC URL. +const PASEO_HOP_ENDPOINTS: &[HopEndpoint] = &[ + HopEndpoint { + bulletin_genesis: PASEO_BULLETIN.genesis, + ws: "wss://paseo-hop-next-0.polkadot.io", + }, + HopEndpoint { + bulletin_genesis: PASEO_BULLETIN.genesis, + ws: "wss://paseo-hop-next-1.polkadot.io", + }, +]; + +// This trusted snapshot identifies a newer Previewnet Bulletin than the CLI's +// existing chain preset. Keep the exact pair: the provider fails closed until +// the selected Bulletin identity matches, rather than silently changing chains. +const PREVIEWNET_HOP_ENDPOINTS: &[HopEndpoint] = &[HopEndpoint { + bulletin_genesis: hex_literal_genesis( + "1144acd27f0e5b2c88da7dc12c111e396983dec036ccfb42da5bbb0dd7104e89", + ), + ws: "wss://previewnet.substrate.dev/bulletin", +}]; + +#[derive(Debug, Clone, Copy)] +pub struct HopEndpoint { + pub bulletin_genesis: [u8; 32], + pub ws: &'static str, +} + /// Resolved RPC/backend/genesis values for one network preset. #[derive(Debug, Clone, Copy)] pub struct NetworkConfig { @@ -162,6 +194,7 @@ pub struct NetworkConfig { /// the chain does not report sends Asset Hub traffic to the fallback chain. pub asset_hub_genesis: [u8; 32], pub live_chain_endpoints: &'static [ChainEndpoint], + pub hop_endpoints: &'static [HopEndpoint], } #[derive(Debug, Clone, Copy)] diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index f67ba21c6..f2c65b304 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -24,12 +24,15 @@ use truapi::latest as api; use truapi::v01; use truapi_platform::{ AuthState, ChainProvider, CoreStorage, CoreStorageKey, DevicePermissionStatus, Features, - JsonRpcConnection, LocaleHost, Navigation, Notifications, PermissionStatusHost, Permissions, - PreimageHost, ProductStorage, ProductStorageKey, SessionUiInfo, SignRawReview, ThemeHost, - UserConfirmation, UserConfirmationReview, + HopProvider, JsonRpcConnection, LocaleHost, NativeChatFileExportRequest, + NativeChatFilePickRequest, NativeChatFilesHost, NativeChatPickedFile, Navigation, + Notifications, PermissionStatusHost, Permissions, PreimageHost, ProductStorage, + ProductStorageKey, SessionUiInfo, SignRawReview, ThemeHost, UserConfirmation, + UserConfirmationReview, }; use crate::chain::WsChainProvider; +use crate::chat_files::{self, ChatFiles}; use crate::terminal_ui::{SystemEvent, UiHandle}; static NEXT_STORAGE_TEMP_ID: AtomicU32 = AtomicU32::new(0); @@ -102,6 +105,7 @@ pub struct CliPlatform { state_dir: Mutex>, pairing_scope: Option, preimages: Mutex, Vec>>, + chat_files: ChatFiles, next_notification_id: AtomicU32, scheduled_notifications: Arc>>, @@ -178,7 +182,8 @@ impl CliPlatform { .unwrap_or_default(); Arc::new(Self { - chain: WsChainProvider::new(network.people_ws, network.live_chain_endpoints), + chain: WsChainProvider::new(network.people_ws, network.live_chain_endpoints) + .with_hop_endpoints(network.bulletin_genesis, network.hop_endpoints), chains: network.host_chain_set(), product_storage: Mutex::new(product_storage), core_storage: Mutex::new(core_storage), @@ -189,6 +194,7 @@ impl CliPlatform { state_dir: Mutex::new(storage.as_ref().map(|paths| paths.state_dir.clone())), pairing_scope: storage.and_then(|paths| paths.pairing_scope), preimages: Mutex::new(HashMap::new()), + chat_files: ChatFiles::default(), next_notification_id: AtomicU32::new(1), scheduled_notifications: Arc::new(Mutex::new(HashMap::new())), approval: Mutex::new(approval), @@ -276,6 +282,20 @@ impl CliPlatform { .clone() } + fn chat_file_scope(&self) -> Result { + self.state_dir() + .ok_or_else(|| chat_files::error("Durable Chat file storage is unavailable")) + } + + fn check_chat_file_scope(&self, scope: &Path) -> Result<(), api::GenericError> { + if self.state_dir().as_deref() != Some(scope) { + return Err(chat_files::error( + "Chat file session changed during terminal selection", + )); + } + Ok(()) + } + fn switch_pairing_user_storage(&self, user_id: &str) -> Result<(), String> { let Some(scope) = &self.pairing_scope else { return Ok(()); @@ -374,7 +394,17 @@ impl CliPlatform { /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. async fn decide(&self, action: &str, detail: String) -> bool { - let approved = match self.approval_policy() { + self.decide_with_policy(action, detail, self.approval_policy()) + .await + } + + async fn decide_with_policy( + &self, + action: &str, + detail: String, + policy: ApprovalPolicy, + ) -> bool { + let approved = match policy { ApprovalPolicy::AutoAccept => { if let Some(ui) = &self.ui { ui.success(format!("Approved {action} automatically"), Some(detail)); @@ -402,6 +432,112 @@ impl CliPlatform { } } +#[async_trait] +impl NativeChatFilesHost for CliPlatform { + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, api::GenericError> { + if request.max_files == 0 { + return Err(chat_files::error( + "Chat file selection requires a positive file limit", + )); + } + let root = self.chat_file_scope()?; + let ui = self.ui.as_ref().ok_or_else(|| { + chat_files::error("Chat file selection requires the interactive terminal UI") + })?; + // Never use auto-accept or a second stdin reader to choose local paths. + let _guard = self.prompt_lock.lock().await; + let detail = format!( + "Product {:?} requests attachments for {:?}. Select at most {} file(s).", + request.product_id, + request + .peer_username + .as_deref() + .unwrap_or("unnamed Chat peer"), + request.max_files, + ); + let paths = ui + .chat_file_paths(detail, request.max_files, false) + .await + .map_err(|_| chat_files::error("Chat file terminal UI is unavailable"))?; + let Some(paths) = paths else { + return Ok(Vec::new()); + }; + self.check_chat_file_scope(&root)?; + ChatFiles::import(root, paths).await + } + + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, api::GenericError> { + ChatFiles::read(self.chat_file_scope()?, source_id, offset, length).await + } + + async fn release_chat_file(&self, source_id: String) -> Result<(), api::GenericError> { + ChatFiles::release(self.chat_file_scope()?, source_id).await + } + + async fn begin_chat_file_export( + &self, + request: NativeChatFileExportRequest, + ) -> Result, api::GenericError> { + let root = self.chat_file_scope()?; + let ui = self.ui.as_ref().ok_or_else(|| { + chat_files::error("Chat file export requires the interactive terminal UI") + })?; + let _guard = self.prompt_lock.lock().await; + let detail = format!( + "Product {:?} requests export of a {}-byte attachment from {:?}.", + request.product_id, + request.metadata.size_bytes, + request + .peer_username + .as_deref() + .unwrap_or("unnamed Chat peer"), + ); + let paths = ui + .chat_file_paths(detail, 1, true) + .await + .map_err(|_| chat_files::error("Chat file terminal UI is unavailable"))?; + let Some(destination) = paths.and_then(|paths| paths.into_iter().next()) else { + return Ok(None); + }; + self.check_chat_file_scope(&root)?; + self.chat_files + .begin_export(root, destination, request.metadata.size_bytes) + .await + .map(Some) + } + + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), api::GenericError> { + self.chat_files + .write_export(self.chat_file_scope()?, export_id, offset, data) + .await + } + + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), api::GenericError> { + self.chat_files + .finish_export(self.chat_file_scope()?, export_id) + .await + } + + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), api::GenericError> { + self.chat_files + .cancel_export(self.chat_file_scope()?, export_id) + .await + } +} + /// Append one ` ` line to the approvals transcript. /// /// The line is written before the confirmation result is returned to the @@ -563,6 +699,28 @@ impl ChainProvider for CliPlatform { } } +#[async_trait] +impl HopProvider for CliPlatform { + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, api::GenericError> { + self.chain + .allowed_hop_endpoints(bulletin_genesis_hash) + .await + } + + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, api::GenericError> { + self.chain + .connect_hop(bulletin_genesis_hash, endpoint) + .await + } +} + #[async_trait] impl Navigation for CliPlatform { async fn navigate_to(&self, url: String) -> Result<(), api::HostNavigateToError> { @@ -793,7 +951,12 @@ impl UserConfirmation for CliPlatform { review: UserConfirmationReview, ) -> Result { let (action, detail) = approval_summary(&review); - Ok(self.decide(action, detail).await) + let policy = if matches!(review, UserConfirmationReview::MainPurseChatPayment(_)) { + ApprovalPolicy::Prompt + } else { + self.approval_policy() + }; + Ok(self.decide_with_policy(action, detail, policy).await) } } @@ -889,6 +1052,22 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { review.product_id ), ), + UserConfirmationReview::MainPurseChatPayment(review) => ( + "pay from your main purse", + format!( + "Product {:?} requests {}.{:02} Coinage for {:?} (identity 0x{}).\nMaximum main-purse debit: {}.{:02} Coinage.\nChain genesis: 0x{}.\nCoinage asset: {}.\nPayment operation: 0x{}.\nThis approves only this payment, not future spending.", + review.calling_product_id, + review.amount_cents / 100, + review.amount_cents % 100, + review.recipient_username.as_deref().unwrap_or("unnamed recipient"), + hex::encode(review.recipient_identity), + review.max_debit_cents / 100, + review.max_debit_cents % 100, + hex::encode(review.genesis_hash), + review.coinage_instance_id.map_or_else(|| "legacy single asset".to_string(), |id| format!("instance {id}")), + hex::encode(review.operation_id), + ), + ), } } diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs index 085610e74..24dc198d3 100644 --- a/rust/crates/truapi-host-cli/src/sessions.rs +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -614,6 +614,11 @@ fn migrate_default_profile(profile: &SessionProfile, target_path: &Path) -> Resu fs::rename(&scripts, target_path.join("scripts")) .with_context(|| format!("move {}", scripts.display()))?; } + let chat_files = profile.path.join(crate::chat_files::DIRECTORY); + if chat_files.is_dir() { + fs::rename(&chat_files, target_path.join(crate::chat_files::DIRECTORY)) + .map_err(|_| anyhow::anyhow!("could not move private Chat file storage"))?; + } if profile.product_storage_dir.is_dir() { fs::rename(&profile.product_storage_dir, target_path.join("storage")) .with_context(|| format!("move {}", profile.product_storage_dir.display()))?; @@ -1258,6 +1263,25 @@ mod tests { Ok(()) } + #[tokio::test] + async fn promoting_the_default_profile_preserves_chat_source_custody() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.ensure_profile(DEFAULT_SESSION_NAME)?; + let selected = temporary.path().join("selected"); + fs::write(&selected, b"attachment")?; + let sources = crate::chat_files::ChatFiles::import(profile.path.clone(), vec![selected]) + .await + .expect("import selected file"); + let promoted = catalog.promote_to_user(&profile, "alice.dot")?; + let bytes = + crate::chat_files::ChatFiles::read(promoted.path, sources[0].source_id.clone(), 0, 10) + .await + .expect("read source after identity promotion"); + assert_eq!(bytes, b"attachment"); + Ok(()) + } + #[test] fn session_metadata_preserves_user_id_and_last_script() -> Result<()> { let temporary = tempdir()?; diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 8d2f3fc31..de4a36cb3 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -309,6 +309,12 @@ enum UiEvent { detail: String, response: oneshot::Sender, }, + ChatFiles { + detail: String, + max_files: u32, + export: bool, + response: oneshot::Sender>>, + }, Connection(String), Session { name: String, @@ -611,6 +617,26 @@ impl UiHandle { } answer.await.unwrap_or(false) } + + /// Collect paths only through the existing terminal event owner. Input is + /// never submitted to command history, the transcript, or another reader. + pub async fn chat_file_paths( + &self, + detail: String, + max_files: u32, + export: bool, + ) -> Result>> { + let (response, answer) = oneshot::channel(); + self.sender + .send(UiEvent::ChatFiles { + detail, + max_files, + export, + response, + }) + .map_err(|_| anyhow::anyhow!("Chat file terminal UI is unavailable"))?; + answer.await.context("Chat file terminal UI closed") + } } /// Inactive terminal UI whose handle can be installed in the host platform. @@ -944,6 +970,10 @@ impl ActiveTerminalUi { return Ok(DriveResult::Cancelled); }; let event = event.context("read terminal event")?; + if self.app.pending_chat_files.is_some() { + self.app.handle_busy_event(event); + continue; + } match pairing_image_request(&event) { Some(PairingImageRequest::Clipboard) => { match self.read_clipboard_image() { @@ -1012,6 +1042,14 @@ impl ActiveTerminalUi { } fn draw(&mut self) -> Result<()> { + if self + .app + .pending_chat_files + .as_ref() + .is_some_and(|pending| pending.response.is_closed()) + { + self.app.answer_chat_files(true); + } let app = &mut self.app; self.terminal .as_mut() @@ -1215,6 +1253,14 @@ struct PendingApproval { saved_input: String, } +struct PendingChatFiles { + response: oneshot::Sender>>, + saved_input: String, + paths: Vec, + max_files: u32, + export: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HostRole { Pairing, @@ -1231,6 +1277,7 @@ struct App { entries: VecDeque, editor: CommandEditor, pending_approval: Option, + pending_chat_files: Option, busy: Option, scroll_from_bottom: usize, transcript_height: usize, @@ -1293,6 +1340,7 @@ impl App { entries: VecDeque::new(), editor, pending_approval: None, + pending_chat_files: None, busy: None, scroll_from_bottom: 0, transcript_height: 1, @@ -1545,7 +1593,7 @@ impl App { detail, response, } => { - if self.pending_approval.is_some() { + if self.pending_approval.is_some() || self.pending_chat_files.is_some() { let _ = response.send(false); self.notice( NoticeTone::Error, @@ -1569,6 +1617,48 @@ impl App { saved_input, }); } + UiEvent::ChatFiles { + detail, + max_files, + export, + response, + } => { + if self.pending_approval.is_some() + || self.pending_chat_files.is_some() + || max_files == 0 + { + // Dropping the sender reports unavailable, not user cancellation. + return; + } + let saved_input = self.editor.text(); + self.editor.clear(); + self.notice( + NoticeTone::Info, + if export { + "Export Chat attachment" + } else { + "Select Chat attachments" + } + .to_string(), + Some(detail), + ); + self.notice( + NoticeTone::Info, + if export { + "Enter a new destination file path (without shell quotes). Existing files are never overwritten." + } else { + "Enter one file path at a time (without shell quotes). Empty Enter confirms the selection; Esc cancels." + }.to_string(), + None, + ); + self.pending_chat_files = Some(PendingChatFiles { + response, + saved_input, + paths: Vec::new(), + max_files, + export, + }); + } } } @@ -1585,7 +1675,7 @@ impl App { Some(format!( "{url}\n{}", if auto_accept { - "Confirmations are approved automatically" + "Non-payment confirmations are approved automatically; main-purse payments require a terminal review and are denied here" } else { "Confirmations will be denied: there is no terminal to prompt on, so pass --auto-accept" } @@ -1925,6 +2015,10 @@ impl App { if self.handle_scroll_key(key) { return None; } + if self.pending_chat_files.is_some() { + self.handle_chat_files_key(key); + return None; + } if self.pending_approval.is_some() { self.handle_approval_key(key); return None; @@ -1949,6 +2043,10 @@ impl App { if self.handle_scroll_key(key) { return false; } + if self.pending_chat_files.is_some() { + self.handle_chat_files_key(key); + return false; + } if self.pending_approval.is_some() { self.handle_approval_key(key); return false; @@ -1969,6 +2067,14 @@ impl App { } fn insert_paste(&mut self, text: &str) { + if self.pending_chat_files.is_some() && text.chars().any(char::is_control) { + self.notice( + NoticeTone::Warning, + "Paste one path without control characters".to_string(), + None, + ); + return; + } for character in text.chars().filter(|character| !character.is_control()) { self.editor.insert(character); } @@ -2032,6 +2138,66 @@ impl App { None } + fn handle_chat_files_key(&mut self, key: KeyEvent) { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match (control, key.code) { + (false, KeyCode::Esc) | (true, KeyCode::Char('c')) => self.answer_chat_files(true), + (false, KeyCode::Enter) => { + let text = self.editor.text(); + if text.is_empty() { + self.answer_chat_files(false); + return; + } + let pending = self + .pending_chat_files + .as_mut() + .expect("active file prompt"); + if pending.paths.len() >= pending.max_files as usize { + self.editor.clear(); + self.notice( + NoticeTone::Warning, + "Selection limit reached; empty Enter confirms, Esc cancels".to_string(), + None, + ); + return; + } + pending.paths.push(PathBuf::from(text)); + let export = pending.export; + let count = pending.paths.len(); + self.editor.clear(); + if export { + self.answer_chat_files(false); + } else { + self.notice( + NoticeTone::Info, + format!("{count} Chat file(s) selected"), + None, + ); + } + } + (false, KeyCode::Char(character)) if !character.is_control() => { + self.editor.insert(character) + } + (false, KeyCode::Backspace) => self.editor.backspace(), + (false, KeyCode::Delete) => self.editor.delete(), + (false, KeyCode::Left) => self.editor.left(), + (false, KeyCode::Right) => self.editor.right(), + (false, KeyCode::Home) => self.editor.home(), + (false, KeyCode::End) => self.editor.end(), + _ => {} + } + } + + fn answer_chat_files(&mut self, cancelled: bool) { + let Some(pending) = self.pending_chat_files.take() else { + return; + }; + self.editor.clear(); + self.editor.set_text(pending.saved_input); + let paths = (!cancelled && !pending.paths.is_empty()).then_some(pending.paths); + let _ = pending.response.send(paths); + } + fn handle_approval_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match (control, key.code) { @@ -2134,7 +2300,7 @@ impl App { } fn render(frame: &mut ratatui::Frame<'_>, app: &mut App) { - let completions = if app.pending_approval.is_some() { + let completions = if app.pending_approval.is_some() || app.pending_chat_files.is_some() { Vec::new() } else { app.editor.completions() @@ -2282,7 +2448,11 @@ fn render(frame: &mut ratatui::Frame<'_>, app: &mut App) { let approval = app.pending_approval.is_some(); let raw_input = app.editor.text(); - let input = mask_mnemonic(&raw_input).unwrap_or(raw_input); + let input = if app.pending_chat_files.is_some() { + raw_input + } else { + mask_mnemonic(&raw_input).unwrap_or(raw_input) + }; let prompt_area = Rect::new( composer_content_area.x, surface_area @@ -2296,21 +2466,22 @@ fn render(frame: &mut ratatui::Frame<'_>, app: &mut App) { app.editor.cursor(), prompt_area.width.saturating_sub(2), ); - let (prompt_text, prompt_style) = if input.is_empty() && !approval { - ( - "Type / for commands".to_string(), - Style::default().add_modifier(Modifier::DIM), - ) - } else { - ( - viewport.text, - if approval { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }, - ) - }; + let (prompt_text, prompt_style) = + if input.is_empty() && !approval && app.pending_chat_files.is_none() { + ( + "Type / for commands".to_string(), + Style::default().add_modifier(Modifier::DIM), + ) + } else { + ( + viewport.text, + if approval { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ) + }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled( @@ -2380,6 +2551,13 @@ fn composer_status_line( } fn footer_text(app: &App, approval: bool, autocomplete: bool, width: u16) -> String { + if let Some(pending) = &app.pending_chat_files { + return if pending.export { + "Enter destination · Esc cancel".to_string() + } else { + "Enter add path · empty Enter finish · Esc cancel".to_string() + }; + } if approval { if app.scroll_from_bottom > 0 { return "y approve · n deny · End latest · PgUp/PgDn".to_string(); @@ -3129,6 +3307,68 @@ mod tests { assert!(app.pending_approval.is_none()); } + #[test] + fn chat_file_paths_are_private_and_restore_the_command_draft() { + let mut app = test_app(); + app.editor.set_text("/script draft.ts"); + let (response, answer) = oneshot::channel(); + app.handle_event(UiEvent::ChatFiles { + detail: "Send files to a Chat peer".to_string(), + max_files: 1, + export: false, + response, + }); + app.handle_idle_event(Event::Paste("/private/selected file".to_string())); + app.handle_idle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + app.handle_idle_event(Event::Paste("/private/too many".to_string())); + app.handle_idle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + app.handle_idle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + assert_eq!( + answer.blocking_recv().unwrap(), + Some(vec![PathBuf::from("/private/selected file")]) + ); + assert_eq!(app.editor.text(), "/script draft.ts"); + assert!(!app.transcript_text().contains("/private/")); + app.editor.up(); + assert!(!app.editor.text().contains("/private/")); + } + + #[test] + fn chat_file_cancellation_discards_paths_without_answering_an_approval() { + let mut app = test_app(); + let (response, answer) = oneshot::channel(); + app.handle_event(UiEvent::ChatFiles { + detail: String::new(), + max_files: 2, + export: false, + response, + }); + app.handle_busy_event(Event::Paste("/private/file".to_string())); + app.handle_busy_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + let (response, approval) = oneshot::channel(); + app.handle_event(UiEvent::Approval { + action: "pay".to_string(), + detail: String::new(), + response, + }); + assert!(!approval.blocking_recv().unwrap()); + app.handle_busy_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); + assert_eq!(answer.blocking_recv().unwrap(), None); + assert!(!app.transcript_text().contains("/private/")); + } + #[test] fn ctrl_u_and_ctrl_d_scroll_half_a_viewport() { let mut app = test_app(); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index e90217996..108943486 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -37,9 +37,10 @@ use truapi::latest::{ HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocaleSubscribeItem, - HostNavigateToError, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, - HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, - HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostNativeChatAttachmentMetadata, HostNavigateToError, HostPlatform, + HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, + HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, LegacyAccountTxPayload, NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, RemotePermissionResponse, RingLocation, @@ -109,6 +110,10 @@ pub struct SigningHostConfig { /// ring-VRF keys. Must match the People chain's /// `NetworkSuffix.NetworkSuffix` value used for proof contexts. pub network_suffix: String, + /// Trusted Coinage asset instance for runtimes with instance-scoped assets. + /// Required by those runtimes; `None` preserves the legacy Coinage ABI. + /// This is not a purse derivation identifier. + pub coinage_instance_id: Option, } /// Product identity attached to one product-facing TrUAPI connection. @@ -244,6 +249,7 @@ impl SigningHostConfig { bulletin_chain_genesis_hash, asset_hub_chain_genesis_hash, network_suffix, + coinage_instance_id: None, }) } } @@ -1333,7 +1339,153 @@ pub trait ChainProvider: Send + Sync { ) -> Result, GenericError>; } -/// A live JSON-RPC connection to a chain. +/// Trusted native Chat selection context; never passed to a product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct NativeChatFilePickRequest { + /// Authenticated product requesting selection. + pub product_id: String, + /// Host-authenticated recipient identity. + pub peer_identity: [u8; 32], + /// Host-resolved recipient name, if available. + pub peer_username: Option, + /// Maximum number of files the Host can accept. + pub max_files: u32, +} + +/// Immutable Host-owned source and metadata derived from its actual bytes. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct NativeChatPickedFile { + /// Opaque private handle surviving restart until explicitly released. + pub source_id: String, + /// Actual source size and native media metadata. + pub metadata: HostNativeChatAttachmentMetadata, +} + +/// Trusted context for exporting a verified native Chat attachment. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct NativeChatFileExportRequest { + /// Authenticated product requesting presentation. + pub product_id: String, + /// Host-authenticated peer identity. + pub peer_identity: [u8; 32], + /// Host-resolved peer name, if available. + pub peer_username: Option, + /// Verified attachment metadata, including the exact export size. + pub metadata: HostNativeChatAttachmentMetadata, +} + +/// Host-private native Chat selection, immutable custody and safe export. +/// +/// Handles, file bytes and destinations must never be exposed to products. +/// Optional embedders without a backend must fail explicitly as unavailable. +#[async_trait] +pub trait NativeChatFilesHost: Send + Sync { + /// Present trusted selection and durably snapshot the selected files. + /// An empty result denotes user cancellation, not an unavailable backend. + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, GenericError>; + + /// Read exactly `length` bytes from an immutable source. Reject lengths + /// above 2,000,000 and checked ranges extending beyond its actual u32 size. + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, GenericError>; + + /// Release durable source custody. Repeated release is harmless. + async fn release_chat_file(&self, source_id: String) -> Result<(), GenericError>; + + /// Present trusted export consent and create a private partial output. + /// `None` denotes user cancellation. Never overwrite without consent. + async fn begin_chat_file_export( + &self, + request: NativeChatFileExportRequest, + ) -> Result, GenericError>; + + /// Append a bounded chunk at the exact current offset; never allow holes, + /// rewrites, or bytes beyond the declared export size. + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), GenericError>; + + /// Publish only an exact-size completed output through safe native UI. + /// Never automatically execute HTML, SVG, scripts, or other active files. + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), GenericError>; + + /// Idempotently discard a partial output, never a completed user export. + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), GenericError>; +} + +/// Host-private HOP transport for the configured Bulletin chain. +/// +/// Endpoints are trusted host configuration, never product-supplied dialing +/// instructions. Implementations must re-read their allowlist and enforce exact +/// URL membership before opening a connection. An unconfigured host is +/// explicitly unavailable; it must not fall back to a chain RPC endpoint. +#[async_trait] +pub trait HopProvider: Send + Sync { + /// Current exact WSS endpoints from the host's trusted Bulletin registry. + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, GenericError> { + let _ = bulletin_genesis_hash; + Ok(Vec::new()) + } + + /// Open one private HOP connection, sharing the JSON-RPC lifecycle only. + /// The caller closes the returned lease when the private operation ends. + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, GenericError> { + let _ = (bulletin_genesis_hash, endpoint); + Err(GenericError { + reason: "HOP provider unavailable".to_string(), + }) + } +} + +/// Check an endpoint without normalizing it into a different allowlist entry. +pub fn ensure_allowed_hop_endpoint(endpoint: &str, allowed: &[String]) -> Result<(), GenericError> { + let valid = endpoint.starts_with("wss://") + && !endpoint + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) + && !endpoint.contains(['#', '\\']) + && !endpoint[6..] + .split(['/', '?']) + .next() + .unwrap_or_default() + .contains('@') + && url::Url::parse(endpoint).is_ok_and(|url| { + url.scheme() == "wss" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + }) + && allowed.iter().any(|entry| entry == endpoint); + if !valid { + return Err(GenericError { + reason: "HOP endpoint is not in the trusted Bulletin WSS allowlist".to_string(), + }); + } + Ok(()) +} + +/// A live JSON-RPC connection to a host-selected service. pub trait JsonRpcConnection: Send + Sync { /// Send a JSON-RPC request string. fn send(&self, request: String); @@ -1438,6 +1590,53 @@ pub enum CoreStorageKey { /// Product whose manifest was cached, normalized. product_id: String, }, + /// Encrypted main-purse inventory, operation journal, and claim recovery state. + /// + /// This slot is wallet-owned, not product-owned, and must survive clearing a + /// product's data. Writes replace the entire value atomically. + #[codec(index = 13)] + MainPurseCoinage { + /// Root public key identifying the wallet. + root_public_key: [u8; 32], + /// Chain whose Coinage inventory is recorded. + genesis_hash: [u8; 32], + }, + /// Encrypted Host-owned native Chat device, peer roster, and migration state. + #[codec(index = 14)] + NativeChatDevice { + /// Wallet owning the Chat identity. + root_public_key: [u8; 32], + /// Host-selected Chat network. + genesis_hash: [u8; 32], + /// Authenticated product using the device. + product_id: String, + }, + /// One encrypted, bounded native Chat attachment cache chunk. + /// Values use the Chat state's wallet-bound authenticated encryption. + #[codec(index = 15)] + NativeChatFileChunk { + /// Wallet owning the attachment. + root_public_key: [u8; 32], + /// Host-selected Chat network. + genesis_hash: [u8; 32], + /// Authenticated product using the device. + product_id: String, + /// Public opaque attachment identifier, not a source handle or ticket. + attachment_id: [u8; 32], + /// Exact chunk index within the authenticated file root. + chunk_index: u32, + }, + /// Previously initialized Chat products to restore after this wallet unlocks. + /// + /// This is a host-private wallet-owned index, not a permission grant. The + /// core rechecks each product's current grants before restoring reception. + #[codec(index = 16)] + NativeChatProducts { + /// Wallet owning the installed Chat devices. + root_public_key: [u8; 32], + /// Host-selected Chat network. + genesis_hash: [u8; 32], + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1491,6 +1690,12 @@ pub fn describe_core_storage_key( CoreStorageKey::DeviceEncryptionKey => ("DeviceEncryptionKey", None), CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None), CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)), + CoreStorageKey::MainPurseCoinage { .. } => ("MainPurseCoinage", None), + CoreStorageKey::NativeChatDevice { .. } => ("NativeChatDevice", None), + CoreStorageKey::NativeChatProducts { .. } => ("NativeChatProducts", None), + CoreStorageKey::NativeChatFileChunk { product_id, .. } => { + ("NativeChatFileChunk", Some(product_id)) + } }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -1662,6 +1867,34 @@ fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissi mod tests { use super::*; + #[test] + fn hop_dialing_requires_exact_secure_endpoint_membership() { + let allowed = vec!["wss://hop.example/rpc".to_string()]; + assert!(ensure_allowed_hop_endpoint(&allowed[0], &allowed).is_ok()); + for endpoint in [ + "wss://hop.example/rpc/", + "wss://HOP.example/rpc", + "wss://hop.example:443/rpc", + "wss://hop.example/rpc?other=1", + "wss://hop.example.evil/rpc", + ] { + assert!(ensure_allowed_hop_endpoint(endpoint, &allowed).is_err()); + } + // Even malformed trusted configuration must not weaken the dial policy. + for endpoint in [ + "ws://hop.example/rpc", + "https://hop.example/rpc", + "wss://user:secret@hop.example/rpc", + "wss://@hop.example/rpc", + "wss://hop.example\\rpc", + "wss://hop.example/rpc#fragment", + "wss://hop.example/\nrpc", + " wss://hop.example/rpc", + ] { + assert!(ensure_allowed_hop_endpoint(endpoint, &[endpoint.to_string()]).is_err()); + } + } + fn signing_host_config( network_suffix: &str, ) -> Result { @@ -2770,10 +3003,10 @@ mod tests { /// they accumulate for the life of the install. /// /// [`describe_core_storage_key`] names the product owning a slot: -/// [`CoreStorageKeyDescription::product_id`] is `Some` exactly for the -/// product-indexed variants, which are `PermissionAuthorization`, -/// `AutoSigningKey`, and `ProductSubtree`. Keying host storage by that value -/// makes the sweep a prefix delete rather than a scan. +/// [`CoreStorageKeyDescription::product_id`] is `Some` for product-indexed +/// slots, including the encrypted attachment chunk cache. Wallet-owned state +/// remains outside that sweep. Keying host storage by this metadata makes +/// product removal a prefix delete rather than a scan. #[async_trait] pub trait CoreStorage: Send + Sync { /// Read a core-owned value by typed slot. @@ -3008,6 +3241,31 @@ pub struct ChatAuthorityReview { pub product_id: String, } +/// Exact Host-resolved payment reviewed before debiting the user's main purse. +/// +/// This review never grants a reusable spending permission. Chat authority and +/// automatic product signing do not authorize it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct MainPurseChatPaymentReview { + /// Authenticated product requesting this payment. + pub calling_product_id: String, + /// Recipient identity authenticated by the Host's native Chat session. + pub recipient_identity: [u8; 32], + /// Host-resolved username for that identity, never a product display label. + pub recipient_username: Option, + /// Exact recipient amount in cents of the selected Coinage asset. + pub amount_cents: u64, + /// Maximum main-purse debit, including any approved fee, in the same cents. + pub max_debit_cents: u64, + /// Genesis hash of the Host-selected Coinage chain. + pub genesis_hash: [u8; 32], + /// Trusted Coinage asset instance; None denotes a legacy single-asset runtime. + pub coinage_instance_id: Option, + /// Immutable, wallet-scoped payment operation being authorized. + pub operation_id: [u8; 32], +} + /// Review shown before a product resolves its own account subtree over SSO, /// when the value is not cached and the core must ask the Account Holder. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -3056,6 +3314,8 @@ pub enum UserConfirmationReview { ProductSubtree(ProductSubtreeReview), /// Allow a product to bind and use wallet-held Chat identity authority. ChatAuthority(ChatAuthorityReview), + /// Confirm this exact main-purse payment; never eligible for auto-approval. + MainPurseChatPayment(MainPurseChatPaymentReview), } /// Local user confirmation UI for sensitive core-owned operations. @@ -3082,6 +3342,21 @@ pub trait LocaleHost: Send + Sync { -> BoxStream<'static, Result>; } +/// Optional host-authenticated username candidate source. The host owns backend +/// configuration and credentials. Candidates are never ownership assertions: +/// the core checks finalized dotNS ownership and the canonical People Chat key. +#[async_trait] +pub trait IdentityBackendHost: Send + Sync { + /// Return exact-name candidates on the specified People network. Reject + /// unavailable configuration/authentication and incomplete search results. + /// Neither a guest URL nor a backend display label crosses this boundary. + async fn identity_username_candidates( + &self, + username: String, + people_chain_genesis_hash: [u8; 32], + ) -> Result, GenericError>; +} + /// Host preimage backend. The core builds, signs, and submits the Bulletin /// `TransactionStorage.store` transaction itself; the host only owns preimage /// content retrieval (P2P/IPFS lookup). @@ -3229,6 +3504,8 @@ pub trait Platform: + ProductStorage + CoreStorage + ChainProvider + + HopProvider + + NativeChatFilesHost + AuthPresenter + UserConfirmation + ThemeHost @@ -3245,6 +3522,8 @@ impl Platform for T where + ProductStorage + CoreStorage + ChainProvider + + HopProvider + + NativeChatFilesHost + AuthPresenter + UserConfirmation + ThemeHost @@ -3257,6 +3536,12 @@ impl Platform for T where /// omits one is not broken: the core answers the corresponding product calls /// with `Unsupported`. Codegen reads this list to emit each capability as an /// optional group on the host-callback surface. -pub trait OptionalPlatform: ChatPlatform + PermissionStatusHost + PocketPlatform {} +pub trait OptionalPlatform: + ChatPlatform + PermissionStatusHost + PocketPlatform + IdentityBackendHost +{ +} -impl OptionalPlatform for T where T: ChatPlatform + PermissionStatusHost + PocketPlatform {} +impl OptionalPlatform for T where + T: ChatPlatform + PermissionStatusHost + PocketPlatform + IdentityBackendHost +{ +} diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index edf5e3a84..fff1b2262 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -3,7 +3,7 @@ name = "truapi-server" version = "0.1.0" edition.workspace = true description = "TrUAPI server runtime: dispatcher, frames, SCALE, streams" -license = "MIT" +license = "MIT AND AGPL-3.0-only" [lib] crate-type = ["rlib", "cdylib", "staticlib"] @@ -34,6 +34,8 @@ ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand", "dep:base64"] truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } truapi-macros = { path = "../truapi-macros" } +truapi-coinage = { path = "../truapi-coinage" } +useragent-chat-v2 = { git = "https://github.com/paritytech/polkavm-app-kit.git", rev = "57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17", default-features = false } async-trait = "0.1" derive_more = { version = "2", features = ["debug", "display", "error", "from"] } futures = "0.3" diff --git a/rust/crates/truapi-server/LICENSE b/rust/crates/truapi-server/LICENSE new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/rust/crates/truapi-server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/crates/truapi-server/LICENSE-AGPL-3.0 b/rust/crates/truapi-server/LICENSE-AGPL-3.0 new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-server/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-server/NOTICE b/rust/crates/truapi-server/NOTICE new file mode 100644 index 000000000..76e9ee8e5 --- /dev/null +++ b/rust/crates/truapi-server/NOTICE @@ -0,0 +1,17 @@ +Original Host code retains its MIT license; see LICENSE. +This combined signing runtime includes AGPL-3.0-only code and is not an +MIT-only distribution. See LICENSE-AGPL-3.0. + +Coinage is a modified extraction from paritytech/brevity-dozer, revision +d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. +Detailed provenance accompanies the truapi-coinage crate in its NOTICE. +Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that +same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. +Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, +based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, +including local native-attachment codec and secret-zeroization modifications. + +Corresponding Source must include the exact Host source revision, all local +modifications, dependency provenance/license notices and build instructions. +Source repository: https://github.com/paritytech/host-rust-core +A repository URL alone does not provide unpublished local modifications. diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 5209eee1b..1e6068d3b 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -244,6 +244,44 @@ proof contexts. Configuration keeps local activation and key derivation available offline. The core validates supported suffixes but does not automatically check that the configured suffix matches the chain. +`SigningHostConfig.coinage_instance_id` is trusted host/network configuration +for instance-scoped Coinage runtimes. The constructor leaves it `None` for +legacy ABI compatibility; an embedder sets `Some(instance_id)` before creating +the signing runtime. Instance-scoped Coinage operations fail closed when it is +missing. This is an asset instance, not a purse derivation identifier, and is +not exposed as guest-selected configuration. +The encrypted wallet snapshot binds this configured selection across restarts; +changing it for the same root and genesis is rejected before inventory or +payment work. The trusted review identifies the selected asset alongside the +chain, recipient, amount, and debit ceiling. The asset instance does not change +the current iOS MAIN_PURSE/page-0 derivations (`//coinage//4294967295//0/` +for coins, `//coinage-ring-vrf//4294967295//0//` for vouchers) or create +a separate allocator. Snapshot version 3 refuses legacy `//pps` snapshots +without clearing them. Native iOS and Host allocator state is not shared; +same-wallet use requires reconciliation and one owner, not concurrent allocators. + +The signing runtime persists initialized Chat products in the wallet/network-owned +`CoreStorageKey::NativeChatProducts` slot (index 16). Unlock restores their existing +devices and background subscriptions only when the current `ChatAuthority` and +`StatementSubmit` grants remain authorized; it never prompts or generates a +replacement for missing device state. `clear_product_state` forgets this product +from reception without deleting wallet custody or received history. This is +in-process restoration, not an OS background scheduler. + +Native `native_describe_core_storage_key` and WASM `describeCoreStorageKey` let +embedders route permission slots to the same verified-artifact namespace used by +their product execution. Root callbacks used by restored receivers must resolve +that current namespace; copying grants into a broader wallet namespace would +defeat artifact revocation. WASM role handles accept optional execution-local raw +platform callbacks as the third `productRuntime` argument while retaining one +shared authority and wallet allocator. + +A host retaining another main-purse allocator must reject `MainPurseCoinage` +storage access explicitly rather than return an absent value and open a second +purse. This refuses incoming claims as well as outgoing spending. An identity-only +wallet loader is not another allocator; a single Host runtime may own the purse +while that loader retains responsibility for secure unlock. + ### The two roles Both implement the role-neutral **`ProductAuthority`** trait; each owns its diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 097c011cc..1151a96c1 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -683,7 +683,6 @@ impl ChainRuntime { } /// Raw JSON-RPC client for the chain identified by `genesis_hash`. - #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn rpc_client( &self, method: &'static str, diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d2e5dc6be..f7244b7ff 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -304,6 +304,23 @@ impl PairingHostRuntime { ) } + /// Scope product callbacks without creating another shared authority. + #[cfg(target_arch = "wasm32")] + pub(crate) fn product_runtime_with( + &self, + product: ProductContext, + adapters: ConnectionAdapters, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + adapters, + sink, + ) + } + /// Build a product-scoped administration handle from this pairing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] pub fn product_admin(&self, product: ProductContext) -> HostAdmin { @@ -604,7 +621,11 @@ impl SigningHostRuntime { every cross-product grant not already cached is refused" ); } - let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); + let signing_host = SigningHostRole::new( + services.clone(), + config.network_suffix, + config.coinage_instance_id, + ); Self { services, signing_host, @@ -622,6 +643,15 @@ impl SigningHostRuntime { self.services.install_permission_status_host(host) } + /// Install the trusted host's authenticated username candidate source once, + /// before serving products. Chain ownership and Chat keys remain authoritative. + pub fn set_identity_backend_host( + &self, + host: Arc, + ) -> bool { + self.services.install_identity_backend_host(host) + } + /// Install the host's [`PocketPlatform`], which owns the card collection. /// /// Set-once, so the collection cannot change hands under a running @@ -648,9 +678,8 @@ impl SigningHostRuntime { ) } - /// Build one product connection with adapters scoped to one native - /// executable while sharing this runtime's authentication and services. - #[cfg(all(not(target_arch = "wasm32"), feature = "ws-bridge"))] + /// Scope product callbacks while sharing authentication, custody and services. + #[cfg(any(target_arch = "wasm32", feature = "ws-bridge"))] pub(crate) fn product_runtime_with( &self, product: ProductContext, @@ -716,6 +745,7 @@ impl SigningHostRuntime { pub async fn clear_product_state(&self, product_id: &str) -> Result<(), v01::GenericError> { self.signing_host .clear_product_state(product_id) + .await .map_err(|error| v01::GenericError { reason: error.to_string(), }) diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b5a0f7020..332d20de6 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -341,62 +341,19 @@ pub struct CreateTransactionWithLegacyAccountRequest { pub payload: CreateTransactionLegacyPayload, } -/// Product-device Chat v2 operation carried over encrypted SSO. +/// Host-owned Chat operations carried over encrypted SSO. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoProductDeviceChatOperation { - /// Bind the product device to the wallet identity and derive peer routes. - Bind { - /// Product account index; the Account Holder re-derives the device - /// account instead of trusting a pairing-host supplied public key. - derivation_index: v01::DerivationIndex, - /// Peer wallet identity account used for directional routing. - peer_identity_account_id: [u8; 32], - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - }, - /// Seal an identity-route payload for the peer. - Seal { - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - /// Explicit legacy or context-bound cipher suite. - cipher_suite: v01::HostProductDeviceChatCipherSuite, - /// Identity-route plaintext. - plaintext: Vec, - }, - /// Open an authenticated identity-route payload from the peer. - Open { - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - /// Explicit legacy or context-bound cipher suite. - cipher_suite: v01::HostProductDeviceChatCipherSuite, - /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. - combined_ciphertext: Vec, - }, - /// Sign a canonical Chat first-contact proof payload as the product device. - SignRequestProof { - /// Product account index to derive on the signing host. - derivation_index: v01::DerivationIndex, - /// Canonical SCALE-encoded Chat request proof payload. - payload: Vec, - }, - /// Read the authorized wallet's public Chat identity. - Identity, - /// Verify an incoming peer's identity-to-device binding. - VerifyPeerDevice { - /// Peer wallet identity account. - peer_identity_account_id: [u8; 32], - /// Peer's independently resolved X25519 Chat public key. - peer_chat_public_key: [u8; 32], - /// Device account authenticated by the signed request. - peer_device_account_id: [u8; 32], - /// Keyed identity binding from the request. - proof: [u8; 32], - }, + /// The removed raw-crypto tags 0 through 5 are deliberately not accepted. + #[codec(index = 6)] + V2(truapi::latest::HostProductDeviceChatRequest), } -/// Product-device Chat v2 response returned by the Account Holder. -pub type ProductDeviceChatResponse = - Result; +/// Public Chat views and typed failures returned by the signing authority. +pub type ProductDeviceChatResponse = Result< + truapi::versioned::account::HostProductDeviceChatResponse, + truapi::versioned::account::HostProductDeviceChatError, +>; /// Versioned legacy transaction-creation payload. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -1034,14 +991,16 @@ mod tests { } #[test] - fn product_device_chat_messages_pin_mobile_wire_indices() { + fn product_device_chat_messages_preserve_typed_operations_and_failures() { let chat_request = ProductRequest { calling_product_id: "egui-chat.paseo".to_string(), - payload: SsoProductDeviceChatOperation::Bind { - derivation_index: DerivationIndex::Index(0), - peer_identity_account_id: [0x55; 32], - peer_chat_public_key: [0x66; 32], - }, + payload: SsoProductDeviceChatOperation::V2( + truapi::latest::HostProductDeviceChatRequest::SendPayment { + peer_identity: [0x55; 32], + request_id: "payment-one".to_string(), + amount_cents: 19, + }, + ), }; let request = RemoteMessage::request("request".to_string(), chat_request); let encoded_request = request.encode(); @@ -1052,9 +1011,9 @@ mod tests { ); let product_response: ProductDeviceChatResponse = - Ok(v01::HostProductDeviceChatResponse::Sealed { - combined_ciphertext: vec![0x77; 28], - }); + Err(truapi::versioned::account::HostProductDeviceChatError::V1( + truapi::latest::HostProductDeviceChatError::OperationConflict, + )); let response_envelope = Response { responding_to: "request".to_string(), payload: product_response, @@ -1067,12 +1026,25 @@ mod tests { }; let encoded_response = response.encode(); assert_eq!(encoded_response[10], 25); + assert_eq!( + RemoteMessage::decode(&mut encoded_response.as_slice()).unwrap(), + response + ); let RemoteMessageData::V1(data) = response.data; assert_eq!( ProductRequest::::response_from_message(data), Some(response_envelope) ); } + + #[test] + fn product_device_chat_rejects_removed_raw_crypto_tags() { + for tag in 0..6u8 { + let mut legacy = vec![tag]; + legacy.extend_from_slice(&[0; 256]); + assert!(SsoProductDeviceChatOperation::decode(&mut legacy.as_slice()).is_err()); + } + } #[test] fn statement_store_product_sign_messages_pin_extension_wire_indices() { let payload = unsigned_statement_signing_payload(vec![ diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs index 56c25c279..8c0ff63d9 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -6,7 +6,7 @@ use core::fmt::Display; -use truapi::{latest::HostAccountSignVrfError, v01::HostProductDeviceChatError}; +use truapi::{latest::HostAccountSignVrfError, versioned::account::HostProductDeviceChatError}; use super::messages::{RemoteMessage, RemoteMessageData, Response, RingVrfError, v1}; @@ -52,7 +52,7 @@ impl SsoError for HostAccountSignVrfError { impl SsoError for HostProductDeviceChatError { fn not_connected() -> Self { - Self::NotConnected + Self::V1(truapi::latest::HostProductDeviceChatError::NotConnected) } } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 21fcc19d0..037c46d77 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -22,9 +22,10 @@ use parity_scale_codec::Encode; use truapi::{Bytes32, latest::HostPlatform, v01}; use truapi_platform::{ AuthPresenter, AuthState, ChainProvider, CoreAdmin, CoreStorage, CoreStorageKey, Features, - HostInfo, JsonRpcConnection, LocaleHost, Navigation, Notifications, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, PlatformInfo, - PreimageHost, ProductContext, ProductExecutionKind, ProductStorage, + HopProvider, HostInfo, JsonRpcConnection, LocaleHost, NativeChatFileExportRequest, + NativeChatFilePickRequest, NativeChatFilesHost, NativeChatPickedFile, Navigation, + Notifications, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, + PlatformInfo, PreimageHost, ProductContext, ProductExecutionKind, ProductStorage, RuntimeConfigValidationError, SigningHostConfig, ThemeHost, UserConfirmation, UserConfirmationReview, async_trait, normalize_product_identifier, }; @@ -212,6 +213,10 @@ pub struct NativeHostRuntimeConfig { /// positional over the FFI and the checksum does not cover their order, so /// an insert shifts every field below it. pub asset_hub_chain_genesis_hash: Vec, + /// Trusted Coinage asset instance from the wallet's network configuration. + /// Required for instance-scoped Coinage runtimes; omit only for legacy use. + /// Appended to preserve the order of existing positional FFI record fields. + pub coinage_instance_id: Option, } /// Trusted identity attached by a native host to one executable connection. @@ -333,7 +338,7 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { actual: config.asset_hub_chain_genesis_hash.len() as u64, } })?; - let signing = SigningHostConfig::new( + let mut signing = SigningHostConfig::new( HostInfo { name: config.host_name, icon: config.host_icon, @@ -349,6 +354,7 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { asset_hub_chain_genesis_hash, config.network_suffix, )?; + signing.coinage_instance_id = config.coinage_instance_id; Ok(Self { signing, local_session_secret: config.local_session_secret, @@ -415,6 +421,31 @@ pub fn parse_navigate(input: String) -> NavigateDecision { dotns::parse_navigate(&input) } +/// Strictly decoded storage metadata for host-private namespace routing. +#[derive(Debug, Clone, uniffi::Record)] +pub struct NativeCoreStorageKeyDescription { + /// Stable core storage slot kind. + pub kind: String, + /// Product owning this slot, absent for wallet-owned state. + pub product_id: Option, +} + +/// Describe exactly one encoded storage key without duplicating SCALE in hosts. +#[uniffi::export] +pub fn native_describe_core_storage_key( + encoded: Vec, +) -> Result { + let description = truapi_platform::describe_core_storage_key(&encoded).map_err(|error| { + HostRejection::Rejected { + reason: error.to_string(), + } + })?; + Ok(NativeCoreStorageKeyDescription { + kind: description.kind.to_owned(), + product_id: description.product_id, + }) +} + /// Whether `product_id` is a first-party product the host grants every /// [`truapi::latest::RemotePermission`] without prompting. /// @@ -559,6 +590,21 @@ pub trait HostCallbacks: Send + Sync { /// connection id, or `None` when unsupported. fn chain_connect(&self, genesis_hash: Vec) -> Result, HostRejection>; + /// Current trusted HOP WSS URLs for the configured Bulletin chain. + /// An embedding with no HOP configuration returns an empty allowlist. + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: Vec, + ) -> Result, HostRejection>; + + /// Open only an exact endpoint from the current trusted Bulletin allowlist. + /// HOP ids share the chain send/close and response/closed event namespace. + fn hop_connect( + &self, + bulletin_genesis_hash: Vec, + endpoint: String, + ) -> Result, HostRejection>; + /// Send one JSON-RPC request over a previously opened chain connection. fn chain_send(&self, connection_id: u32, request: String) -> Result<(), HostRejection>; @@ -571,10 +617,56 @@ pub trait HostCallbacks: Send + Sync { review: UserConfirmationReview, ) -> Result; + /// Trusted file selection into durable immutable Host custody. + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, HostRejection>; + + /// Exact bounded read from a Host-private immutable source. + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, HostRejection>; + + /// Idempotently release a durable private source. + async fn release_chat_file(&self, source_id: String) -> Result<(), HostRejection>; + + /// Trusted export consent; `None` means user cancellation. + async fn begin_chat_file_export( + &self, + request: NativeChatFileExportRequest, + ) -> Result, HostRejection>; + + /// Contiguous bounded write into a partial private export. + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), HostRejection>; + + /// Publish an exact-size export through safe native UI. + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), HostRejection>; + + /// Idempotently discard a partial export, never a completed user export. + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), HostRejection>; + /// Look up one preimage value by key. The native shim emits this as the /// current item in its subscription stream. async fn lookup_preimage(&self, key: Vec) -> Result>, HostRejection>; + /// Exact-name AccountId32 candidates from the host's configured, + /// authenticated native username service. Every item must contain 32 bytes; + /// the core verifies finalized dotNS ownership and the canonical People key. + async fn identity_username_candidates( + &self, + username: String, + people_chain_genesis_hash: Vec, + ) -> Result>, HostRejection>; + /// Current host theme, named variant included. The native shim emits this /// as the current item in its subscription stream. fn current_theme(&self) -> Result; @@ -721,6 +813,7 @@ impl NativeTrUApiHostRuntime { runtime_config.signing, spawner.clone(), )); + runtime.set_identity_backend_host(platform.clone()); assert!( runtime.worker_ledger().install_demand_observer(platform), "a freshly built runtime installs its worker demand observer once" @@ -1679,6 +1772,74 @@ impl NativeEventBus { } } +#[async_trait] +impl NativeChatFilesHost for CallbackPlatform { + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, v01::GenericError> { + self.callbacks + .pick_chat_files(request) + .await + .map_err(Into::into) + } + + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, v01::GenericError> { + self.callbacks + .read_chat_file(source_id, offset, length) + .await + .map_err(Into::into) + } + + async fn release_chat_file(&self, source_id: String) -> Result<(), v01::GenericError> { + self.callbacks + .release_chat_file(source_id) + .await + .map_err(Into::into) + } + + async fn begin_chat_file_export( + &self, + request: NativeChatFileExportRequest, + ) -> Result, v01::GenericError> { + self.callbacks + .begin_chat_file_export(request) + .await + .map_err(Into::into) + } + + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), v01::GenericError> { + self.callbacks + .write_chat_file_export(export_id, offset, data) + .await + .map_err(Into::into) + } + + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + self.callbacks + .finish_chat_file_export(export_id) + .await + .map_err(Into::into) + } + + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + self.callbacks + .cancel_chat_file_export(export_id) + .await + .map_err(Into::into) + } +} + #[async_trait] impl Navigation for CallbackPlatform { async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { @@ -1862,6 +2023,7 @@ struct NativeJsonRpcConnection { events: Arc, response_rx: Mutex>>, closed: AtomicBool, + private_rpc: bool, } impl JsonRpcConnection for NativeJsonRpcConnection { @@ -1872,8 +2034,13 @@ impl JsonRpcConnection for NativeJsonRpcConnection { if let Err(err) = self.callbacks.chain_send(self.id, request) { self.callbacks.on_core_log( "truapi.native.callback.chain_send_failed".to_string(), - err.to_string(), + if self.private_rpc { + "HOP send failed".to_string() + } else { + err.to_string() + }, ); + self.close(); } } @@ -1899,7 +2066,11 @@ impl JsonRpcConnection for NativeJsonRpcConnection { if let Err(err) = self.callbacks.chain_close(self.id) { self.callbacks.on_core_log( "truapi.native.callback.chain_close_failed".to_string(), - err.to_string(), + if self.private_rpc { + "HOP close failed".to_string() + } else { + err.to_string() + }, ); } } @@ -1933,6 +2104,47 @@ impl ChainProvider for CallbackPlatform { events: self.events.clone(), response_rx: Mutex::new(Some(response_rx)), closed: AtomicBool::new(false), + private_rpc: false, + })) + } +} + +#[async_trait] +impl HopProvider for CallbackPlatform { + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + self.callbacks + .allowed_hop_endpoints(bulletin_genesis_hash.to_vec()) + .await + .map_err(v01::GenericError::from) + } + + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, v01::GenericError> { + let allowed = self.allowed_hop_endpoints(bulletin_genesis_hash).await?; + truapi_platform::ensure_allowed_hop_endpoint(&endpoint, &allowed)?; + let Some(connection_id) = self + .callbacks + .hop_connect(bulletin_genesis_hash.to_vec(), endpoint) + .map_err(v01::GenericError::from)? + else { + return Err(v01::GenericError { + reason: "HOP provider unavailable".to_string(), + }); + }; + let response_rx = self.events.register_chain(connection_id); + Ok(Box::new(NativeJsonRpcConnection { + id: connection_id, + callbacks: self.callbacks.clone(), + events: self.events.clone(), + response_rx: Mutex::new(Some(response_rx)), + closed: AtomicBool::new(false), + private_rpc: true, })) } } @@ -2007,6 +2219,40 @@ impl PreimageHost for CallbackPlatform { } } +#[async_trait] +impl truapi_platform::IdentityBackendHost for CallbackPlatform { + async fn identity_username_candidates( + &self, + username: String, + people_chain_genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let candidates = self + .callbacks + .identity_username_candidates(username, people_chain_genesis_hash.to_vec()) + .await + .map_err(v01::GenericError::from)?; + decode_identity_candidates(candidates) + } +} + +fn decode_identity_candidates( + candidates: Vec>, +) -> Result, v01::GenericError> { + if candidates.len() > 32 { + return Err(v01::GenericError { + reason: "too many username candidates".into(), + }); + } + candidates + .into_iter() + .map(|candidate| { + candidate.try_into().map_err(|_| v01::GenericError { + reason: "username candidate is not AccountId32".into(), + }) + }) + .collect() +} + /// [`truapi_platform::ChatPlatform`] served by host-provided /// [`NativeChatCallbacks`]; constructed only when the host passed one. struct ChatCallbackPlatform { @@ -2139,6 +2385,17 @@ mod tests { use truapi::v01::LegacyAccountTxPayload; use truapi_platform::CreateTransactionReview; + #[test] + fn native_identity_candidates_reject_malformed_accounts_and_oversized_sets() { + assert_eq!( + decode_identity_candidates(vec![vec![3; 32]]).unwrap(), + vec![[3; 32]] + ); + assert!(decode_identity_candidates(vec![vec![3; 31]]).is_err()); + assert!(decode_identity_candidates(vec![vec![3; 33]]).is_err()); + assert!(decode_identity_candidates(vec![vec![3; 32]; 33]).is_err()); + } + type PreimageFixtureEntries = Vec<(Vec, Option>)>; fn pocket_card(card_id: &str, privileged: bool) -> v01::PocketCard { @@ -2489,8 +2746,60 @@ mod tests { } } + fn unavailable_chat_files() -> Result { + Err(HostRejection::Rejected { + reason: "native Chat files unavailable in this fixture".into(), + }) + } + #[async_trait::async_trait] impl HostCallbacks for EventCallbacks { + async fn pick_chat_files( + &self, + _: NativeChatFilePickRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn read_chat_file( + &self, + _: String, + _: u64, + _: u32, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn release_chat_file(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn begin_chat_file_export( + &self, + _: NativeChatFileExportRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn write_chat_file_export( + &self, + _: String, + _: u64, + _: Vec, + ) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn finish_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn cancel_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn identity_username_candidates( + &self, + _: String, + _: Vec, + ) -> Result>, HostRejection> { + Err(HostRejection::Rejected { + reason: "no identity provider in event fixture".into(), + }) + } fn on_core_log(&self, _marker: String, _detail: String) {} fn worker_demand_changed(&self, product_id: String, transition: WorkerTransition) { self.worker_demand @@ -2547,6 +2856,12 @@ mod tests { fn core_storage_clear(&self, _key: Vec) -> Result<(), HostRejection> { Ok(()) } + async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { + Ok(Vec::new()) + } + fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { + Ok(None) + } fn chain_connect(&self, genesis_hash: Vec) -> Result, HostRejection> { self.chain_connects .lock() @@ -2755,6 +3070,7 @@ mod tests { network_suffix: "paseo".to_string(), local_session_secret: Some(vec![7; 32]), local_session_lite_username: Some("alice".to_string()), + coinage_instance_id: None, } } @@ -3823,6 +4139,52 @@ mod tests { struct Noop; #[async_trait::async_trait] impl HostCallbacks for Noop { + async fn pick_chat_files( + &self, + _: NativeChatFilePickRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn read_chat_file( + &self, + _: String, + _: u64, + _: u32, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn release_chat_file(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn begin_chat_file_export( + &self, + _: NativeChatFileExportRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn write_chat_file_export( + &self, + _: String, + _: u64, + _: Vec, + ) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn finish_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn cancel_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn identity_username_candidates( + &self, + _: String, + _: Vec, + ) -> Result>, HostRejection> { + Err(HostRejection::Rejected { + reason: "no identity provider in fixture".into(), + }) + } fn on_core_log(&self, _marker: String, _detail: String) {} fn worker_demand_changed(&self, _product_id: String, _transition: WorkerTransition) {} async fn navigate_to(&self, _url: String) -> Result<(), HostNavigateRejection> { @@ -3869,6 +4231,15 @@ mod tests { fn core_storage_clear(&self, _key: Vec) -> Result<(), HostRejection> { Ok(()) } + async fn allowed_hop_endpoints( + &self, + _: Vec, + ) -> Result, HostRejection> { + Ok(Vec::new()) + } + fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { + Ok(None) + } fn chain_connect(&self, _genesis_hash: Vec) -> Result, HostRejection> { Ok(None) } @@ -3969,6 +4340,52 @@ mod tests { #[async_trait::async_trait] impl HostCallbacks for GatedPermissionCallbacks { + async fn pick_chat_files( + &self, + _: NativeChatFilePickRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn read_chat_file( + &self, + _: String, + _: u64, + _: u32, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn release_chat_file(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn begin_chat_file_export( + &self, + _: NativeChatFileExportRequest, + ) -> Result, HostRejection> { + unavailable_chat_files() + } + async fn write_chat_file_export( + &self, + _: String, + _: u64, + _: Vec, + ) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn finish_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn cancel_chat_file_export(&self, _: String) -> Result<(), HostRejection> { + unavailable_chat_files() + } + async fn identity_username_candidates( + &self, + _: String, + _: Vec, + ) -> Result>, HostRejection> { + Err(HostRejection::Rejected { + reason: "no identity provider in permission fixture".into(), + }) + } fn on_core_log(&self, _marker: String, _detail: String) {} fn worker_demand_changed(&self, _product_id: String, _transition: WorkerTransition) {} async fn navigate_to(&self, _url: String) -> Result<(), HostNavigateRejection> { @@ -4022,6 +4439,15 @@ mod tests { fn core_storage_clear(&self, _key: Vec) -> Result<(), HostRejection> { Ok(()) } + async fn allowed_hop_endpoints( + &self, + _: Vec, + ) -> Result, HostRejection> { + Ok(Vec::new()) + } + fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { + Ok(None) + } fn chain_connect(&self, _genesis_hash: Vec) -> Result, HostRejection> { Ok(None) } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 9c5166865..54c9f7496 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -17,9 +17,14 @@ mod authority; pub(crate) mod bulletin_rpc; mod capabilities; mod chat; +mod chat_device; +mod chat_identity; +mod coinage_chain; +mod coinage_store; mod dotns_lookup; mod identity; pub(crate) mod login_failure; +mod native_chat; mod pairing_host; pub(crate) mod product_manifest; mod product_subtree; @@ -871,41 +876,10 @@ fn account_get_authority_error(err: AuthorityError) -> CallError CallError { - let error = match error { - AuthorityError::Disconnected => v01::HostProductDeviceChatError::NotConnected, - AuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, - AuthorityError::Cancelled(error) => v01::HostProductDeviceChatError::Unknown { - reason: error.to_string(), - }, - AuthorityError::Unavailable { reason } - | AuthorityError::NotSupported { reason } - | AuthorityError::Unknown { reason } => v01::HostProductDeviceChatError::Unknown { reason }, - }; - CallError::Domain(HostProductDeviceChatError::V1(error)) -} - fn product_device_chat_authority_error( error: ProductDeviceChatAuthorityError, ) -> CallError { - let error = match error { - ProductDeviceChatAuthorityError::Disconnected => { - v01::HostProductDeviceChatError::NotConnected - } - ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, - ProductDeviceChatAuthorityError::InvalidPeerKey => { - v01::HostProductDeviceChatError::InvalidPeerKey - } - ProductDeviceChatAuthorityError::InvalidCiphertext => { - v01::HostProductDeviceChatError::InvalidCiphertext - } - ProductDeviceChatAuthorityError::Unavailable(reason) => { - v01::HostProductDeviceChatError::Unknown { reason } - } - }; - CallError::Domain(HostProductDeviceChatError::V1(error)) + CallError::Domain(HostProductDeviceChatError::V1(error.into())) } fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index e8cdc3878..16a088dc9 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -14,15 +14,13 @@ use truapi::latest::{ HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, - HostCreateTransactionResponse, HostRequestResourceAllocationRequest, + HostCreateTransactionResponse, HostProductDeviceChatError, HostProductDeviceChatRequest, + HostProductDeviceChatResponse, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, VrfSignature, }; -use truapi::v01::{ - DerivationIndex, HostProductDeviceChatCipherSuite, HostProductDeviceChatResponse, -}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -291,52 +289,36 @@ pub(crate) enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } -/// Host-private Chat identity operation after product authorization. +/// Host-owned Chat operation after the caller's capability authorization. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ProductDeviceChatAuthorityRequest { - Bind { - calling_product_id: String, - device_account_id: [u8; 32], - derivation_index: DerivationIndex, - peer_identity_account_id: [u8; 32], - peer_chat_public_key: [u8; 32], - }, - Seal { - calling_product_id: String, - peer_chat_public_key: [u8; 32], - cipher_suite: HostProductDeviceChatCipherSuite, - plaintext: Vec, - }, - Open { - calling_product_id: String, - peer_chat_public_key: [u8; 32], - cipher_suite: HostProductDeviceChatCipherSuite, - combined_ciphertext: Vec, - }, - SignRequestProof { - calling_product_id: String, - product_account_id: ProductAccountId, - payload: Vec, - }, - Identity { - calling_product_id: String, - }, - VerifyPeerDevice { - calling_product_id: String, - peer_identity_account_id: [u8; 32], - peer_chat_public_key: [u8; 32], - peer_device_account_id: [u8; 32], - proof: [u8; 32], - }, +pub(crate) struct ProductDeviceChatAuthorityRequest { + pub calling_product_id: String, + pub operation: HostProductDeviceChatRequest, +} + +/// Receive may emit acknowledgments; reconciliation may resume queued delivery. +pub(crate) fn chat_requires_statement_submit(operation: &HostProductDeviceChatRequest) -> bool { + match operation { + HostProductDeviceChatRequest::Initialize + | HostProductDeviceChatRequest::PaymentStatus { .. } => false, + HostProductDeviceChatRequest::Invite { .. } + | HostProductDeviceChatRequest::Receive { .. } + | HostProductDeviceChatRequest::AcceptInvitation { .. } + | HostProductDeviceChatRequest::RejectInvitation { .. } + | HostProductDeviceChatRequest::Send { .. } + | HostProductDeviceChatRequest::SendPayment { .. } + | HostProductDeviceChatRequest::SendAttachments { .. } + | HostProductDeviceChatRequest::OpenAttachment { .. } + | HostProductDeviceChatRequest::Reconcile => true, + } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ProductDeviceChatAuthorityError { Disconnected, Rejected, - InvalidPeerKey, - InvalidCiphertext, Unavailable(String), + Domain(HostProductDeviceChatError), } impl From for ProductDeviceChatAuthorityError { @@ -349,6 +331,17 @@ impl From for ProductDeviceChatAuthorityError { } } +impl From for truapi::v02::HostProductDeviceChatError { + fn from(error: ProductDeviceChatAuthorityError) -> Self { + match error { + ProductDeviceChatAuthorityError::Disconnected => Self::NotConnected, + ProductDeviceChatAuthorityError::Rejected => Self::UserRejected, + ProductDeviceChatAuthorityError::Unavailable(_) => Self::NetworkUnavailable, + ProductDeviceChatAuthorityError::Domain(error) => error, + } + } +} + /// Whether an active AutoSigning grant covers one product-account call. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum AutoSigningGrant { @@ -572,7 +565,7 @@ pub(crate) trait ProductAuthority: Send + Sync { request: ProductRequest, ) -> Result; - /// Bind/seal/open using the active wallet's host-private Chat identity key. + /// Execute an authorized operation on the Host-owned native Chat device. async fn product_device_chat( &self, cx: &CallContext, @@ -636,326 +629,6 @@ pub(crate) trait ProductAuthority: Send + Sync { ) -> Result<[u8; 32], AuthorityError>; } -pub(super) fn execute_product_device_chat( - identity_chat_private_key: &[u8; 32], - identity_account_id: [u8; 32], - request: ProductDeviceChatAuthorityRequest, -) -> Result { - use chacha20poly1305::aead::{Aead, KeyInit, Payload}; - use chacha20poly1305::{ChaCha20Poly1305, Nonce}; - use hkdf::Hkdf; - use sha2::Sha256; - use x25519_dalek::{PublicKey, StaticSecret}; - use zeroize::Zeroizing; - - let peer_public_key = match &request { - ProductDeviceChatAuthorityRequest::Bind { - peer_chat_public_key, - .. - } - | ProductDeviceChatAuthorityRequest::Seal { - peer_chat_public_key, - .. - } - | ProductDeviceChatAuthorityRequest::Open { - peer_chat_public_key, - .. - } - | ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - peer_chat_public_key, - .. - } => *peer_chat_public_key, - ProductDeviceChatAuthorityRequest::Identity { .. } => { - return Ok(HostProductDeviceChatResponse::Identity { - identity_account_id, - chat_public_key: PublicKey::from(&StaticSecret::from(*identity_chat_private_key)) - .to_bytes(), - }); - } - ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { - return Err(ProductDeviceChatAuthorityError::Unavailable( - "Chat request proof signing must be handled by the product signing authority" - .to_string(), - )); - } - }; - if !is_canonical_x25519_public_key(&peer_public_key) { - return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); - } - let shared_secret = Zeroizing::new( - StaticSecret::from(*identity_chat_private_key) - .diffie_hellman(&PublicKey::from(peer_public_key)) - .to_bytes(), - ); - if *shared_secret == [0; 32] { - return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); - } - - return match request { - ProductDeviceChatAuthorityRequest::Bind { - device_account_id, - peer_identity_account_id, - .. - } => { - let proof = chat_device_identity_proof( - &shared_secret, - &identity_account_id, - &device_account_id, - ); - let mut proof_bytes = [0; 32]; - proof_bytes.copy_from_slice(proof.as_bytes()); - let wallet_own_session_id = chat_identity_session_id( - &shared_secret, - &identity_account_id, - &peer_identity_account_id, - ); - let peer_own_session_id = chat_identity_session_id( - &shared_secret, - &peer_identity_account_id, - &identity_account_id, - ); - let wallet_outgoing_channel_id = chat_request_channel_id( - &shared_secret, - &identity_account_id, - &peer_identity_account_id, - ); - let wallet_incoming_channel_id = chat_request_channel_id( - &shared_secret, - &peer_identity_account_id, - &identity_account_id, - ); - Ok(HostProductDeviceChatResponse::IdentityBinding { - identity_account_id, - proof: proof_bytes, - wallet_own_session_id, - peer_own_session_id, - wallet_outgoing_channel_id, - wallet_incoming_channel_id, - }) - } - ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - peer_identity_account_id, - peer_device_account_id, - proof, - .. - } => { - let expected = chat_device_identity_proof( - &shared_secret, - &peer_identity_account_id, - &peer_device_account_id, - ); - // Hash's slice comparison is constant-time for this fixed length. - Ok(HostProductDeviceChatResponse::PeerDeviceVerified { - valid: expected.eq(proof.as_slice()), - }) - } - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id, - cipher_suite, - plaintext, - .. - } => { - let (key, aad) = product_device_chat_aead_material( - &shared_secret, - &calling_product_id, - &identity_account_id, - &cipher_suite, - true, - )?; - let key = Zeroizing::new(key); - let mut nonce = [0; 12]; - getrandom::getrandom(&mut nonce).map_err(|error| { - ProductDeviceChatAuthorityError::Unavailable(format!( - "failed to generate Chat identity-route nonce: {error}" - )) - })?; - let encrypted = ChaCha20Poly1305::new((&*key).into()) - .encrypt( - Nonce::from_slice(&nonce), - Payload { - msg: &plaintext, - aad: &aad, - }, - ) - .map_err(|_| { - ProductDeviceChatAuthorityError::Unavailable( - "Chat identity-route encryption failed".to_string(), - ) - })?; - let mut combined_ciphertext = Vec::with_capacity(12 + encrypted.len()); - combined_ciphertext.extend_from_slice(&nonce); - combined_ciphertext.extend_from_slice(&encrypted); - Ok(HostProductDeviceChatResponse::Sealed { - combined_ciphertext, - }) - } - ProductDeviceChatAuthorityRequest::Open { - calling_product_id, - cipher_suite, - combined_ciphertext, - .. - } => { - if combined_ciphertext.len() < 28 { - return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); - } - let (key, aad) = product_device_chat_aead_material( - &shared_secret, - &calling_product_id, - &identity_account_id, - &cipher_suite, - false, - )?; - let key = Zeroizing::new(key); - let plaintext = ChaCha20Poly1305::new((&*key).into()) - .decrypt( - Nonce::from_slice(&combined_ciphertext[..12]), - Payload { - msg: &combined_ciphertext[12..], - aad: &aad, - }, - ) - .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; - Ok(HostProductDeviceChatResponse::Opened { plaintext }) - } - ProductDeviceChatAuthorityRequest::Identity { .. } => { - unreachable!("public identity returns before shared-key derivation") - } - ProductDeviceChatAuthorityRequest::SignRequestProof { .. } => { - Err(ProductDeviceChatAuthorityError::Unavailable( - "Chat request proof signing must be handled by the product signing authority" - .to_string(), - )) - } - }; - - fn chat_device_identity_proof( - shared_secret: &[u8; 32], - identity: &[u8; 32], - device: &[u8; 32], - ) -> blake2b_simd::Hash { - const CONTEXT: &[u8] = b"mds-chat-request"; - let mut payload = [0; 65 + CONTEXT.len()]; - payload[..32].copy_from_slice(identity); - payload[32..64].copy_from_slice(device); - payload[64] = (CONTEXT.len() as u8) << 2; - payload[65..].copy_from_slice(CONTEXT); - blake2b_simd::Params::new() - .hash_length(32) - .key(shared_secret) - .hash(&payload) - } - - fn product_device_chat_aead_material( - shared_secret: &[u8; 32], - calling_product_id: &str, - identity_account_id: &[u8; 32], - cipher_suite: &HostProductDeviceChatCipherSuite, - sealing: bool, - ) -> Result<([u8; 32], Vec), ProductDeviceChatAuthorityError> { - let mut key = [0; 32]; - let HostProductDeviceChatCipherSuite::ContextBoundV1 { - peer_account_id, - channel_id, - } = cipher_suite - else { - Hkdf::::new(Some(&[]), shared_secret) - .expand(&[], &mut key) - .map_err(|_| { - ProductDeviceChatAuthorityError::Unavailable( - "Chat identity-route HKDF failed".to_string(), - ) - })?; - return Ok((key, Vec::new())); - }; - let product_id_len = u32::try_from(calling_product_id.len()).map_err(|_| { - ProductDeviceChatAuthorityError::Unavailable( - "Chat product identifier is too long".to_string(), - ) - })?; - let (sender_account_id, recipient_account_id) = if sealing { - (identity_account_id, peer_account_id) - } else { - (peer_account_id, identity_account_id) - }; - let domain = b"dotli-chat/context-bound/v1"; - let mut aad = Vec::with_capacity( - domain.len() + 4 + calling_product_id.len() + 32 + 32 + channel_id.len(), - ); - aad.extend_from_slice(domain); - aad.extend_from_slice(&product_id_len.to_le_bytes()); - aad.extend_from_slice(calling_product_id.as_bytes()); - aad.extend_from_slice(sender_account_id); - aad.extend_from_slice(recipient_account_id); - aad.extend_from_slice(channel_id); - Hkdf::::new(Some(domain), shared_secret) - .expand(&aad, &mut key) - .map_err(|_| { - ProductDeviceChatAuthorityError::Unavailable( - "context-bound Chat identity-route HKDF failed".to_string(), - ) - })?; - Ok((key, aad)) - } - - fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { - const FIELD_MODULUS: [u8; 32] = [ - 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x7f, - ]; - if key[31] & 0x80 != 0 { - return false; - } - for index in (0..32).rev() { - if key[index] < FIELD_MODULUS[index] { - return true; - } - if key[index] > FIELD_MODULUS[index] { - return false; - } - } - false - } - - fn chat_identity_session_id( - shared_secret: &[u8; 32], - first_account_id: &[u8; 32], - second_account_id: &[u8; 32], - ) -> [u8; 32] { - let mut input = Vec::with_capacity(7 + 32 + 32 + 2); - input.extend_from_slice(b"session"); - input.extend_from_slice(first_account_id); - input.extend_from_slice(second_account_id); - input.extend_from_slice(b"//"); - let hash = blake2b_simd::Params::new() - .hash_length(32) - .key(shared_secret) - .hash(&input); - let mut output = [0; 32]; - output.copy_from_slice(hash.as_bytes()); - output - } - - fn chat_request_channel_id( - shared_secret: &[u8; 32], - requester_account_id: &[u8; 32], - acceptor_account_id: &[u8; 32], - ) -> [u8; 32] { - let mut input = Vec::with_capacity(12 + 32 + 32 + 2); - input.extend_from_slice(b"chat-request"); - input.extend_from_slice(requester_account_id); - input.extend_from_slice(acceptor_account_id); - input.extend_from_slice(b"//"); - let hash = blake2b_simd::Params::new() - .hash_length(32) - .key(shared_secret) - .hash(&input); - let mut output = [0; 32]; - output.copy_from_slice(hash.as_bytes()); - output - } -} - /// Build the neutral authority-session snapshot for `session`. pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { AuthoritySession::from_session_info(session, authority_session_validation_id(session)) @@ -994,281 +667,3 @@ pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec } id } - -#[cfg(test)] -mod tests { - use super::*; - - fn hex32(value: &str) -> [u8; 32] { - hex::decode(value).unwrap().try_into().unwrap() - } - - #[test] - fn product_device_bind_matches_ios_chat_v2_derivations() { - let peer_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); - assert_eq!( - peer_public_key, - hex32("0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20") - ); - - let response = execute_product_device_chat( - &[0x11; 32], - [0x33; 32], - ProductDeviceChatAuthorityRequest::Bind { - calling_product_id: "egui-chat.paseo".to_string(), - device_account_id: [0x44; 32], - derivation_index: DerivationIndex::Index(0), - peer_identity_account_id: [0x55; 32], - peer_chat_public_key: peer_public_key, - }, - ) - .unwrap(); - let HostProductDeviceChatResponse::IdentityBinding { - identity_account_id, - proof, - wallet_own_session_id, - peer_own_session_id, - wallet_outgoing_channel_id, - wallet_incoming_channel_id, - } = response - else { - panic!("Bind must return an identity binding"); - }; - assert_eq!(identity_account_id, [0x33; 32]); - assert_eq!( - proof, - hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333") - ); - assert_eq!( - wallet_own_session_id, - hex32("460db8611d842e65414f9eea4aa74d3fe1ac2e31468d4fbebededd914be28422") - ); - assert_eq!( - peer_own_session_id, - hex32("bfb5eb8c0b959f95b3ab09bd0f8001ab80f100cf5bb617640534372ab777c5c3") - ); - assert_eq!( - wallet_outgoing_channel_id, - hex32("576f71aa7f51aa340f411c20779c35f476361d8008247db367a8ce4d7e087d70") - ); - assert_eq!( - wallet_incoming_channel_id, - hex32("19de8cf16554a8463d0f8af7ad23717f4106463af331ee33f297b7367c8fe9fa") - ); - } - - #[test] - fn peer_device_binding_verifies_reciprocally_and_rejects_substitution() { - let sender_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x11; 32])).to_bytes(); - // Independent iOS-compatible binding vector from the sender test above. - let proof = hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333"); - let verify = |identity, device, binding| { - execute_product_device_chat( - &[0x22; 32], - [0x55; 32], - ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - calling_product_id: "egui-chat.paseo".to_string(), - peer_identity_account_id: identity, - peer_chat_public_key: sender_key, - peer_device_account_id: device, - proof: binding, - }, - ) - .unwrap() - }; - assert_eq!( - verify([0x33; 32], [0x44; 32], proof), - HostProductDeviceChatResponse::PeerDeviceVerified { valid: true } - ); - let mut corrupted = proof; - corrupted[31] ^= 1; - for (identity, device, binding) in [ - ([0x34; 32], [0x44; 32], proof), - ([0x33; 32], [0x45; 32], proof), - ([0x33; 32], [0x44; 32], corrupted), - ] { - assert_eq!( - verify(identity, device, binding), - HostProductDeviceChatResponse::PeerDeviceVerified { valid: false } - ); - } - } - - #[test] - fn product_device_seal_open_round_trip_and_authenticate() { - let identity_chat_private_key = [0x11; 32]; - let peer_chat_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); - let plaintext = b"private first-contact payload".to_vec(); - let sealed = execute_product_device_chat( - &identity_chat_private_key, - [0x33; 32], - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key, - cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, - plaintext: plaintext.clone(), - }, - ) - .unwrap(); - let HostProductDeviceChatResponse::Sealed { - mut combined_ciphertext, - } = sealed - else { - panic!("Seal must return ciphertext"); - }; - - let opened = execute_product_device_chat( - &identity_chat_private_key, - [0x33; 32], - ProductDeviceChatAuthorityRequest::Open { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key, - cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, - combined_ciphertext: combined_ciphertext.clone(), - }, - ) - .unwrap(); - assert_eq!(opened, HostProductDeviceChatResponse::Opened { plaintext }); - - let last = combined_ciphertext.len() - 1; - combined_ciphertext[last] ^= 1; - assert_eq!( - execute_product_device_chat( - &identity_chat_private_key, - [0x33; 32], - ProductDeviceChatAuthorityRequest::Open { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key, - cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, - combined_ciphertext, - }, - ), - Err(ProductDeviceChatAuthorityError::InvalidCiphertext) - ); - } - - #[test] - fn context_bound_product_device_chat_rejects_downgrade_and_wrong_context() { - let sender_private_key = [0x11; 32]; - let recipient_private_key = [0x22; 32]; - let sender_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(sender_private_key)) - .to_bytes(); - let recipient_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(recipient_private_key)) - .to_bytes(); - let sender_account_id = [0x33; 32]; - let recipient_account_id = [0x44; 32]; - let channel_id = [0x55; 32]; - let plaintext = b"context-bound identity payload".to_vec(); - let sealed = execute_product_device_chat( - &sender_private_key, - sender_account_id, - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key: recipient_public_key, - cipher_suite: HostProductDeviceChatCipherSuite::ContextBoundV1 { - peer_account_id: recipient_account_id, - channel_id, - }, - plaintext: plaintext.clone(), - }, - ) - .unwrap(); - let HostProductDeviceChatResponse::Sealed { - combined_ciphertext, - } = sealed - else { - panic!("Seal must return ciphertext"); - }; - - let open = |calling_product_id: &str, cipher_suite: HostProductDeviceChatCipherSuite| { - execute_product_device_chat( - &recipient_private_key, - recipient_account_id, - ProductDeviceChatAuthorityRequest::Open { - calling_product_id: calling_product_id.to_string(), - peer_chat_public_key: sender_public_key, - cipher_suite, - combined_ciphertext: combined_ciphertext.clone(), - }, - ) - }; - assert_eq!( - open( - "egui-chat.paseo", - HostProductDeviceChatCipherSuite::ContextBoundV1 { - peer_account_id: sender_account_id, - channel_id, - }, - ), - Ok(HostProductDeviceChatResponse::Opened { - plaintext: plaintext.clone(), - }) - ); - assert_eq!( - open( - "egui-chat.paseo", - HostProductDeviceChatCipherSuite::LegacyV2 - ), - Err(ProductDeviceChatAuthorityError::InvalidCiphertext) - ); - assert_eq!( - open( - "egui-chat.paseo", - HostProductDeviceChatCipherSuite::ContextBoundV1 { - peer_account_id: sender_account_id, - channel_id: [0x56; 32], - }, - ), - Err(ProductDeviceChatAuthorityError::InvalidCiphertext) - ); - assert_eq!( - open( - "egui-chat.westend", - HostProductDeviceChatCipherSuite::ContextBoundV1 { - peer_account_id: sender_account_id, - channel_id, - }, - ), - Err(ProductDeviceChatAuthorityError::InvalidCiphertext) - ); - } - - #[test] - fn product_device_rejects_invalid_peer_keys() { - assert_eq!( - execute_product_device_chat( - &[0x11; 32], - [0x33; 32], - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key: [0; 32], - cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, - plaintext: Vec::new(), - }, - ), - Err(ProductDeviceChatAuthorityError::InvalidPeerKey) - ); - - let mut noncanonical_peer_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); - noncanonical_peer_key[31] |= 0x80; - assert_eq!( - execute_product_device_chat( - &[0x11; 32], - [0x33; 32], - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id: "egui-chat.paseo".to_string(), - peer_chat_public_key: noncanonical_peer_key, - cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, - plaintext: Vec::new(), - }, - ), - Err(ProductDeviceChatAuthorityError::InvalidPeerKey) - ); - } -} diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index a4de30328..9158ed428 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -244,8 +244,12 @@ impl BulletinRpc { } } + /// Configured Bulletin identity, also used to select trusted HOP endpoints. + pub(crate) fn genesis_hash(&self) -> [u8; 32] { + self.genesis_hash + } + /// Open a raw RPC client over the configured Bulletin chain. - #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn client( &self, label: &'static str, diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 002a02ac0..4cd9331cd 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -30,13 +30,14 @@ use truapi_platform::{ use crate::host_logic::product_manifest::Granted; use crate::host_logic::sso::messages::ProductRequest; -use crate::runtime::authority::ProductDeviceChatAuthorityRequest; +use crate::runtime::authority::{ + ProductDeviceChatAuthorityRequest, chat_requires_statement_submit, +}; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, - product_device_chat_account_authority_error, product_device_chat_authority_error, - remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, - ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, validate_vrf_transcript, - vrf_call_error, + product_device_chat_authority_error, remote_authority_call, remote_authority_context, + ring_vrf_alias_error, ring_vrf_list_error, ring_vrf_proof_error, ring_vrf_register_error, + ring_vrf_sign_error, validate_vrf_transcript, vrf_call_error, }; #[truapi::async_trait] @@ -392,45 +393,16 @@ impl Account for ProductRuntimeHost { cx: &CallContext, request: HostProductDeviceChatRequest, ) -> Result> { - let HostProductDeviceChatRequest::V1(request) = request; - let product_account_id = match &request { - v01::HostProductDeviceChatRequest::Bind { - product_account_id, .. - } - | v01::HostProductDeviceChatRequest::Seal { - product_account_id, .. - } - | v01::HostProductDeviceChatRequest::Open { - product_account_id, .. - } - | v01::HostProductDeviceChatRequest::SignRequestProof { - product_account_id, .. - } - | v01::HostProductDeviceChatRequest::Identity { - product_account_id, .. - } - | v01::HostProductDeviceChatRequest::VerifyPeerDevice { - product_account_id, .. - } => product_account_id.clone(), - }; - let product_account_id = - Self::normalize_product_account_id(product_account_id).map_err(|()| { + let HostProductDeviceChatRequest::V1(operation) = request; + let calling_product_id = + normalize_product_identifier(&self.product_id()).map_err(|_| { CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Unknown { - reason: "Invalid product account".to_string(), - }, + latest::HostProductDeviceChatError::InvalidRequest, )) })?; - if product_account_id.dot_ns_identifier != self.product_id() { - return Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Unknown { - reason: "product account does not belong to the calling product".to_string(), - }, - ))); - } let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::NotConnected, + latest::HostProductDeviceChatError::NotConnected, ))); }; if self @@ -440,75 +412,34 @@ impl Account for ProductRuntimeHost { != PermissionAuthorizationStatus::Authorized { return Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Rejected, + latest::HostProductDeviceChatError::AccessNotGranted, ))); } + if chat_requires_statement_submit(&operation) { + self.require_remote_permission( + v01::RemotePermission::StatementSubmit, + HostProductDeviceChatError::V1( + latest::HostProductDeviceChatError::AccessNotGranted, + ), + ) + .await?; + } + if matches!( + &operation, + latest::HostProductDeviceChatRequest::SendAttachments { .. } + ) { + self.require_remote_permission( + v01::RemotePermission::PreimageSubmit, + HostProductDeviceChatError::V1( + latest::HostProductDeviceChatError::AccessNotGranted, + ), + ) + .await?; + } let cx = remote_authority_context(cx); - let authority_request = match request { - v01::HostProductDeviceChatRequest::Bind { - peer_identity_account_id, - peer_chat_public_key, - .. - } => { - let device_account_id = self - .product_account_public_key(&cx, &session, &product_account_id) - .await - .map_err(product_device_chat_account_authority_error)?; - ProductDeviceChatAuthorityRequest::Bind { - calling_product_id: self.product_id(), - device_account_id, - derivation_index: product_account_id.derivation_index.clone(), - peer_identity_account_id, - peer_chat_public_key, - } - } - v01::HostProductDeviceChatRequest::Seal { - peer_chat_public_key, - cipher_suite, - plaintext, - .. - } => ProductDeviceChatAuthorityRequest::Seal { - calling_product_id: self.product_id(), - peer_chat_public_key, - cipher_suite, - plaintext, - }, - v01::HostProductDeviceChatRequest::Open { - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - .. - } => ProductDeviceChatAuthorityRequest::Open { - calling_product_id: self.product_id(), - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - }, - v01::HostProductDeviceChatRequest::SignRequestProof { payload, .. } => { - ProductDeviceChatAuthorityRequest::SignRequestProof { - calling_product_id: self.product_id(), - product_account_id, - payload, - } - } - v01::HostProductDeviceChatRequest::Identity { .. } => { - ProductDeviceChatAuthorityRequest::Identity { - calling_product_id: self.product_id(), - } - } - v01::HostProductDeviceChatRequest::VerifyPeerDevice { - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - .. - } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - calling_product_id: self.product_id(), - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - }, + let authority_request = ProductDeviceChatAuthorityRequest { + calling_product_id, + operation, }; remote_authority_call( &cx, diff --git a/rust/crates/truapi-server/src/runtime/chat_device.rs b/rust/crates/truapi-server/src/runtime/chat_device.rs new file mode 100644 index 000000000..0c67c43bb --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/chat_device.rs @@ -0,0 +1,1214 @@ +//! Host-private native Chat multi-device encryption and content classification. +//! +//! The caller must authenticate the statement sender and its account/key binding, +//! authorize the recipient roster, and open the identity route before calling this +//! module. Native multi-device envelopes bind recipient keys, not account names or +//! the outer transport kind; those are authenticated by that enclosing route. + +mod rich; +pub(crate) use rich::{MAX_ATTACHMENTS, RichContent, validate_metadata}; + +use parity_scale_codec::{Compact, Decode, Encode}; +use truapi::latest::HostNativeChatRichMessageKind as RichKind; +use useragent_chat_v2::{self as chat, V2ChatMessageContent, V2StatementTransportData}; +use zeroize::Zeroizing; + +/// Maximum authenticated devices in a native Chat recipient roster. +pub(crate) const MAX_CHAT_PEERS: usize = 16; +/// Maximum encoded native Chat envelope accepted by the Host. +pub(crate) const MAX_CHAT_ENVELOPE_BYTES: usize = 256 * 1024; +const MAX_MESSAGES: usize = 256; +const MAX_ID_BYTES: usize = 128; +const MAX_TEXT_BYTES: usize = 8 * 1024; +const MAX_EMOJI_BYTES: usize = 64; +const AEAD_OVERHEAD: usize = 12 + 16; +const WRAPPED_KEY_BYTES: usize = 32 + AEAD_OVERHEAD; + +/// A device whose account/key association the Host has already authenticated. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PeerDevice { + /// Statement signing account, not the identity account. + pub(crate) account_id: [u8; 32], + /// Canonical X25519 device encryption key. + pub(crate) public_key: [u8; 32], +} + +/// Payload-free failures safe to propagate across the Host boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub(crate) enum ChatDeviceError { + /// An input exceeds the bounded Chat protocol surface. + #[display("Chat device input exceeds its size limit")] + LimitExceeded, + /// SCALE framing, supported content, or fixed-width fields are malformed. + #[display("Invalid Chat device encoding")] + InvalidEncoding, + /// A key is noncanonical, noncontributory, or aliases this device's account. + #[display("Invalid Chat device key")] + InvalidPeerKey, + /// The roster is empty or repeats an account or encryption key. + #[display("Invalid Chat device roster")] + InvalidRoster, + /// The envelope has no recipient entry for this device. + #[display("Chat envelope is addressed to another device")] + WrongRecipient, + /// The sender/key binding or encrypted payload failed authentication. + #[display("Chat device authentication failed")] + AuthenticationFailed, + /// The Host cannot safely interpret this content variant. + #[display("Unsupported Chat device content")] + UnsupportedContent, + /// Guest data attempts a Host-owned payment or lifecycle operation. + #[display("Chat content requires Host authority")] + ForbiddenContent, + /// Secure platform randomness is unavailable. + #[display("Chat device randomness unavailable")] + RandomnessUnavailable, +} + +/// A non-exportable Chat device secret. It is never a Coinage derivation root. +pub(crate) struct HostChatDevice { + account_id: [u8; 32], + secret: Zeroizing<[u8; 32]>, + public_key: [u8; 32], +} + +/// A decoded exchange; secret-bearing messages deliberately have no Debug/Clone. +pub(crate) enum OpenedDeviceExchange { + /// A strictly decoded request retaining wire order and acknowledgement ID. + Request { + /// Correlation ID to acknowledge only after durable Host processing. + request_id: String, + /// Ordered content; controls and payments must never be forwarded raw. + messages: Vec, + }, + /// Native response codes are raw bytes: zero succeeds, nonzero fails. + Response { + /// Correlation ID of the acknowledged request. + request_id: String, + /// Native success/failure code, without reinterpretation. + response_code: u8, + }, +} + +/// Exactly one classified message in its original position in the request. +pub(crate) enum OpenedDeviceMessage { + /// Fully decoded, supported ordinary content in its original native encoding. + Ordinary(Vec), + /// Host-owned device/session lifecycle change, not an authorized mutation. + DeviceControl(DeviceControl), + /// Host-private spendable material; never a guest-visible payment event. + Payment(PaymentMemo), + /// Private HOP reference; expand and classify before either kind of ACK. + CompactedHistory(CompactedHistory), + /// Rich content with private file credentials separated from public metadata. + RichContent(RichContent), + /// Native notification metadata, never ordinary guest content. This Host has + /// no mobile push provider; retain only ordering and replay evidence. + PushToken { timestamp: u64, digest: [u8; 32] }, +} + +/// Lifecycle metadata to validate against durable Host roster and replay state. +pub(crate) struct DeviceControl { + /// Native message identifier. + pub(crate) message_id: String, + /// Native message timestamp; ordering/replay authorization belongs to Host. + pub(crate) timestamp: u64, + /// Typed lifecycle operation without an arbitrary encoded-message escape. + pub(crate) content: DeviceLifecycle, +} + +/// Native lifecycle variants requiring Host authorization before application. +pub(crate) enum DeviceLifecycle { + /// Add an independently authenticated device to the sender identity. + Added(PeerDevice), + /// Revoke a device's statement account. + Removed([u8; 32]), + /// Accept a native single-device request. + Accepted { + /// Native request correlation ID. + request_id: String, + }, + /// Accept a native multi-device request and advertise its device binding. + MultiAccepted { + /// Native request correlation ID. + request_id: String, + /// Advertised binding to authenticate before adding it to a roster. + device: PeerDevice, + }, + /// Native contact-added notification. + ContactAdded, + /// Native session departure notification. + LeftChat, +} + +/// Native content-index-16 memo; every owned secret buffer zeroizes on drop. +pub(crate) struct PaymentMemo { + /// Native message identifier for durable receive deduplication. + pub(crate) message_id: String, + /// Native message timestamp. + pub(crate) timestamp: u64, + /// Declared amount, which the Coinage engine must verify against the chain. + pub(crate) total_value: u128, + /// Native 64-byte coin secrets, retained exclusively by the Host. + pub(crate) coin_keys: Zeroizing>>, +} + +/// An authenticated reference to encrypted native history, never guest content. +pub(crate) struct CompactedHistory { + pub(crate) message_id: String, + pub(crate) timestamp: u64, + pub(crate) identifier: [u8; 32], + pub(crate) ticket: Zeroizing<[u8; 32]>, + pub(crate) endpoint: String, +} + +impl HostChatDevice { + /// Take custody of an already Host-private device key and statement account. + pub(crate) fn from_secret(account: [u8; 32], secret: [u8; 32]) -> Self { + let secret = Zeroizing::new(secret); + let public_key = chat::x25519_public_key(&secret); + Self { + account_id: account, + secret, + public_key, + } + } + + /// Public device key safe to advertise in an authenticated device binding. + pub(crate) fn public_key(&self) -> [u8; 32] { + self.public_key + } + + /// Derive the native device-to-identity outer route without exporting a key. + pub(crate) fn identity_shared_secret( + &self, + peer_identity_key: &[u8; 32], + ) -> Result, ChatDeviceError> { + validate_public_key(peer_identity_key)?; + chat::x25519_shared_secret(&self.secret, peer_identity_key) + .map(Zeroizing::new) + .map_err(|_| ChatDeviceError::InvalidPeerKey) + } + + /// Open canonical StatementData after the authenticated identity route opens. + /// + /// `sender` must come from the verified statement and Host-private roster, + /// never an unauthenticated guest account/key pair. Nothing returns until the + /// entire inner request has classified successfully; there is no raw fallback. + pub(crate) fn open_multi_device( + &self, + sender: &PeerDevice, + envelope_bytes: &[u8], + ) -> Result { + preflight_envelope(envelope_bytes)?; + let wrapping_key = self.pairwise_key(sender)?; + let (is_request, encrypted, devices) = + match chat::decode_transport_plaintext(envelope_bytes) + .map_err(|_| ChatDeviceError::InvalidEncoding)? + { + V2StatementTransportData::MultiRequest(request) => { + (true, request.encrypted_request, request.devices_info) + } + V2StatementTransportData::MultiResponse(response) => { + (false, response.encrypted_response, response.devices_info) + } + _ => return Err(ChatDeviceError::UnsupportedContent), + }; + for (index, device) in devices.iter().enumerate() { + if devices[..index] + .iter() + .any(|previous| previous.statement_account_id == device.statement_account_id) + { + return Err(ChatDeviceError::InvalidRoster); + } + } + let own_entry = devices + .iter() + .find(|device| device.statement_account_id == self.account_id) + .ok_or(ChatDeviceError::WrongRecipient)?; + // The codec's unwrap helper leaves its temporary Vec unguarded. Using the + // same codec AEAD primitive directly lets us guard both length-error and + // success paths without copying the unwrapped one-shot key. + let one_shot_bytes = Zeroizing::new( + chat::decrypt_multi_device_payload(&wrapping_key, &own_entry.encrypted_key) + .map_err(|_| ChatDeviceError::AuthenticationFailed)?, + ); + let one_shot_key: &[u8; 32] = one_shot_bytes + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?; + let plaintext = Zeroizing::new( + chat::decrypt_multi_device_payload(one_shot_key, &encrypted) + .map_err(|_| ChatDeviceError::AuthenticationFailed)?, + ); + if is_request { + classify_request(&plaintext) + } else { + let response = decode_response(&plaintext)?; + Ok(OpenedDeviceExchange::Response { + request_id: response.request_id, + response_code: response.response_code, + }) + } + } + + /// Seal a Host-authorized request/response for an authenticated recipient roster. + /// + /// Input is the codec's **tagged** `encode_transport_request_plaintext` or + /// `encode_transport_response_plaintext` output. Only the bare inner payload + /// is encrypted, preserving native MultiRequest/MultiResponse wire semantics. + /// This primitive is not a guest generic Seal API: the caller must authorize + /// every message, especially lifecycle controls and outgoing payment memos. + pub(crate) fn seal_multi_device( + &self, + peers: &[PeerDevice], + inner_plaintext: &[u8], + ) -> Result, ChatDeviceError> { + check_size(inner_plaintext.len())?; + if peers.is_empty() || peers.len() > MAX_CHAT_PEERS { + return Err(ChatDeviceError::InvalidRoster); + } + for (index, peer) in peers.iter().enumerate() { + if peers[..index].iter().any(|previous| { + previous.account_id == peer.account_id || previous.public_key == peer.public_key + }) { + return Err(ChatDeviceError::InvalidRoster); + } + } + let (&kind, body) = inner_plaintext + .split_first() + .ok_or(ChatDeviceError::InvalidEncoding)?; + match kind { + 0 => preflight_request(body)?, + 1 => { + decode_response(body)?; + } + _ => return Err(ChatDeviceError::UnsupportedContent), + } + let encrypted_len = body.len() + AEAD_OVERHEAD; + let envelope_len = 1 + + Compact(encrypted_len as u32).encoded_size() + + encrypted_len + + Compact(peers.len() as u32).encoded_size() + + peers.len() + * (32 + Compact(WRAPPED_KEY_BYTES as u32).encoded_size() + WRAPPED_KEY_BYTES); + check_size(envelope_len)?; + let wrapping_keys = peers + .iter() + .map(|peer| self.pairwise_key(peer)) + .collect::, _>>()?; + let mut one_shot_key = Zeroizing::new([0; 32]); + getrandom::getrandom(one_shot_key.as_mut()) + .map_err(|_| ChatDeviceError::RandomnessUnavailable)?; + let encrypted = + chat::encrypt_multi_device_payload_with_nonce(&one_shot_key, body, random_nonce()?) + .map_err(|_| ChatDeviceError::AuthenticationFailed)?; + let mut devices_info = Vec::with_capacity(peers.len()); + for (peer, wrapping_key) in peers.iter().zip(&wrapping_keys) { + let encrypted_key = chat::encrypt_multi_device_payload_with_nonce( + wrapping_key, + one_shot_key.as_ref(), + random_nonce()?, + ) + .map_err(|_| ChatDeviceError::AuthenticationFailed)?; + devices_info.push(chat::V2RequestDeviceInfo { + statement_account_id: peer.account_id, + encrypted_key, + }); + } + match kind { + 0 => chat::encode_transport_multi_request_plaintext(&chat::V2MultiDeviceRequest { + encrypted_request: encrypted, + devices_info, + }), + _ => chat::encode_transport_multi_response_plaintext(&chat::V2MultiDeviceResponse { + encrypted_response: encrypted, + devices_info, + }), + } + .map_err(|_| ChatDeviceError::InvalidEncoding) + } + + fn pairwise_key(&self, peer: &PeerDevice) -> Result, ChatDeviceError> { + let own_key = self.public_key(); + if (peer.account_id == self.account_id) != (peer.public_key == own_key) { + return Err(ChatDeviceError::InvalidPeerKey); + } + check_canonical_key(&peer.public_key)?; + let shared = Zeroizing::new( + chat::x25519_shared_secret(&self.secret, &peer.public_key) + .map_err(|_| ChatDeviceError::InvalidPeerKey)?, + ); + chat::hkdf_sha256_32(shared.as_ref()) + .map(Zeroizing::new) + .map_err(|_| ChatDeviceError::InvalidPeerKey) + } +} + +/// Reject guest payment/control injection, unsupported variants, and attachments. +/// +/// The allowlist is intentionally semantic, not a top-level byte denylist: the +/// codec fully parses reply/edit rich text; this boundary rejects private file references. +pub(crate) fn validate_guest_messages(messages: &[Vec]) -> Result<(), ChatDeviceError> { + if messages.len() > MAX_MESSAGES { + return Err(ChatDeviceError::LimitExceeded); + } + let mut total = 0usize; + for bytes in messages { + total = total + .checked_add(bytes.len()) + .ok_or(ChatDeviceError::LimitExceeded)?; + check_size(total)?; + let (_, content_index, _) = message_header(bytes)?; + match content_index { + 0 | 4 | 5 | 7 | 12 | 15 => {} + 3 | 13 | 14 | 16 | 17 | 18 | 20 => return Err(ChatDeviceError::ForbiddenContent), + _ => return Err(ChatDeviceError::UnsupportedContent), + } + let message = chat::decode_message(bytes).map_err(|_| ChatDeviceError::InvalidEncoding)?; + validate_ordinary(&message.content)?; + } + Ok(()) +} + +/// Classify a Host-decrypted identity exchange without exporting its plaintext. +/// The caller permits only the control messages valid for its handshake state. +pub(crate) fn open_identity_exchange( + plaintext: &[u8], +) -> Result { + check_size(plaintext.len())?; + match plaintext.split_first() { + Some((0, body)) => classify_request(body), + Some((1, body)) => { + let response = decode_response(body)?; + Ok(OpenedDeviceExchange::Response { + request_id: response.request_id, + response_code: response.response_code, + }) + } + _ => Err(ChatDeviceError::InvalidEncoding), + } +} + +fn classify_request(plaintext: &[u8]) -> Result { + // Preflight without copying: the codec's allocating request decoder can drop + // earlier secret-bearing messages unzeroized when a later frame is truncated. + preflight_request(plaintext)?; + let request = chat::decode_message_exchange_request_plaintext(plaintext) + .map_err(|_| ChatDeviceError::InvalidEncoding)?; + let mut raw_messages = Zeroizing::new(request.messages); + let messages = raw_messages + .iter_mut() + .map(classify_message) + .collect::>()?; + Ok(OpenedDeviceExchange::Request { + request_id: request.request_id, + messages, + }) +} + +/// Classify one owned frame, including a frame recovered from private HOP history. +/// The caller zeroizes the source buffer on every success or failure path. +pub(crate) fn classify_message( + bytes: &mut Vec, +) -> Result { + let (mut content, index, timestamp) = message_header(bytes)?; + match index { + 1 => { + // Validate the native Token frame without allocating or retaining its + // notification credential. It must not poison an acceptance batch. + data(&mut content)?; + if !matches!(take(&mut content, 1)?[0], 0..=2) { + return Err(ChatDeviceError::InvalidEncoding); + } + finish(content)?; + return Ok(OpenedDeviceMessage::PushToken { + timestamp, + digest: sp_crypto_hashing::blake2_256(bytes), + }); + } + 16 => preflight_payment(&mut content)?, + 19 => preflight_compaction(&mut content)?, + _ => {} + } + let message = chat::decode_message(bytes).map_err(|_| ChatDeviceError::InvalidEncoding)?; + let control = match message.content { + V2ChatMessageContent::RichText { + text, + attachments: Some(files), + } if !files.is_empty() => { + return rich::classify( + message.message_id, + message.timestamp, + RichKind::Message, + text, + files, + bytes, + ); + } + V2ChatMessageContent::Reply { + message_id, + text, + attachments: Some(files), + } if !files.is_empty() => { + return rich::classify( + message.message_id, + message.timestamp, + RichKind::Reply { message_id }, + text, + files, + bytes, + ); + } + V2ChatMessageContent::Edited { + message_id, + new_text, + attachments: Some(files), + } if !files.is_empty() => { + return rich::classify( + message.message_id, + message.timestamp, + RichKind::Edited { message_id }, + new_text, + files, + bytes, + ); + } + V2ChatMessageContent::CoinageSend { + total_value, + coin_keys, + } => { + let coin_keys = Zeroizing::new(coin_keys); + let total_value = total_value + .parse() + .map_err(|_| ChatDeviceError::InvalidEncoding)?; + return Ok(OpenedDeviceMessage::Payment(PaymentMemo { + message_id: message.message_id, + timestamp: message.timestamp, + total_value, + coin_keys, + })); + } + V2ChatMessageContent::CompactedMessages { + claim_identifier, + claim_ticket, + node, + } => { + let ticket = Zeroizing::new(claim_ticket); + let chat::V2NodeEndpoint::WssUrl(endpoint) = node; + return Ok(OpenedDeviceMessage::CompactedHistory(CompactedHistory { + message_id: message.message_id, + timestamp: message.timestamp, + identifier: claim_identifier + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?, + ticket: Zeroizing::new( + ticket + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?, + ), + endpoint, + })); + } + V2ChatMessageContent::DeviceAdded { + statement_account_id, + encryption_public_key, + } => { + let device = PeerDevice { + account_id: statement_account_id + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?, + public_key: encryption_public_key + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidPeerKey)?, + }; + validate_public_key(&device.public_key)?; + DeviceLifecycle::Added(device) + } + V2ChatMessageContent::DeviceRemoved { + statement_account_id, + } => DeviceLifecycle::Removed( + statement_account_id + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?, + ), + V2ChatMessageContent::ChatAccepted { request_id } => { + validate_id(&request_id)?; + DeviceLifecycle::Accepted { request_id } + } + V2ChatMessageContent::MultiChatAccepted { request_id, device } => { + validate_id(&request_id)?; + validate_public_key(&device.encryption_public_key)?; + DeviceLifecycle::MultiAccepted { + request_id, + device: PeerDevice { + account_id: device.statement_account_id, + public_key: device.encryption_public_key, + }, + } + } + V2ChatMessageContent::ContactAdded => DeviceLifecycle::ContactAdded, + V2ChatMessageContent::LeftChat => DeviceLifecycle::LeftChat, + ordinary => { + validate_ordinary(&ordinary)?; + return Ok(OpenedDeviceMessage::Ordinary(core::mem::take(bytes))); + } + }; + Ok(OpenedDeviceMessage::DeviceControl(DeviceControl { + message_id: message.message_id, + timestamp: message.timestamp, + content: control, + })) +} + +fn validate_ordinary(content: &V2ChatMessageContent) -> Result<(), ChatDeviceError> { + match content { + V2ChatMessageContent::Text(text) => validate_text(text), + V2ChatMessageContent::RichText { text, attachments } => { + if attachments.as_ref().is_some_and(|files| !files.is_empty()) { + return Err(ChatDeviceError::ForbiddenContent); + } + text.as_deref().map_or(Ok(()), validate_text) + } + V2ChatMessageContent::Reply { + message_id, + text, + attachments, + } + | V2ChatMessageContent::Edited { + message_id, + new_text: text, + attachments, + } => { + if attachments.as_ref().is_some_and(|files| !files.is_empty()) { + return Err(ChatDeviceError::ForbiddenContent); + } + validate_id(message_id)?; + text.as_deref().map_or(Ok(()), validate_text) + } + V2ChatMessageContent::Reacted { message_id, emoji } + | V2ChatMessageContent::ReactionRemoved { message_id, emoji } => { + validate_id(message_id)?; + if emoji.len() > MAX_EMOJI_BYTES { + return Err(ChatDeviceError::LimitExceeded); + } + if emoji.trim().is_empty() || emoji.chars().any(char::is_control) { + return Err(ChatDeviceError::InvalidEncoding); + } + Ok(()) + } + // CompactedMessages includes a claim ticket for another message batch; + // forwarding it would create an unclassified nested-content bypass. + _ => Err(ChatDeviceError::UnsupportedContent), + } +} + +fn check_size(len: usize) -> Result<(), ChatDeviceError> { + if len > MAX_CHAT_ENVELOPE_BYTES { + Err(ChatDeviceError::LimitExceeded) + } else { + Ok(()) + } +} + +fn validate_id(id: &str) -> Result<(), ChatDeviceError> { + if id.is_empty() { + return Err(ChatDeviceError::InvalidEncoding); + } + if id.len() > MAX_ID_BYTES { + return Err(ChatDeviceError::LimitExceeded); + } + Ok(()) +} + +fn validate_text(text: &str) -> Result<(), ChatDeviceError> { + if text.len() > MAX_TEXT_BYTES { + Err(ChatDeviceError::LimitExceeded) + } else { + Ok(()) + } +} + +fn check_canonical_key(key: &[u8; 32]) -> Result<(), ChatDeviceError> { + let mut modulus = [0xff; 32]; + modulus[0] = 0xed; + modulus[31] = 0x7f; + if key.iter().rev().cmp(modulus.iter().rev()) != core::cmp::Ordering::Less { + return Err(ChatDeviceError::InvalidPeerKey); + } + Ok(()) +} + +fn validate_public_key(key: &[u8; 32]) -> Result<(), ChatDeviceError> { + check_canonical_key(key)?; + // A fixed clamped scalar suffices to detect all low-order X25519 inputs. + let _shared = Zeroizing::new( + chat::x25519_shared_secret(&[0; 32], key).map_err(|_| ChatDeviceError::InvalidPeerKey)?, + ); + Ok(()) +} + +fn random_nonce() -> Result<[u8; 12], ChatDeviceError> { + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|_| ChatDeviceError::RandomnessUnavailable)?; + Ok(nonce) +} + +// Borrowed SCALE preflights protect secret allocations made by the shared codec. +// The shared codec still owns message/transport decoding and all wire encoding. +fn take<'a>(input: &mut &'a [u8], len: usize) -> Result<&'a [u8], ChatDeviceError> { + if input.len() < len { + return Err(ChatDeviceError::InvalidEncoding); + } + let (value, rest) = input.split_at(len); + *input = rest; + Ok(value) +} + +fn count(input: &mut &[u8]) -> Result { + Compact::::decode(input) + .map(|value| value.0 as usize) + .map_err(|_| ChatDeviceError::InvalidEncoding) +} + +fn data<'a>(input: &mut &'a [u8]) -> Result<&'a [u8], ChatDeviceError> { + let len = count(input)?; + take(input, len) +} + +fn id(input: &mut &[u8]) -> Result<(), ChatDeviceError> { + let value = core::str::from_utf8(data(input)?).map_err(|_| ChatDeviceError::InvalidEncoding)?; + validate_id(value) +} + +fn finish(input: &[u8]) -> Result<(), ChatDeviceError> { + if input.is_empty() { + Ok(()) + } else { + Err(ChatDeviceError::InvalidEncoding) + } +} + +fn preflight_envelope(bytes: &[u8]) -> Result<(), ChatDeviceError> { + check_size(bytes.len())?; + let mut input = bytes; + if !matches!(take(&mut input, 1)?[0], 2 | 3) { + return Err(ChatDeviceError::UnsupportedContent); + } + if data(&mut input)?.len() < AEAD_OVERHEAD { + return Err(ChatDeviceError::InvalidEncoding); + } + let peers = count(&mut input)?; + if peers == 0 || peers > MAX_CHAT_PEERS { + return Err(ChatDeviceError::InvalidRoster); + } + for _ in 0..peers { + take(&mut input, 32)?; + if data(&mut input)?.len() != WRAPPED_KEY_BYTES { + return Err(ChatDeviceError::InvalidEncoding); + } + } + finish(input) +} + +fn preflight_request(bytes: &[u8]) -> Result<(), ChatDeviceError> { + check_size(bytes.len())?; + let mut input = bytes; + id(&mut input)?; + let messages = count(&mut input)?; + if messages > MAX_MESSAGES { + return Err(ChatDeviceError::LimitExceeded); + } + for _ in 0..messages { + if data(&mut input)?.is_empty() { + return Err(ChatDeviceError::InvalidEncoding); + } + } + finish(input) +} + +fn decode_response(bytes: &[u8]) -> Result { + let mut input = bytes; + id(&mut input)?; + take(&mut input, 1)?; + finish(input)?; + chat::decode_message_exchange_response_plaintext(bytes) + .map_err(|_| ChatDeviceError::InvalidEncoding) +} + +fn message_header(bytes: &[u8]) -> Result<(&[u8], u8, u64), ChatDeviceError> { + check_size(bytes.len())?; + let mut input = bytes; + id(&mut input)?; + let timestamp = u64::decode(&mut input).map_err(|_| ChatDeviceError::InvalidEncoding)?; + if take(&mut input, 1)?[0] != 0 { + return Err(ChatDeviceError::UnsupportedContent); + } + let index = take(&mut input, 1)?[0]; + Ok((input, index, timestamp)) +} + +fn preflight_payment(input: &mut &[u8]) -> Result<(), ChatDeviceError> { + let amount = Compact::::decode(input) + .map_err(|_| ChatDeviceError::InvalidEncoding)? + .0; + if amount == 0 { + return Err(ChatDeviceError::InvalidEncoding); + } + let keys = count(input)?; + if keys == 0 || keys > input.len() / 66 { + return Err(ChatDeviceError::InvalidEncoding); + } + for _ in 0..keys { + if data(input)?.len() != 64 { + return Err(ChatDeviceError::InvalidEncoding); + } + } + finish(input) +} + +fn preflight_compaction(input: &mut &[u8]) -> Result<(), ChatDeviceError> { + if data(input)?.len() != 32 || data(input)?.len() != 32 || take(input, 1)?[0] != 0 { + return Err(ChatDeviceError::InvalidEncoding); + } + let endpoint = + core::str::from_utf8(data(input)?).map_err(|_| ChatDeviceError::InvalidEncoding)?; + if !endpoint.starts_with("wss://") { + return Err(ChatDeviceError::InvalidEncoding); + } + finish(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn device(value: u8) -> HostChatDevice { + HostChatDevice::from_secret([value; 32], [value.wrapping_add(64); 32]) + } + + fn peer(device: &HostChatDevice) -> PeerDevice { + PeerDevice { + account_id: device.account_id, + public_key: device.public_key(), + } + } + + // Construct independently through the shared native codec, not Host sealing. + fn native_envelope( + sender: &HostChatDevice, + recipients: &[PeerDevice], + body: &[u8], + response: bool, + ) -> Vec { + let one_shot = Zeroizing::new([0x27; 32]); + let encrypted = + chat::encrypt_multi_device_payload_with_nonce(&one_shot, body, [0x31; 12]).unwrap(); + let devices_info = recipients + .iter() + .enumerate() + .map(|(index, recipient)| chat::V2RequestDeviceInfo { + statement_account_id: recipient.account_id, + encrypted_key: chat::wrap_multi_device_key_with_nonce( + &sender.secret, + &recipient.public_key, + &one_shot, + [index as u8; 12], + ) + .unwrap(), + }) + .collect(); + if response { + chat::encode_transport_multi_response_plaintext(&chat::V2MultiDeviceResponse { + encrypted_response: encrypted, + devices_info, + }) + .unwrap() + } else { + chat::encode_transport_multi_request_plaintext(&chat::V2MultiDeviceRequest { + encrypted_request: encrypted, + devices_info, + }) + .unwrap() + } + } + + fn payment() -> Vec { + chat::encode_coinage_send_message("payment", 23, "1000", &[vec![0x51; 64]]).unwrap() + } + + #[test] + fn native_mixed_request_keeps_secrets_private_and_preserves_message_order() { + let alice = device(1); + let bob = device(2); + let third = device(3); + let ordinary = chat::encode_text_message("text", 21, "hello").unwrap(); + let control = + chat::encode_device_added_message("add", 22, &third.account_id, &third.public_key()) + .unwrap(); + let messages = Zeroizing::new(vec![ordinary.clone(), control, payment()]); + let body = Zeroizing::new( + chat::encode_message_exchange_request_plaintext("request-ack", &messages).unwrap(), + ); + let wire = native_envelope(&alice, &[peer(&bob)], &body, false); + let OpenedDeviceExchange::Request { + request_id, + messages, + } = bob.open_multi_device(&peer(&alice), &wire).unwrap() + else { + panic!("request became response") + }; + assert_eq!(request_id, "request-ack"); + assert_eq!(messages.len(), 3); + match &messages[0] { + OpenedDeviceMessage::Ordinary(bytes) => assert_eq!(bytes, &ordinary), + _ => panic!("ordinary message misclassified"), + } + match &messages[1] { + OpenedDeviceMessage::DeviceControl(control) => { + assert_eq!(control.message_id, "add"); + assert_eq!(control.timestamp, 22); + assert!(matches!(control.content, DeviceLifecycle::Added(found) + if found == peer(&third))); + } + _ => panic!("control escaped classification"), + } + match &messages[2] { + OpenedDeviceMessage::Payment(memo) => { + assert_eq!(memo.message_id, "payment"); + assert_eq!(memo.timestamp, 23); + assert_eq!(memo.total_value, 1000); + assert_eq!(memo.coin_keys.as_slice(), &[vec![0x51; 64]]); + } + _ => panic!("payment escaped classification"), + } + } + + #[test] + fn host_sealing_remains_readable_by_native_codec_for_each_recipient() { + let alice = device(1); + let bob = device(2); + let carol = device(3); + let messages = Zeroizing::new(vec![payment()]); + let plaintext = Zeroizing::new( + chat::encode_transport_request_plaintext("native-ack", &messages).unwrap(), + ); + let wire = alice + .seal_multi_device(&[peer(&bob), peer(&carol)], &plaintext) + .unwrap(); + let other = alice + .seal_multi_device(&[peer(&bob), peer(&carol)], &plaintext) + .unwrap(); + assert_ne!(wire, other, "one-shot keys and nonces must be fresh"); + let V2StatementTransportData::MultiRequest(request) = + chat::decode_transport_plaintext(&wire).unwrap() + else { + panic!("non-native transport kind") + }; + for recipient in [&bob, &carol] { + let entry = request + .devices_info + .iter() + .find(|entry| entry.statement_account_id == recipient.account_id) + .unwrap(); + let key = Zeroizing::new( + chat::unwrap_multi_device_key( + &recipient.secret, + &alice.public_key(), + &entry.encrypted_key, + ) + .unwrap(), + ); + let decrypted = Zeroizing::new( + chat::decrypt_multi_device_payload(&key, &request.encrypted_request).unwrap(), + ); + assert_eq!( + &decrypted[..], + &plaintext[1..], + "native inner has no transport tag" + ); + } + } + + #[test] + fn request_response_disambiguation_preserves_native_ack_codes() { + let alice = device(1); + let bob = device(2); + for code in [0, 255] { + let response = chat::encode_transport_response_plaintext("ack", code).unwrap(); + let wire = alice.seal_multi_device(&[peer(&bob)], &response).unwrap(); + assert_eq!(wire[0], 3); + assert!( + matches!(bob.open_multi_device(&peer(&alice), &wire).unwrap(), + OpenedDeviceExchange::Response { request_id, response_code } + if request_id == "ack" && response_code == code) + ); + } + let empty_request = chat::encode_transport_request_plaintext("ack", &[]).unwrap(); + let wire = alice + .seal_multi_device(&[peer(&bob)], &empty_request) + .unwrap(); + assert_eq!(wire[0], 2); + assert!( + matches!(bob.open_multi_device(&peer(&alice), &wire).unwrap(), + OpenedDeviceExchange::Request { request_id, messages } + if request_id == "ack" && messages.is_empty()) + ); + } + + #[test] + fn invalid_keys_and_roster_aliases_fail_closed() { + let alice = device(1); + let bob = device(2); + let plaintext = chat::encode_transport_request_plaintext("ack", &[]).unwrap(); + let wire = alice.seal_multi_device(&[peer(&bob)], &plaintext).unwrap(); + let mut modulus = [0xff; 32]; + modulus[0] = 0xed; + modulus[31] = 0x7f; + let mut high_bit = bob.public_key(); + high_bit[31] |= 0x80; + let mut one = [0; 32]; + one[0] = 1; + for public_key in [[0; 32], one, modulus, high_bit] { + let invalid = PeerDevice { + account_id: [9; 32], + public_key, + }; + assert_eq!( + alice.seal_multi_device(&[invalid], &plaintext), + Err(ChatDeviceError::InvalidPeerKey) + ); + assert!(matches!( + bob.open_multi_device(&invalid, &wire), + Err(ChatDeviceError::InvalidPeerKey) + )); + } + for roster in [ + vec![peer(&bob), peer(&bob)], + vec![ + peer(&bob), + PeerDevice { + account_id: [9; 32], + ..peer(&bob) + }, + ], + vec![ + peer(&bob), + PeerDevice { + public_key: device(3).public_key(), + ..peer(&bob) + }, + ], + ] { + assert_eq!( + alice.seal_multi_device(&roster, &plaintext), + Err(ChatDeviceError::InvalidRoster) + ); + } + let alias = PeerDevice { + account_id: alice.account_id, + public_key: bob.public_key(), + }; + assert_eq!( + alice.seal_multi_device(&[alias], &plaintext), + Err(ChatDeviceError::InvalidPeerKey) + ); + } + + #[test] + fn wrong_sender_recipient_and_duplicate_recipient_are_rejected() { + let alice = device(1); + let bob = device(2); + let carol = device(3); + let plaintext = chat::encode_transport_request_plaintext("ack", &[]).unwrap(); + let wire = alice.seal_multi_device(&[peer(&bob)], &plaintext).unwrap(); + assert!(matches!( + bob.open_multi_device(&peer(&carol), &wire), + Err(ChatDeviceError::AuthenticationFailed) + )); + assert!(matches!( + carol.open_multi_device(&peer(&alice), &wire), + Err(ChatDeviceError::WrongRecipient) + )); + let wrong_key = HostChatDevice::from_secret(bob.account_id, [99; 32]); + assert!(matches!( + wrong_key.open_multi_device(&peer(&alice), &wire), + Err(ChatDeviceError::AuthenticationFailed) + )); + let mut input = match chat::decode_transport_plaintext(&wire).unwrap() { + V2StatementTransportData::MultiRequest(input) => input, + _ => panic!("wrong envelope kind"), + }; + input.devices_info.push(input.devices_info[0].clone()); + let duplicated = chat::encode_transport_multi_request_plaintext(&input).unwrap(); + assert!(matches!( + bob.open_multi_device(&peer(&alice), &duplicated), + Err(ChatDeviceError::InvalidRoster) + )); + input.devices_info.pop(); + input.encrypted_request[12] ^= 1; + let tampered = chat::encode_transport_multi_request_plaintext(&input).unwrap(); + assert!(matches!( + bob.open_multi_device(&peer(&alice), &tampered), + Err(ChatDeviceError::AuthenticationFailed) + )); + } + + #[test] + fn guest_cannot_inject_payments_lifecycle_or_compacted_batches() { + let added = device(3); + let forbidden = [ + payment(), + chat::encode_device_added_message("add", 1, &added.account_id, &added.public_key()) + .unwrap(), + chat::encode_device_removed_message("remove", 1, &added.account_id).unwrap(), + chat::encode_chat_accepted_message("accept", 1, "request").unwrap(), + chat::encode_multi_chat_accepted_message( + "multi", + 1, + "request", + &chat::V2PeerDevice { + statement_account_id: added.account_id, + encryption_public_key: added.public_key(), + }, + ) + .unwrap(), + chat::encode_contact_added_message("contact", 1).unwrap(), + chat::encode_left_chat_message("left", 1).unwrap(), + ]; + let text = chat::encode_text_message("text", 1, "ordinary first").unwrap(); + for message in forbidden { + assert_eq!( + validate_guest_messages(&[text.clone(), message]), + Err(ChatDeviceError::ForbiddenContent) + ); + } + let compacted = chat::encode_compacted_messages_message( + "batch", + 1, + &[1; 32], + &[2; 32], + &chat::V2NodeEndpoint::WssUrl("wss://example.invalid".into()), + ) + .unwrap(); + assert_eq!( + validate_guest_messages(&[compacted]), + Err(ChatDeviceError::UnsupportedContent) + ); + } + + #[test] + fn guest_ordinary_operations_are_parsed_fully_and_bounded() { + let ordinary = vec![ + chat::encode_text_message("text", 1, "hello").unwrap(), + chat::encode_rich_text_message("rich", 2, Some("rich text"), None).unwrap(), + chat::encode_reply_message("reply", 3, "text", Some("reply")).unwrap(), + chat::encode_edited_message("edit", 4, "text", Some("edited")).unwrap(), + chat::encode_reacted_message("react", 5, "text", "ok").unwrap(), + chat::encode_reaction_removed_message("remove", 6, "text", "ok").unwrap(), + ]; + assert_eq!(validate_guest_messages(&ordinary), Ok(())); + let mut nested = chat::encode_reply_message("nested", 7, "text", Some("body")).unwrap(); + *nested.last_mut().unwrap() = 1; // Unsupported attachments, not ordinary rich text. + nested.extend_from_slice(&payment()); + assert_eq!( + validate_guest_messages(&[nested]), + Err(ChatDeviceError::InvalidEncoding) + ); + let too_long = + chat::encode_text_message("long", 1, &"x".repeat(MAX_TEXT_BYTES + 1)).unwrap(); + assert_eq!( + validate_guest_messages(&[too_long]), + Err(ChatDeviceError::LimitExceeded) + ); + let mut trailing = ordinary[0].clone(); + trailing.extend_from_slice(&payment()); + assert_eq!( + validate_guest_messages(&[trailing]), + Err(ChatDeviceError::InvalidEncoding) + ); + } + + #[test] + fn unknown_and_malformed_secret_content_never_returns_partial_ordinary_data() { + let alice = device(1); + let bob = device(2); + let ordinary = chat::encode_text_message("text", 1, "safe").unwrap(); + let mut unknown = ordinary.clone(); + let (content, _, _) = message_header(&unknown).unwrap(); + let content_offset = unknown.len() - content.len(); + unknown[content_offset - 1] = 254; + let mut unsupported_version = ordinary.clone(); + unsupported_version[content_offset - 2] = 1; + let mut truncated_payment = payment(); + truncated_payment.pop(); + let mut trailing_payment = payment(); + trailing_payment.push(0); + let wrong_key_length = + chat::encode_coinage_send_message("bad-key", 2, "1", &[vec![0x33; 63]]).unwrap(); + for invalid in [ + unknown, + unsupported_version, + truncated_payment, + trailing_payment, + wrong_key_length, + ] { + assert!(validate_guest_messages(&[invalid.clone()]).is_err()); + let messages = Zeroizing::new(vec![ordinary.clone(), payment(), invalid]); + let body = Zeroizing::new( + chat::encode_message_exchange_request_plaintext("ack", &messages).unwrap(), + ); + let wire = native_envelope(&alice, &[peer(&bob)], &body, false); + assert!(bob.open_multi_device(&peer(&alice), &wire).is_err()); + } + } + + #[test] + fn outer_and_inner_framing_and_resource_limits_are_strict() { + let alice = device(1); + let bob = device(2); + let plaintext = chat::encode_transport_request_plaintext("ack", &[]).unwrap(); + let mut wire = alice.seal_multi_device(&[peer(&bob)], &plaintext).unwrap(); + wire.push(0); + assert!(matches!( + bob.open_multi_device(&peer(&alice), &wire), + Err(ChatDeviceError::InvalidEncoding) + )); + assert!(matches!( + bob.open_multi_device(&peer(&alice), &plaintext), + Err(ChatDeviceError::UnsupportedContent) + )); + let peers = (1..=MAX_CHAT_PEERS + 1) + .map(|value| peer(&device(value as u8 + 1))) + .collect::>(); + assert_eq!( + alice.seal_multi_device(&peers, &plaintext), + Err(ChatDeviceError::InvalidRoster) + ); + let oversized = vec![0; MAX_CHAT_ENVELOPE_BYTES + 1]; + assert!(matches!( + bob.open_multi_device(&peer(&alice), &oversized), + Err(ChatDeviceError::LimitExceeded) + )); + let body = Zeroizing::new( + chat::encode_message_exchange_request_plaintext("ack", &[payment()]).unwrap(), + ); + for malformed in [ + [&[0x0d, 0x00][..], &body[1..]].concat(), // Noncanonical compact string length. + [&body[..], &[0][..]].concat(), // Trailing inner bytes. + body[..body.len() - 1].to_vec(), // Truncated secret-bearing message. + ] { + let malformed = Zeroizing::new(malformed); + let wire = native_envelope(&alice, &[peer(&bob)], &malformed, false); + assert!(matches!( + bob.open_multi_device(&peer(&alice), &wire), + Err(ChatDeviceError::InvalidEncoding) + )); + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/chat_device/rich.rs b/rust/crates/truapi-server/src/runtime/chat_device/rich.rs new file mode 100644 index 000000000..4004dccfb --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/chat_device/rich.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Native attachment capabilities stay private; only validated metadata is public. + +use super::*; +use truapi::latest::{ + HostNativeChatAttachmentKind as Kind, HostNativeChatAttachmentMetadata as Metadata, + HostNativeChatRichMessageKind as MessageKind, +}; + +pub(crate) const MAX_ATTACHMENTS: usize = 64; + +pub(crate) struct FileReference { + pub(crate) identifier: [u8; 32], + pub(crate) ticket: Zeroizing<[u8; 32]>, + pub(crate) endpoint: String, + pub(crate) metadata: Metadata, +} + +pub(crate) struct RichContent { + pub(crate) message_id: String, + pub(crate) timestamp: u64, + pub(crate) kind: MessageKind, + pub(crate) text: Option, + pub(crate) files: Vec, + pub(crate) digest: [u8; 32], +} + +pub(crate) fn validate_metadata(metadata: &Metadata) -> Result<(), ChatDeviceError> { + if metadata.mime_type.trim().is_empty() + || metadata.mime_type.len() > 256 + || metadata.mime_type.chars().any(char::is_control) + { + return Err(ChatDeviceError::InvalidEncoding); + } + let thumbnail = match &metadata.kind { + Kind::File => None, + Kind::Image { + width, + height, + thumbnail, + } => { + if *width == 0 || *height == 0 { + return Err(ChatDeviceError::InvalidEncoding); + } + thumbnail.as_ref() + } + Kind::Video { thumbnail, .. } => thumbnail.as_ref(), + }; + if let Some(bytes) = thumbnail { + if bytes.len() > 4096 || core::str::from_utf8(bytes).is_err() { + return Err(ChatDeviceError::InvalidEncoding); + } + } + Ok(()) +} + +pub(super) fn classify( + message_id: String, + timestamp: u64, + kind: MessageKind, + text: Option, + files: Vec, + original: &[u8], +) -> Result { + validate_id(&message_id)?; + if let Some(text) = &text { + validate_text(text)?; + } + match &kind { + MessageKind::Message => {} + MessageKind::Reply { message_id } | MessageKind::Edited { message_id } => { + validate_id(message_id)?; + } + } + if files.is_empty() || files.len() > MAX_ATTACHMENTS { + return Err(ChatDeviceError::LimitExceeded); + } + let mut references = Vec::with_capacity(files.len()); + for file in files { + // The canonical type zeroizes its ticket even when another file fails. + let chat::V2FileVariant::P2pMixnet(mut file) = file; + let identifier = file + .identifier + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?; + let ticket = Zeroizing::new(core::mem::take(&mut file.claim_ticket)); + let ticket = Zeroizing::new( + ticket + .as_slice() + .try_into() + .map_err(|_| ChatDeviceError::InvalidEncoding)?, + ); + let chat::V2NodeEndpoint::WssUrl(endpoint) = &mut file.node; + if endpoint.len() > 4096 { + return Err(ChatDeviceError::LimitExceeded); + } + let endpoint = core::mem::take(endpoint); + let (general, kind) = match &mut file.meta { + chat::V2FileMeta::General(general) => (general, Kind::File), + chat::V2FileMeta::Image(image) => ( + &mut image.general, + Kind::Image { + width: image.width, + height: image.height, + thumbnail: image.thumbnail.take(), + }, + ), + chat::V2FileMeta::Video(video) => ( + &mut video.general, + Kind::Video { + duration_seconds: video.duration, + thumbnail: video.thumbnail.take(), + }, + ), + }; + let metadata = Metadata { + mime_type: core::mem::take(&mut general.mime_type), + size_bytes: general.file_size, + kind, + }; + validate_metadata(&metadata)?; + references.push(FileReference { + identifier, + ticket, + endpoint, + metadata, + }); + } + Ok(OpenedDeviceMessage::RichContent(RichContent { + message_id, + timestamp, + kind, + text, + files: references, + digest: sp_crypto_hashing::blake2_256(original), + })) +} diff --git a/rust/crates/truapi-server/src/runtime/chat_identity.rs b/rust/crates/truapi-server/src/runtime/chat_identity.rs new file mode 100644 index 000000000..463fb4b86 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/chat_identity.rs @@ -0,0 +1,288 @@ +//! Native identity-route primitives for the Host-owned Chat actor. +//! +//! Every secret is an explicit Host-private input. This module has no product +//! request dispatcher and cannot disclose decrypted envelopes to a product. + +use chacha20poly1305::aead::{Aead, AeadInPlace, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChatIdentityError { + InvalidPeerKey, + InvalidCiphertext, + Unavailable, +} + +pub(crate) fn chat_shared_secret( + private_key: &[u8; 32], + peer_public_key: &[u8; 32], +) -> Result, ChatIdentityError> { + if !is_canonical_x25519_public_key(peer_public_key) { + return Err(ChatIdentityError::InvalidPeerKey); + } + let shared = Zeroizing::new( + StaticSecret::from(*private_key) + .diffie_hellman(&PublicKey::from(*peer_public_key)) + .to_bytes(), + ); + if *shared == [0; 32] { + return Err(ChatIdentityError::InvalidPeerKey); + } + Ok(shared) +} + +fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + key.iter().rev().cmp(FIELD_MODULUS.iter().rev()).is_lt() +} + +fn identity_proof_hash( + shared_secret: &[u8; 32], + identity: &[u8; 32], + device: &[u8; 32], +) -> blake2b_simd::Hash { + const CONTEXT: &[u8] = b"mds-chat-request"; + let mut payload = [0; 65 + CONTEXT.len()]; + payload[..32].copy_from_slice(identity); + payload[32..64].copy_from_slice(device); + payload[64] = (CONTEXT.len() as u8) << 2; + payload[65..].copy_from_slice(CONTEXT); + blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&payload) +} + +pub(crate) fn chat_device_identity_proof( + shared_secret: &[u8; 32], + identity: &[u8; 32], + device: &[u8; 32], +) -> [u8; 32] { + let mut proof = [0; 32]; + proof.copy_from_slice(identity_proof_hash(shared_secret, identity, device).as_bytes()); + proof +} + +pub(crate) fn verify_chat_device_identity_proof( + shared_secret: &[u8; 32], + identity: &[u8; 32], + device: &[u8; 32], + proof: &[u8; 32], +) -> bool { + // blake2b_simd::Hash compares equal-length slices in constant time. + identity_proof_hash(shared_secret, identity, device).eq(proof.as_slice()) +} + +pub(crate) fn chat_identity_session_id( + shared_secret: &[u8; 32], + first_account_id: &[u8; 32], + second_account_id: &[u8; 32], +) -> [u8; 32] { + chat_route_id( + shared_secret, + b"session", + first_account_id, + second_account_id, + ) +} + +pub(crate) fn chat_request_channel_id( + shared_secret: &[u8; 32], + requester_account_id: &[u8; 32], + acceptor_account_id: &[u8; 32], +) -> [u8; 32] { + chat_route_id( + shared_secret, + b"chat-request", + requester_account_id, + acceptor_account_id, + ) +} + +fn chat_route_id( + shared_secret: &[u8; 32], + domain: &[u8], + first: &[u8; 32], + second: &[u8; 32], +) -> [u8; 32] { + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .to_state() + .update(domain) + .update(first) + .update(second) + .update(b"//") + .finalize(); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output +} + +fn native_root_key(shared_secret: &[u8; 32]) -> Zeroizing<[u8; 32]> { + let mut key = Zeroizing::new([0; 32]); + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut *key) + .expect("32-byte key fits HKDF-SHA256 output limit"); + key +} + +pub(crate) fn native_root_seal( + shared_secret: &[u8; 32], + plaintext: &[u8], +) -> Result, ChatIdentityError> { + let key = native_root_key(shared_secret); + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|_| ChatIdentityError::Unavailable)?; + let mut combined = Zeroizing::new(Vec::with_capacity(nonce.len() + plaintext.len() + 16)); + combined.extend_from_slice(&nonce); + combined.extend_from_slice(plaintext); + let tag = ChaCha20Poly1305::new((&*key).into()) + .encrypt_in_place_detached(Nonce::from_slice(&nonce), &[], &mut combined[12..]) + .map_err(|_| ChatIdentityError::Unavailable)?; + combined.extend_from_slice(&tag); + Ok(std::mem::take(&mut *combined)) +} + +pub(crate) fn native_root_open( + shared_secret: &[u8; 32], + combined: &[u8], +) -> Result>, ChatIdentityError> { + if combined.len() < 28 { + return Err(ChatIdentityError::InvalidCiphertext); + } + let key = native_root_key(shared_secret); + ChaCha20Poly1305::new((&*key).into()) + .decrypt(Nonce::from_slice(&combined[..12]), &combined[12..]) + .map(Zeroizing::new) + .map_err(|_| ChatIdentityError::InvalidCiphertext) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(value: &str) -> [u8; 32] { + hex::decode(value).unwrap().try_into().unwrap() + } + + #[test] + fn identity_proof_and_routes_match_native_chat_vectors() { + let peer = PublicKey::from(&StaticSecret::from([0x22; 32])).to_bytes(); + let shared = chat_shared_secret(&[0x11; 32], &peer).unwrap(); + assert_eq!( + chat_device_identity_proof(&shared, &[0x33; 32], &[0x44; 32]), + hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333") + ); + assert_eq!( + chat_identity_session_id(&shared, &[0x33; 32], &[0x55; 32]), + hex32("460db8611d842e65414f9eea4aa74d3fe1ac2e31468d4fbebededd914be28422") + ); + assert_eq!( + chat_identity_session_id(&shared, &[0x55; 32], &[0x33; 32]), + hex32("bfb5eb8c0b959f95b3ab09bd0f8001ab80f100cf5bb617640534372ab777c5c3") + ); + assert_eq!( + chat_request_channel_id(&shared, &[0x33; 32], &[0x55; 32]), + hex32("576f71aa7f51aa340f411c20779c35f476361d8008247db367a8ce4d7e087d70") + ); + assert_eq!( + chat_request_channel_id(&shared, &[0x55; 32], &[0x33; 32]), + hex32("19de8cf16554a8463d0f8af7ad23717f4106463af331ee33f297b7367c8fe9fa") + ); + } + + #[test] + fn peer_binding_verifies_reciprocally_and_rejects_substitution() { + let sender = PublicKey::from(&StaticSecret::from([0x11; 32])).to_bytes(); + let shared = chat_shared_secret(&[0x22; 32], &sender).unwrap(); + let proof = hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333"); + assert!(verify_chat_device_identity_proof( + &shared, + &[0x33; 32], + &[0x44; 32], + &proof + )); + assert!(!verify_chat_device_identity_proof( + &shared, + &[0x34; 32], + &[0x44; 32], + &proof + )); + assert!(!verify_chat_device_identity_proof( + &shared, + &[0x33; 32], + &[0x45; 32], + &proof + )); + let mut corrupted = proof; + corrupted[31] ^= 1; + assert!(!verify_chat_device_identity_proof( + &shared, + &[0x33; 32], + &[0x44; 32], + &corrupted + )); + } + + #[test] + fn native_root_cipher_authenticates_peer_ciphertext_and_nonce() { + let sender = PublicKey::from(&StaticSecret::from([0x11; 32])).to_bytes(); + let recipient = PublicKey::from(&StaticSecret::from([0x22; 32])).to_bytes(); + let send_secret = chat_shared_secret(&[0x11; 32], &recipient).unwrap(); + let receive_secret = chat_shared_secret(&[0x22; 32], &sender).unwrap(); + let ciphertext = native_root_seal(&send_secret, b"native request").unwrap(); + assert_eq!( + native_root_open(&receive_secret, &ciphertext) + .unwrap() + .as_slice(), + b"native request" + ); + let other_secret = chat_shared_secret(&[0x44; 32], &sender).unwrap(); + assert_eq!( + native_root_open(&other_secret, &ciphertext), + Err(ChatIdentityError::InvalidCiphertext) + ); + for index in [0, ciphertext.len() - 1] { + let mut corrupted = ciphertext.clone(); + corrupted[index] ^= 1; + assert_eq!( + native_root_open(&receive_secret, &corrupted), + Err(ChatIdentityError::InvalidCiphertext) + ); + } + assert_eq!( + native_root_open(&receive_secret, &ciphertext[..27]), + Err(ChatIdentityError::InvalidCiphertext) + ); + } + + #[test] + fn shared_secret_rejects_low_order_and_noncanonical_peers() { + assert_eq!( + chat_shared_secret(&[0x11; 32], &[0; 32]), + Err(ChatIdentityError::InvalidPeerKey) + ); + let mut noncanonical = PublicKey::from(&StaticSecret::from([0x22; 32])).to_bytes(); + noncanonical[31] |= 0x80; + assert_eq!( + chat_shared_secret(&[0x11; 32], &noncanonical), + Err(ChatIdentityError::InvalidPeerKey) + ); + let mut modulus = [0xff; 32]; + modulus[0] = 0xed; + modulus[31] = 0x7f; + assert_eq!( + chat_shared_secret(&[0x11; 32], &modulus), + Err(ChatIdentityError::InvalidPeerKey) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain.rs b/rust/crates/truapi-server/src/runtime/coinage_chain.rs new file mode 100644 index 000000000..536c25c19 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain.rs @@ -0,0 +1,569 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-ffi/src/{coinage_sender,coinage_transfer}.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +//! Wallet-owned Coinage effects. The caller owns user confirmation and persists +//! claim plans before submission; this edge owns chain identity, secret use, +//! mortal transaction preparation and canonical finality verification. + +mod crypto; +mod rpc; +mod sender; +#[cfg(test)] +mod tests; +mod transaction; + +use crate::{host_rpc_client::HostRpcClient, subscription::Spawner}; +use core::time::Duration; +pub(crate) use crypto::HostVoucherCryptography; +use futures::{StreamExt, stream::BoxStream}; +use parity_scale_codec::Encode; +use serde_json::json; +use std::sync::{Arc, Mutex}; +use subxt_rpcs::RpcClient; +use transaction::{decode_exact, denomination_value, hex0x}; +use truapi_coinage::{ + CoinKeypairFactory, CoinageStorageQuery, DenominationBreakdownContext, + ExternalCoinTransferBackend, ExternalCoinTransferRequest, OnChainCoin, RecoveryChainProbe, + VoucherCryptography, VoucherKeypairFactory, WalStore, +}; +use truapi_platform::{JsonRpcConnection, Platform, async_trait}; +use zeroize::Zeroizing; + +/// One wallet/network/session's chain edge. All clones share the submission +/// gate and recovery snapshot. Serialize complete recovery sweeps, not each +/// individual probe, so a sweep cannot replace another sweep's pinned head. +#[derive(Clone)] +pub(crate) struct HostCoinageChain { + inner: Arc, +} + +struct Inner { + platform: Arc, + genesis_hash: [u8; 32], + coinage_instance_id: Option, + entropy: Zeroizing>, + coins: CoinKeypairFactory, + vouchers: VoucherKeypairFactory, + session_valid: Arc bool + Send + Sync>, + spawner: Spawner, + wal: Arc, + submission: futures::lock::Mutex<()>, + recovery: Mutex>, + crypto: Arc, + runtime: futures::lock::Mutex>, + denominations: Mutex>, +} + +impl HostCoinageChain { + /// Keep root entropy Host-private and bind all secret use to the active session. + pub(crate) fn new( + platform: Arc, + genesis_hash: [u8; 32], + coinage_instance_id: Option, + entropy: Zeroizing>, + session_valid: Arc bool + Send + Sync>, + spawner: Spawner, + wal: Arc, + ) -> Self { + let crypto = Arc::new(HostVoucherCryptography::new(session_valid.clone())); + let coins = CoinKeypairFactory::new(&entropy); + let vouchers = VoucherKeypairFactory::new(&entropy); + Self { + inner: Arc::new(Inner { + platform, + genesis_hash, + coinage_instance_id, + entropy, + coins, + vouchers, + session_valid, + spawner, + wal, + submission: futures::lock::Mutex::new(()), + recovery: Mutex::new(None), + crypto, + runtime: futures::lock::Mutex::new(None), + denominations: Mutex::new(None), + }), + } + } + + fn ensure_session(&self) -> Result<(), String> { + if (self.inner.session_valid)() { + Ok(()) + } else { + Err("Coinage signing session expired".into()) + } + } + + async fn connect(&self) -> Result { + self.ensure_session()?; + let connection: Arc = self + .inner + .platform + .connect(self.inner.genesis_hash) + .await + .map_err(|_| "configured Coinage chain unavailable")? + .into(); + if let Err(error) = self.ensure_session() { + connection.close(); + return Err(error); + } + let rpc = RpcClient::new(HostRpcClient::new(connection, self.inner.spawner.clone())); + let genesis = rpc::hash_value(&rpc::call(&rpc, "chain_getBlockHash", json!([0])).await?)?; + if genesis != self.inner.genesis_hash { + return Err("Coinage chain genesis does not match configured network".into()); + } + self.ensure_session()?; + Ok(rpc) + } + + async fn snapshot( + &self, + rpc: &RpcClient, + at: [u8; 32], + ) -> Result { + let (version, number) = futures::try_join!( + rpc::call(rpc, "state_getRuntimeVersion", json!([hex0x(&at)])), + rpc::block_number(rpc, at), + )?; + let spec = u32::try_from(rpc::number(&version["specVersion"])?) + .map_err(|_| "invalid runtime spec version")?; + let tx = u32::try_from(rpc::number(&version["transactionVersion"])?) + .map_err(|_| "invalid runtime transaction version")?; + let mut snapshot = { + let mut cached = self.inner.runtime.lock().await; + if let Some(snapshot) = cached.as_ref().filter(|snapshot| { + snapshot.state.spec_version == spec && snapshot.state.transaction_version == tx + }) { + let mut snapshot = snapshot.clone(); + snapshot.at = at; + snapshot.number = number; + snapshot + } else { + let metadata = rpc::metadata(rpc, at).await?; + let snapshot = transaction::Snapshot::new( + metadata, + at, + number, + self.inner.genesis_hash, + spec, + tx, + self.inner.coinage_instance_id, + )?; + *cached = Some(snapshot.clone()); + snapshot + } + }; + if let Some(key) = snapshot.instance_asset_key()? { + let row = rpc::query(rpc, &[key], at) + .await? + .pop() + .flatten() + .ok_or("configured Coinage asset instance does not exist")?; + snapshot.bind_instance_asset(&row)?; + } + snapshot.denomination_context()?; + self.ensure_session()?; + Ok(snapshot) + } + + fn bind_denominations( + &self, + snapshot: &transaction::Snapshot, + ) -> Result { + let live = snapshot.denomination_context()?; + let mut expected = self + .inner + .denominations + .lock() + .map_err(|_| "Coinage denomination snapshot unavailable")?; + match expected.as_ref() { + Some(expected) if expected != &live => { + return Err( + "Coinage denominations changed; a new transfer review is required".into(), + ); + } + None => *expected = Some(live.clone()), + _ => (), + } + Ok(live) + } + + /// Read the authoritative denomination constants at one finalized head. + pub(crate) async fn denomination_context( + &self, + ) -> Result { + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let context = self.bind_denominations(&self.snapshot(&rpc, at).await?)?; + self.ensure_session()?; + Ok(context) + } + + /// Runtime-enforced upper bound for one voucher consolidation group. + pub(crate) async fn max_consolidation(&self) -> Result { + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + let value: u32 = transaction::constant(&snapshot.metadata, "Coinage", "MaxConsolidation")?; + if value == 0 { + return Err("Coinage consolidation bound is zero".into()); + } + self.ensure_session()?; + Ok(value as usize) + } + + /// Concrete voucher public/proof effects for the engine's query providers. + pub(crate) fn voucher_crypto(&self) -> Arc { + self.inner.crypto.clone() + } + + /// Derive public voucher material without exposing the seed to a caller. + pub(crate) fn voucher_public_key(&self, index: u32) -> Result<[u8; 32], String> { + self.ensure_session()?; + self.inner + .vouchers + .public_key(index, self.inner.crypto.as_ref()) + } + + /// Derive the protocol recycler alias, never a private voucher key. + pub(crate) fn voucher_alias(&self, index: u32) -> Result<[u8; 32], String> { + self.ensure_session()?; + self.inner.vouchers.alias( + index, + truapi_coinage::RECYCLER_ALIAS_CONTEXT, + self.inner.crypto.as_ref(), + ) + } + + async fn recovery_at(&self) -> Result<[u8; 32], String> { + self.ensure_session()?; + self.inner + .recovery + .lock() + .map_err(|_| "Coinage recovery snapshot unavailable")? + .as_ref() + .map(|(hash, _)| *hash) + .ok_or_else(|| "Coinage recovery pass has no finalized snapshot".into()) + } + + async fn coins_at( + rpc: &RpcClient, + owners: &[[u8; 32]], + snapshot: &transaction::Snapshot, + ) -> Result>, String> { + let keys = owners + .iter() + .map(|owner| { + snapshot + .storage + .coinage_key(&truapi_coinage::CoinageStorageKey::Coin(*owner)) + }) + .collect::, _>>()?; + rpc::query(rpc, &keys, snapshot.at) + .await? + .into_iter() + .zip(owners) + .map(|(value, owner)| { + value + .map(|mut value| { + snapshot.storage.normalize_value( + &truapi_coinage::CoinageStorageKey::Coin(*owner), + &mut value, + )?; + let (exponent, age): (i8, u16) = decode_exact(&value)?; + Ok(OnChainCoin { + exponent: i16::from(exponent), + age: i16::try_from(age).map_err(|_| "Coinage age out of range")?, + }) + }) + .transpose() + }) + .collect() + } + + async fn nonce( + &self, + rpc: &RpcClient, + account: &[u8; 32], + _snapshot: &transaction::Snapshot, + ) -> Result { + // AccountId32 serializes a checked SS58 address. The RPC decodes the + // account bytes independently of the address's display prefix. + let address = subxt::utils::AccountId32(*account); + u32::try_from(rpc::number( + &rpc::call(rpc, "system_accountNextIndex", json!([address])).await?, + )?) + .map_err(|_| "Coinage account nonce exceeds u32".into()) + } +} + +#[async_trait] +impl CoinageStorageQuery for HostCoinageChain { + async fn query( + &self, + keys: &[truapi_coinage::CoinageStorageKey], + at: Option<[u8; 32]>, + ) -> Result>>, String> { + let rpc = self.connect().await?; + let at = match at { + Some(at) => at, + None => rpc::finalized(&rpc).await?, + }; + let snapshot = self.snapshot(&rpc, at).await?; + let physical_keys = keys + .iter() + .map(|key| snapshot.storage.coinage_key(key)) + .collect::, _>>()?; + let mut rows = rpc::query(&rpc, &physical_keys, snapshot.at).await?; + for (key, row) in keys.iter().zip(&mut rows) { + if let Some(row) = row { + snapshot.storage.normalize_value(key, row)?; + } + } + self.ensure_session()?; + Ok(rows) + } + async fn finalized_head(&self) -> Result<[u8; 32], String> { + let rpc = self.connect().await?; + let head = rpc::finalized(&rpc).await?; + self.ensure_session()?; + Ok(head) + } + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + let chain = self.clone(); + // A lazy stream owns its connection; dropping the consumer stops all work. + // Poll the finalized hash rather than depending on author subscriptions, + // which some configured People endpoints stop at inBlock. + futures::stream::try_unfold( + (chain, None::, None::<[u8; 32]>), + |(chain, client, previous)| async move { + let client = match client { + Some(client) => client, + None => chain.connect().await?, + }; + loop { + chain.ensure_session()?; + let head = rpc::finalized(&client).await?; + if previous != Some(head) { + return Ok(Some((head, (chain, Some(client), Some(head))))); + } + futures_timer::Delay::new(Duration::from_secs(1)).await; + } + }, + ) + .boxed() + } +} + +#[async_trait] +impl ExternalCoinTransferBackend for HostCoinageChain { + async fn denomination_context(&self) -> Result { + HostCoinageChain::denomination_context(self).await + } + async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + ) -> Result>, String> { + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + let values = Self::coins_at(&rpc, public_keys, &snapshot).await?; + self.ensure_session()?; + Ok(values) + } + async fn submit_transfer( + &self, + request: ExternalCoinTransferRequest, + ) -> Result { + let _gate = self.inner.submission.lock().await; + self.ensure_session()?; + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + let context = snapshot.denomination_context()?; + let before = + Self::coins_at(&rpc, &[request.source_public, request.recipient], &snapshot).await?; + validate_live_transfer(&request, &context, &before)?; + self.ensure_session()?; + let secret = schnorrkel::SecretKey::from_bytes(&request.source_secret.0) + .map_err(|_| "invalid Coinage source secret")?; + let public = secret.to_public(); + if public.to_bytes() != request.source_public { + return Err("Coinage source does not match supplied secret".into()); + } + let keypair = schnorrkel::Keypair { secret, public }; + let [pallet, call_index] = snapshot + .metadata + .call_indices("Coinage", "transfer") + .map_err(|_| "Coinage transfer call unavailable")?; + let call = truapi_coinage::pallet::transfer_call(pallet, call_index, &request.recipient); + let nonce = self.nonce(&rpc, &request.source_public, &snapshot).await?; + self.ensure_session()?; + let extrinsic = snapshot.signed(&keypair, &call, nonce)?; + // The external-claim service persisted the source/destination plan before + // entering this method. An ambiguous result leaves that plan recoverable. + let finalized = self.broadcast(&rpc, &extrinsic, &snapshot).await?; + let finalized_snapshot = self.snapshot(&rpc, finalized).await?; + let after = Self::coins_at( + &rpc, + &[request.source_public, request.recipient], + &finalized_snapshot, + ) + .await?; + validate_finalized_transfer(&request, &after) + } +} + +pub(super) fn validate_live_transfer( + request: &ExternalCoinTransferRequest, + context: &DenominationBreakdownContext, + rows: &[Option], +) -> Result<(), String> { + if request.source_public == [0; 32] + || request.recipient == [0; 32] + || request.source_public == request.recipient + { + return Err("invalid Coinage source or destination".into()); + } + if request.asset_unit != context.asset_unit + || denomination_value(context, request.exponent)? != request.amount_planks + { + return Err("Coinage denomination changed since claim preparation".into()); + } + if rows.len() != 2 + || rows[0].is_none_or(|coin| coin.exponent != request.exponent) + || rows[1].is_some() + { + return Err("Coinage claim source or destination changed".into()); + } + Ok(()) +} + +pub(super) fn validate_finalized_transfer( + request: &ExternalCoinTransferRequest, + rows: &[Option], +) -> Result { + if rows.len() != 2 || rows[0].is_some() { + return Err("finalized Coinage source was not consumed".into()); + } + rows[1] + .filter(|coin| coin.exponent == request.exponent) + .ok_or_else(|| "finalized Coinage destination denomination mismatch".into()) +} + +#[async_trait] +impl RecoveryChainProbe for HostCoinageChain { + async fn finalized_block(&self) -> Result { + let rpc = self.connect().await?; + let hash = rpc::finalized(&rpc).await?; + let number = self.snapshot(&rpc, hash).await?.number; + self.ensure_session()?; + *self + .inner + .recovery + .lock() + .map_err(|_| "Coinage recovery snapshot unavailable")? = Some((hash, number)); + Ok(number) + } + async fn canonical_hash(&self, height: u64) -> Result, String> { + let snapshot = self + .inner + .recovery + .lock() + .map_err(|_| "Coinage recovery snapshot unavailable")? + .ok_or("Coinage recovery pass has no snapshot")?; + if height > snapshot.1 { + return Ok(None); + } + let rpc = self.connect().await?; + let value = rpc::call(&rpc, "chain_getBlockHash", json!([height])).await?; + if value.is_null() { + Ok(None) + } else { + rpc::hash_value(&value).map(Some) + } + } + async fn coins_present(&self, indices: &[u32]) -> Result, String> { + Ok(self + .coin_exponents(indices) + .await? + .into_iter() + .map(|coin| coin.is_some()) + .collect()) + } + async fn coin_exponents(&self, indices: &[u32]) -> Result>, String> { + let at = self.recovery_at().await?; + self.ensure_session()?; + let factory = &self.inner.coins; + let owners = indices + .iter() + .map(|index| { + self.ensure_session()?; + factory.public_key(*index) + }) + .collect::, String>>()?; + let rpc = self.connect().await?; + let snapshot = self.snapshot(&rpc, at).await?; + let values = Self::coins_at(&rpc, &owners, &snapshot).await?; + self.ensure_session()?; + Ok(values + .into_iter() + .map(|coin| coin.map(|coin| coin.exponent)) + .collect()) + } + async fn vouchers_present(&self, indices: &[u32]) -> Result, String> { + let at = self.recovery_at().await?; + let rpc = self.connect().await?; + let snapshot = self.snapshot(&rpc, at).await?; + let mut result = Vec::with_capacity(indices.len()); + for index in indices { + let member = self.voucher_public_key(*index)?; + let query = truapi_coinage::CoinageStorageKey::Recycler(member); + let exponent_key = snapshot.storage.coinage_key(&query)?; + let Some(mut value) = rpc::query(&rpc, &[exponent_key], at).await?.pop().flatten() + else { + result.push(false); + continue; + }; + snapshot.storage.normalize_value(&query, &mut value)?; + let exponent: i8 = decode_exact(&value)?; + let collection = snapshot.storage.collection(i16::from(exponent)); + let position_key = transaction::storage_key( + &snapshot.storage, + "Members", + "Members", + &[collection.encode(), member.encode()], + )?; + let Some(value) = rpc::query(&rpc, &[position_key], at).await?.pop().flatten() else { + // An existing recycler assignment with missing membership is + // ambiguous, not proof that a spend consumed the voucher. + return Err("Coinage voucher membership is inconsistent".into()); + }; + let position: truapi_coinage::members::RingPosition = decode_exact(&value)?; + let truapi_coinage::members::RingPosition::Included { ring_index, .. } = position + else { + result.push(true); + continue; + }; + let alias = self.voucher_alias(*index)?; + let key = snapshot.storage.coinage_key( + &truapi_coinage::CoinageStorageKey::RecyclerAlias { + exponent, + ring_index, + alias, + }, + )?; + let value = rpc::query(&rpc, &[key], at).await?.pop().flatten(); + let state = value + .as_deref() + .map(decode_exact::) + .transpose()?; + result.push(state != Some(truapi_coinage::AliasState::Unloaded)); + } + self.ensure_session()?; + Ok(result) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain/crypto.rs b/rust/crates/truapi-server/src/runtime/coinage_chain/crypto.rs new file mode 100644 index 000000000..1cffa9bc4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain/crypto.rs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-core/src/personhood_keys.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +use crate::host_logic::product_account::{ + derive_full_person_ring_vrf_entropy, derive_lite_person_ring_vrf_entropy, +}; +use crate::runtime::statement_allowance::proof; +use std::sync::Arc; +use truapi_coinage::{ + PersonOriginKind, PersonRingProofSigner, RingProofParams, VoucherCryptography, VoucherSeed, +}; +use verifiable::{GenerateVerifiable, ring::bandersnatch::BandersnatchVrfVerifiable}; +use zeroize::Zeroizing; + +/// Session-checked concrete Bandersnatch primitives; no secrets escape this adapter. +pub(crate) struct HostVoucherCryptography { + valid: Arc bool + Send + Sync>, +} + +impl HostVoucherCryptography { + /// Bind proof generation to the wallet session that owns the inputs. + pub(crate) fn new(valid: Arc bool + Send + Sync>) -> Self { + Self { valid } + } + fn check(&self) -> Result<(), String> { + if (self.valid)() { + Ok(()) + } else { + Err("Coinage signing session expired".into()) + } + } +} + +impl VoucherCryptography for HostVoucherCryptography { + fn member_key(&self, seed: &VoucherSeed) -> Result<[u8; 32], String> { + self.check()?; + Ok(proof::member_key(seed.0)) + } + fn sign(&self, seed: &VoucherSeed, message: &[u8]) -> Result<[u8; 64], String> { + self.check()?; + let secret = BandersnatchVrfVerifiable::new_secret(seed.0); + BandersnatchVrfVerifiable::sign(&secret, message) + .map_err(|_| "Coinage ownership proof failed".into()) + } + fn alias(&self, seed: &VoucherSeed, context: &[u8]) -> Result<[u8; 32], String> { + self.check()?; + let secret = BandersnatchVrfVerifiable::new_secret(seed.0); + BandersnatchVrfVerifiable::alias_in_context(&secret, context) + .map_err(|_| "Coinage alias derivation failed".into()) + } + fn ring_vrf_proof( + &self, + seed: &VoucherSeed, + ring_exponent: u8, + ring_members: &[[u8; 32]], + context: &[u8], + message: &[u8], + ) -> Result, String> { + self.check()?; + let domain = proof::domain_for_ring_exponent(ring_exponent) + .map_err(|_| "Coinage ring exponent is unsupported")?; + proof::ring_vrf_proof(domain, seed.0, ring_members, context, message) + .map_err(|_| "Coinage ring proof failed".into()) + } +} + +pub(super) struct HostPersonProof { + full: Zeroizing<[u8; 32]>, + lite: Zeroizing<[u8; 32]>, + valid: Arc bool + Send + Sync>, +} + +impl HostPersonProof { + pub fn new( + entropy: &[u8], + suffix: &str, + valid: Arc bool + Send + Sync>, + ) -> Result { + if !valid() { + return Err("Coinage signing session expired".into()); + } + if !matches!(suffix, "dot" | "paseo" | "testnet") { + return Err("unsupported Coinage personhood network suffix".into()); + } + Ok(Self { + full: Zeroizing::new(derive_full_person_ring_vrf_entropy(entropy, suffix)), + lite: Zeroizing::new(derive_lite_person_ring_vrf_entropy(entropy, suffix)), + valid, + }) + } + fn seed(&self, origin: PersonOriginKind) -> Result { + if !(self.valid)() { + return Err("Coinage signing session expired".into()); + } + Ok(VoucherSeed(match origin { + PersonOriginKind::Full => *self.full, + PersonOriginKind::Lite => *self.lite, + })) + } + pub fn member(&self, origin: PersonOriginKind) -> Result<[u8; 32], String> { + HostVoucherCryptography::new(self.valid.clone()).member_key(&self.seed(origin)?) + } + pub fn alias(&self, origin: PersonOriginKind, context: &[u8]) -> Result<[u8; 32], String> { + HostVoucherCryptography::new(self.valid.clone()).alias(&self.seed(origin)?, context) + } +} + +impl PersonRingProofSigner for HostPersonProof { + fn ring_vrf_proof( + &self, + origin: PersonOriginKind, + ring: &RingProofParams, + context: &[u8], + message: &[u8], + ) -> Result, String> { + HostVoucherCryptography::new(self.valid.clone()).ring_vrf_proof( + &self.seed(origin)?, + ring.ring_exponent, + &ring.ring_members, + context, + message, + ) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain/rpc.rs b/rust/crates/truapi-server/src/runtime/coinage_chain/rpc.rs new file mode 100644 index 000000000..ecb99a70c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain/rpc.rs @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-ffi/src/{coinage_sender,coinage_transfer,game_submitter}.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +use super::HostCoinageChain; +use super::transaction::{Snapshot, decode_exact, hex0x, parse_hash}; +use core::time::Duration; +use futures::FutureExt; +use parity_scale_codec::Encode; +use scale_info::{TypeDef, TypeDefPrimitive}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use subxt_rpcs::{ + RpcClient, + client::{RpcParams, rpc_params}, +}; + +pub(super) async fn call(rpc: &RpcClient, method: &str, params: Value) -> Result { + let mut encoded = RpcParams::new(); + for value in params.as_array().ok_or("invalid Host RPC parameters")? { + encoded + .push(value) + .map_err(|_| "invalid Host RPC parameter")?; + } + let request = rpc.request(method, encoded).fuse(); + let timeout = futures_timer::Delay::new(Duration::from_secs(30)).fuse(); + futures::pin_mut!(request, timeout); + futures::select! { + result = request => result.map_err(|_| format!("Coinage chain request failed ({method})")), + _ = timeout => Err(format!("Coinage chain request timed out ({method})")), + } +} + +pub(super) async fn free_unload_token_limits( + rpc: &RpcClient, + snapshot: &Snapshot, +) -> Result<(u32, u32), String> { + const FUNCTION: &str = "get_free_unload_token_info"; + let definition = snapshot + .metadata + .view_function("Coinage", FUNCTION) + .ok_or("Coinage free unload token view missing")?; + let registry = snapshot.metadata.registry(); + let valid_output = registry.resolve(definition.output_type).is_some_and(|ty| { + matches!(&ty.type_def, TypeDef::Tuple(tuple) if tuple.fields.len() == 2 + && tuple.fields.iter().all(|field| matches!( + registry.resolve(field.id).map(|ty| &ty.type_def), + Some(TypeDef::Primitive(TypeDefPrimitive::U32)) + ))) + }); + if definition.inputs != 0 || !valid_output { + return Err("invalid Coinage free unload token view contract".into()); + } + let arguments = (definition.id, Vec::::new()).encode(); + let response = call( + rpc, + "state_call", + json!([ + "RuntimeViewFunction_execute_view_function", + hex0x(&arguments), + hex0x(&snapshot.at) + ]), + ) + .await?; + let output = + crate::runtime::statement_allowance::view::decode_response("Coinage", FUNCTION, response) + .map_err(|error| error.to_string())?; + decode_exact(&output) +} + +pub(super) async fn finalized(rpc: &RpcClient) -> Result<[u8; 32], String> { + hash_value(&call(rpc, "chain_getFinalizedHead", json!([])).await?) +} + +pub(super) fn hash_value(value: &Value) -> Result<[u8; 32], String> { + parse_hash(value.as_str().ok_or("chain hash is not a string")?) +} + +pub(super) fn number(value: &Value) -> Result { + value + .as_u64() + .or_else(|| { + value.as_str().and_then(|s| { + s.strip_prefix("0x") + .and_then(|s| u64::from_str_radix(s, 16).ok()) + .or_else(|| s.parse().ok()) + }) + }) + .ok_or_else(|| "chain number is invalid".into()) +} + +pub(super) async fn block_number(rpc: &RpcClient, hash: [u8; 32]) -> Result { + number(&call(rpc, "chain_getHeader", json!([hex0x(&hash)])).await?["number"]) +} + +pub(super) async fn metadata(rpc: &RpcClient, at: [u8; 32]) -> Result, String> { + // V16, unlike the legacy metadata RPC, includes the active extension pipeline map. + let result = call( + rpc, + "state_call", + json!(["Metadata_metadata_at_version", "0x10000000", hex0x(&at)]), + ) + .await?; + let bytes = hex_bytes(&result)?; + decode_exact::>>(&bytes)? + .ok_or_else(|| "Coinage requires runtime metadata V16".into()) +} + +pub(super) fn hex_bytes(value: &Value) -> Result, String> { + hex::decode( + value + .as_str() + .ok_or("chain bytes are not a string")? + .strip_prefix("0x") + .ok_or("chain bytes lack hex prefix")?, + ) + .map_err(|_| "chain bytes are invalid hex".into()) +} + +pub(super) async fn query( + rpc: &RpcClient, + keys: &[Vec], + at: [u8; 32], +) -> Result>>, String> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let hex_keys: Vec<_> = keys.iter().map(|key| hex0x(key)).collect(); + let result = call(rpc, "state_queryStorageAt", json!([hex_keys, hex0x(&at)])).await?; + decode_query(&result, keys, at) +} + +pub(super) fn decode_query( + result: &Value, + keys: &[Vec], + at: [u8; 32], +) -> Result>>, String> { + let blocks = result.as_array().ok_or("invalid storage snapshot")?; + if blocks.len() != 1 || hash_value(&blocks[0]["block"])? != at { + return Err("storage response does not match requested snapshot".into()); + } + let mut rows = HashMap::new(); + for change in blocks[0]["changes"] + .as_array() + .ok_or("invalid storage changes")? + { + let pair = change.as_array().ok_or("invalid storage change")?; + if pair.len() != 2 { + return Err("invalid storage change arity".into()); + } + let key = hex_bytes(&pair[0])?; + let value = if pair[1].is_null() { + None + } else { + Some(hex_bytes(&pair[1])?) + }; + if !keys.contains(&key) || rows.insert(key, value).is_some() { + return Err("unexpected or repeated storage key".into()); + } + } + keys.iter() + .map(|key| { + rows.get(key) + .cloned() + .ok_or_else(|| "storage response omitted requested key".into()) + }) + .collect() +} + +pub(super) async fn keys( + rpc: &RpcClient, + prefix: &[u8], + at: [u8; 32], +) -> Result>, String> { + let mut result = Vec::new(); + loop { + let start = result.last().map(|key: &Vec| hex0x(key)); + let page = call( + rpc, + "state_getKeysPaged", + json!([hex0x(prefix), 256, start, hex0x(&at)]), + ) + .await?; + let page = page.as_array().ok_or("invalid storage key page")?; + if page.len() > 256 { + return Err("oversized storage key page".into()); + } + for value in page { + let key = hex_bytes(value)?; + if !key.starts_with(prefix) || result.last().is_some_and(|previous| previous >= &key) { + return Err("invalid storage key pagination".into()); + } + result.push(key); + } + if page.len() < 256 { + return Ok(result); + } + } +} + +impl HostCoinageChain { + pub(super) async fn broadcast( + &self, + rpc: &RpcClient, + extrinsic: &[u8], + snapshot: &Snapshot, + ) -> Result<[u8; 32], String> { + self.ensure_session()?; + let head = finalized(rpc).await?; + if block_number(rpc, head).await? >= snapshot.valid_until() { + return Err("Coinage signing snapshot expired before broadcast".into()); + } + self.ensure_session()?; + // Submission is performed once. Neither a timeout nor a lost author subscription authorizes a retry. + let submit = rpc + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![hex0x(extrinsic)], + "author_unwatchExtrinsic", + ) + .fuse(); + let submit_timeout = futures_timer::Delay::new(Duration::from_secs(30)).fuse(); + futures::pin_mut!(submit, submit_timeout); + let mut watch = futures::select! { + result = submit => result.map_err(|_| "Coinage broadcast outcome unknown; recovery required")?, + _ = submit_timeout => return Err("Coinage broadcast outcome unknown; recovery required".into()), + }; + let observe = async { + let mut included = None; + while let Some(status) = watch.next().await { + let status = status.map_err(|_| "Coinage submission observation interrupted")?; + if let Some(hash) = status.get("finalized").or_else(|| status.get("inBlock")) { + included = Some(hash_value(hash)?); + break; + } + if ["invalid", "dropped", "usurped", "finalityTimeout"] + .iter() + .any(|key| status.get(*key).is_some()) + || status + .as_str() + .is_some_and(|s| matches!(s, "invalid" | "dropped")) + { + return Err("Coinage transaction rejected; recovery required".into()); + } + } + let included = included.ok_or("Coinage inclusion is unknown; recovery required")?; + let height = block_number(rpc, included).await?; + loop { + let head = finalized(rpc).await?; + let finalized_height = block_number(rpc, head).await?; + if finalized_height >= height { + let canonical = + hash_value(&call(rpc, "chain_getBlockHash", json!([height])).await?)?; + if canonical != included { + return Err("Coinage inclusion was retracted; recovery required".into()); + } + self.verify_dispatch(rpc, included, extrinsic).await?; + return Ok(included); + } + if finalized_height >= snapshot.valid_until() { + return Err("Coinage transaction expired; recovery required".into()); + } + futures_timer::Delay::new(Duration::from_secs(1)).await; + } + } + .fuse(); + let timeout = futures_timer::Delay::new(Duration::from_secs(180)).fuse(); + futures::pin_mut!(observe, timeout); + futures::select! { + result = observe => result, + _ = timeout => Err("Coinage finality is unknown; recovery required".into()), + } + } + + async fn verify_dispatch( + &self, + rpc: &RpcClient, + at: [u8; 32], + submitted: &[u8], + ) -> Result<(), String> { + let block = call(rpc, "chain_getBlock", json!([hex0x(&at)])).await?; + let mut index = None; + for (i, value) in block["block"]["extrinsics"] + .as_array() + .ok_or("missing block extrinsics")? + .iter() + .enumerate() + { + if hex_bytes(value)? == submitted { + if index.replace(i as u32).is_some() { + return Err("duplicate submitted extrinsic in block".into()); + } + } + } + let index = index.ok_or("finalized block does not contain submitted extrinsic")?; + // Events are encoded by the runtime which executed the block, i.e. the parent's state. + let parent = hash_value(&block["block"]["header"]["parentHash"])?; + let execution = self.snapshot(rpc, parent).await?; + let mut key = sp_crypto_hashing::twox_128(b"System").to_vec(); + key.extend_from_slice(&sp_crypto_hashing::twox_128(b"Events")); + let bytes = query(rpc, &[key], at) + .await? + .pop() + .flatten() + .ok_or("finalized System.Events missing")?; + dispatch_success(&execution, block_number(rpc, at).await?, bytes, index) + } +} + +pub(super) fn dispatch_success( + snapshot: &Snapshot, + height: u64, + bytes: Vec, + index: u32, +) -> Result<(), String> { + use subxt::config::substrate::{SpecVersionForRange, SubstrateConfig}; + let config = SubstrateConfig::builder() + .set_metadata_for_spec_versions([(snapshot.state.spec_version, snapshot.subxt.clone())]) + .set_spec_version_for_block_ranges([SpecVersionForRange { + block_range: 0..u64::MAX, + spec_version: snapshot.state.spec_version, + transaction_version: snapshot.state.transaction_version, + }]) + .build(); + let client = subxt::OfflineClient::new_with_config(config); + let at = client + .at_block(height) + .map_err(|_| "cannot prepare event decoder")?; + let events = at.events().from_bytes(bytes); + let mut success = false; + for event in events.iter() { + let event = event.map_err(|_| "cannot decode finalized events")?; + if event.phase() != subxt::events::Phase::ApplyExtrinsic(index) + || event.pallet_name() != "System" + { + continue; + } + match event.event_name() { + "ExtrinsicFailed" => return Err("Coinage transaction failed on chain".into()), + "ExtrinsicSuccess" if success => return Err("duplicate transaction outcome".into()), + "ExtrinsicSuccess" => success = true, + _ => (), + } + } + if success { + Ok(()) + } else { + Err("finalized transaction has no explicit success event".into()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain/sender.rs b/rust/crates/truapi-server/src/runtime/coinage_chain/sender.rs new file mode 100644 index 000000000..0cb3241fa --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain/sender.rs @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-ffi/src/coinage_sender.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +use super::transaction::{Snapshot, constant, decode_exact, denomination_value, storage_key}; +use super::{HostCoinageChain, crypto::HostPersonProof, rpc}; +use parity_scale_codec::Encode; +use scale_decode::DecodeAsType; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use subxt_rpcs::RpcClient; +use truapi_coinage::{ + AliasState, BandersnatchRingProofProvider, CheckpointBlock, Coin, PersonOriginKind, + PreparedUnloadGroup, RegularTransferSubmitter, ResolvedUnloadToken, RingProofParams, + RingProofProvider, SplitTransferSubmission, UnloadGroupDraft, UnloadProofRequest, +}; +use truapi_platform::async_trait; + +const PEOPLE: &[u8; 32] = b"pop:polkadot.network/people "; +const PEOPLE_LITE: &[u8; 32] = b"pop:polkadot.network/people-lite"; + +impl HostCoinageChain { + async fn person_proof( + &self, + rpc: &RpcClient, + snapshot: &Snapshot, + ) -> Result, String> { + let key = storage_key(&snapshot.storage, "NetworkSuffix", "NetworkSuffix", &[])?; + let value = rpc::query(rpc, &[key], snapshot.at) + .await? + .pop() + .flatten() + .ok_or("Coinage network suffix is missing")?; + let suffix: Vec = decode_exact(&value)?; + let suffix = + core::str::from_utf8(&suffix).map_err(|_| "Coinage network suffix is invalid")?; + self.ensure_session()?; + Ok(Arc::new(HostPersonProof::new( + &self.inner.entropy, + suffix, + self.inner.session_valid.clone(), + )?)) + } + + async fn resolve_person( + &self, + rpc: &RpcClient, + snapshot: &Snapshot, + proof: &HostPersonProof, + ) -> Result<(PersonOriginKind, RingProofParams), String> { + for (origin, collection) in [ + (PersonOriginKind::Full, PEOPLE), + (PersonOriginKind::Lite, PEOPLE_LITE), + ] { + let member = proof.member(origin)?; + let key = storage_key( + &snapshot.storage, + "Members", + "Members", + &[collection.encode(), member.encode()], + )?; + let Some(value) = rpc::query(rpc, &[key], snapshot.at).await?.pop().flatten() else { + continue; + }; + let position: truapi_coinage::members::RingPosition = decode_exact(&value)?; + let truapi_coinage::members::RingPosition::Included { ring_index, .. } = position + else { + continue; + }; + if let Some(ring) = + ring_snapshot(rpc, snapshot, collection, ring_index, &[member]).await? + { + return Ok((origin, ring)); + } + } + Err("no finalized full or lite People origin is proof-ready".into()) + } + + async fn tokens( + &self, + rpc: &RpcClient, + snapshot: &Snapshot, + proof: &HostPersonProof, + origin: PersonOriginKind, + count: usize, + ) -> Result, String> { + let duration: u32 = constant( + &snapshot.metadata, + "Coinage", + "UnloadTokenTimePeriodPeopleLitePeople", + )?; + // Eligibility is price-dependent and differs for full and lite people. + // Read it at the same finalized snapshot as the token-consumption checks. + let (full, lite) = rpc::free_unload_token_limits(rpc, snapshot).await?; + let maximum = match origin { + PersonOriginKind::Full => full, + PersonOriginKind::Lite => lite, + }; + if duration == 0 || maximum > 65536 { + return Err("invalid Coinage free-token bounds".into()); + } + if maximum == 0 { + return Err("no free Coinage unload tokens available for this origin".into()); + } + // Chain time, never an ambient system clock, defines the eligibility period. + let key = storage_key(&snapshot.storage, "Timestamp", "Now", &[])?; + let now: u64 = decode_exact( + &rpc::query(rpc, &[key], snapshot.at) + .await? + .pop() + .flatten() + .ok_or("finalized timestamp missing")?, + )?; + let now = now / 1000; + let current = u32::try_from(now / u64::from(duration)) + .map_err(|_| "Coinage token period out of range")?; + let old = u32::try_from(now.saturating_sub(3600) / u64::from(duration)) + .map_err(|_| "Coinage token period out of range")?; + let periods = if old == current { + vec![current] + } else { + vec![old, current] + }; + let mut available = Vec::with_capacity(count); + for period in periods { + // Bound each query and avoid deriving unused token aliases once enough are found. + for first in (0..maximum).step_by(128) { + let mut candidates = Vec::new(); + let mut keys = Vec::new(); + for counter in first..maximum.min(first.saturating_add(128)) { + self.ensure_session()?; + let token = ResolvedUnloadToken { period, counter }; + let alias = proof.alias(origin, &token.context())?; + keys.push(storage_key( + &snapshot.storage, + "Coinage", + "ConsumedFreeUnloadTokens", + &[period.encode(), alias.encode()], + )?); + candidates.push(token); + } + for (token, value) in candidates + .into_iter() + .zip(rpc::query(rpc, &keys, snapshot.at).await?) + { + if value.is_none() { + available.push(token); + } + if available.len() == count { + return Ok(available); + } + } + } + } + Err("insufficient finalized free Coinage unload tokens".into()) + } + + async fn prepare_groups_at( + &self, + rpc: &RpcClient, + snapshot: &Snapshot, + groups: &[UnloadGroupDraft], + ) -> Result, String> { + if groups.is_empty() { + return Ok(Vec::new()); + } + self.bind_denominations(snapshot)?; + let maximum: u32 = constant(&snapshot.metadata, "Coinage", "MaxConsolidation")?; + let person = self.person_proof(rpc, snapshot).await?; + let (origin, people_ring) = self.resolve_person(rpc, snapshot, &person).await?; + let tokens = self + .tokens(rpc, snapshot, &person, origin, groups.len()) + .await?; + self.ensure_session()?; + let vouchers = &self.inner.vouchers; + let mut prepared = Vec::with_capacity(groups.len()); + for (group, token) in groups.iter().zip(tokens) { + if group.vouchers.is_empty() || group.vouchers.len() > maximum as usize { + return Err("voucher group violates runtime consolidation bounds".into()); + } + let mut unique = HashSet::new(); + let required = group + .vouchers + .iter() + .map(|voucher| { + if voucher.exponent != group.recycler.exponent + || !unique.insert(voucher.derivation_index) + { + return Err("invalid Coinage voucher group".into()); + } + self.ensure_session()?; + vouchers.public_key(voucher.derivation_index, self.inner.crypto.as_ref()) + }) + .collect::, String>>()?; + let collection = snapshot.storage.collection(group.recycler.exponent); + let recycler_ring = + ring_snapshot(rpc, snapshot, &collection, group.recycler.index, &required) + .await? + .ok_or("voucher recycler ring is not proof-ready")?; + prepared.push(PreparedUnloadGroup { + draft: group.clone(), + readiness_block_hash: snapshot.at, + recycler_revision: recycler_ring.ring_revision, + origin: truapi_coinage::UnloadOriginPreparation { + recycler_ring, + person_origin: origin, + people_ring: people_ring.clone(), + token, + }, + }); + } + Ok(prepared) + } + + async fn checkpoint(&self, id: &str, snapshot: &Snapshot) -> Result<(), String> { + self.ensure_session()?; + self.inner + .wal + .update_checkpoint( + id, + CheckpointBlock::Known { + number: snapshot.number, + hash: snapshot.at, + }, + ) + .await + .map_err(|_| "Coinage durable checkpoint failed")?; + self.ensure_session() + } + + fn destinations( + &self, + coins: &[&Coin], + snapshot: &Snapshot, + ) -> Result< + ( + Vec, + Vec<[u8; 32]>, + u128, + ), + String, + > { + if coins.is_empty() { + return Err("Coinage transfer has no destinations".into()); + } + self.ensure_session()?; + let factory = &self.inner.coins; + let context = snapshot.denomination_context()?; + let mut unique = HashSet::new(); + let mut grouped = BTreeMap::>::new(); + let mut owners = Vec::with_capacity(coins.len()); + let mut total = 0u128; + for coin in coins { + if !unique.insert(coin.derivation_index) { + return Err("duplicate Coinage destination".into()); + } + self.ensure_session()?; + let owner = factory.public_key(coin.derivation_index)?; + total = total + .checked_add(denomination_value(&context, coin.exponent)?) + .ok_or("Coinage output amount overflow")?; + owners.push(owner); + grouped.entry(coin.exponent).or_default().push(owner); + } + Ok(( + grouped + .into_iter() + .map( + |(exponent, accounts)| truapi_coinage::pallet::SplitDestination { + exponent, + accounts, + }, + ) + .collect(), + owners, + total, + )) + } + + async fn verify_outputs( + &self, + rpc: &RpcClient, + coins: &[&Coin], + owners: &[[u8; 32]], + snapshot: &Snapshot, + ) -> Result<(), String> { + let outputs = Self::coins_at(rpc, owners, snapshot).await?; + if outputs.len() != coins.len() + || outputs + .iter() + .zip(coins) + .any(|(output, coin)| output.is_none_or(|output| output.exponent != coin.exponent)) + { + return Err("finalized Coinage outputs do not match the transfer".into()); + } + Ok(()) + } +} + +#[async_trait] +impl RegularTransferSubmitter for HostCoinageChain { + async fn prepare_unload_groups( + &self, + groups: &[UnloadGroupDraft], + ) -> Result, String> { + self.ensure_session()?; + if groups.is_empty() { + return Ok(Vec::new()); + } + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + let result = self.prepare_groups_at(&rpc, &snapshot, groups).await?; + self.ensure_session()?; + Ok(result) + } + + async fn submit_split(&self, submission: &SplitTransferSubmission) -> Result<(), String> { + let _gate = self.inner.submission.lock().await; + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + let context = self.bind_denominations(&snapshot)?; + let coins: Vec<_> = submission + .recipient_coins + .iter() + .chain(&submission.change_coins) + .collect(); + let (destinations, owners, total) = self.destinations(&coins, &snapshot)?; + if total != denomination_value(&context, submission.overflow_coin.exponent)? { + return Err("Coinage split does not conserve its input denomination".into()); + } + self.ensure_session()?; + let factory = &self.inner.coins; + let keypair = factory.keypair(submission.overflow_coin.derivation_index)?; + let source = keypair.public.to_bytes(); + if owners.contains(&source) { + return Err("Coinage split reuses its source".into()); + } + let mut check = vec![source]; + check.extend_from_slice(&owners); + let before = Self::coins_at(&rpc, &check, &snapshot).await?; + if before[0].is_none_or(|coin| coin.exponent != submission.overflow_coin.exponent) + || before[1..].iter().any(Option::is_some) + { + return Err("Coinage split input or outputs changed".into()); + } + let [pallet, index] = snapshot + .metadata + .call_indices("Coinage", "split") + .map_err(|_| "Coinage split call unavailable")?; + let call = truapi_coinage::pallet::split_call(pallet, index, &destinations) + .map_err(|_| "invalid Coinage split call")?; + let nonce = self.nonce(&rpc, &source, &snapshot).await?; + self.ensure_session()?; + let extrinsic = snapshot.signed(&keypair, &call, nonce)?; + self.checkpoint(&submission.wal_entry_id, &snapshot).await?; + let finalized = self.broadcast(&rpc, &extrinsic, &snapshot).await?; + let finalized_snapshot = self.snapshot(&rpc, finalized).await?; + if Self::coins_at(&rpc, &[source], &finalized_snapshot).await?[0].is_some() { + return Err("finalized Coinage split left its source unconsumed".into()); + } + self.verify_outputs(&rpc, &coins, &owners, &finalized_snapshot) + .await + } + + async fn submit_unload_group(&self, submission: &PreparedUnloadGroup) -> Result<(), String> { + let _gate = self.inner.submission.lock().await; + let rpc = self.connect().await?; + let at = rpc::finalized(&rpc).await?; + let snapshot = self.snapshot(&rpc, at).await?; + // Re-resolve every ring/revision/token at the signing snapshot. A prepared + // preview is not authority to sign stale roots or a now-consumed token. + let fresh = self + .prepare_groups_at(&rpc, &snapshot, core::slice::from_ref(&submission.draft)) + .await? + .pop() + .ok_or("missing unload preparation")?; + let coins: Vec<_> = fresh + .draft + .recipient_coins + .iter() + .chain(&fresh.draft.change_coins) + .collect(); + let (destinations, owners, total) = self.destinations(&coins, &snapshot)?; + let expected = denomination_value( + &snapshot.denomination_context()?, + fresh.draft.recycler.exponent, + )? + .checked_mul(fresh.draft.vouchers.len() as u128) + .ok_or("Coinage input amount overflow")?; + if expected != total { + return Err("Coinage unload does not conserve input denominations".into()); + } + if Self::coins_at(&rpc, &owners, &snapshot) + .await? + .iter() + .any(Option::is_some) + { + return Err("Coinage unload destination already exists".into()); + } + let person = self.person_proof(&rpc, &snapshot).await?; + self.ensure_session()?; + let provider = BandersnatchRingProofProvider::new( + &self.inner.entropy, + self.inner.crypto.clone(), + person, + ); + let indices: Vec<_> = fresh + .draft + .vouchers + .iter() + .map(|voucher| voucher.derivation_index) + .collect(); + let aliases = provider + .unload_aliases(&indices) + .map_err(|_| "Coinage unload alias derivation failed")?; + let exponent = i8::try_from(fresh.draft.recycler.exponent) + .map_err(|_| "Coinage exponent out of range")?; + let alias_keys = |snapshot: &Snapshot| { + aliases + .iter() + .map(|alias| { + snapshot.storage.coinage_key( + &truapi_coinage::CoinageStorageKey::RecyclerAlias { + exponent, + ring_index: fresh.draft.recycler.index, + alias: *alias, + }, + ) + }) + .collect::, String>>() + }; + if rpc::query(&rpc, &alias_keys(&snapshot)?, snapshot.at) + .await? + .iter() + .any(Option::is_some) + { + return Err("Coinage unload alias is consumed or locked".into()); + } + let [pallet, index] = snapshot + .metadata + .call_indices("Coinage", "unload_recycler_into_coins") + .map_err(|_| "Coinage unload call unavailable")?; + let call = truapi_coinage::pallet::unload_recycler_into_coins_call( + pallet, + index, + snapshot.storage.instance_id, + &aliases, + exponent, + fresh.draft.recycler.index, + fresh.recycler_revision, + &destinations, + 0, + ) + .map_err(|_| "invalid Coinage unload call")?; + let extensions = snapshot.extensions(0)?; + let implication = snapshot.implication(&call, &extensions)?; + self.ensure_session()?; + let proof = provider + .unload_proof(&UnloadProofRequest { + recycler: fresh.draft.recycler, + voucher_derivation_indices: indices, + recycler_ring: fresh.origin.recycler_ring, + person_origin: fresh.origin.person_origin, + people_ring: fresh.origin.people_ring, + token: fresh.origin.token, + inherited_implication: implication, + }) + .map_err(|_| "Coinage unload proof failed")?; + if proof.aliases != aliases { + return Err("Coinage proof aliases changed".into()); + } + self.ensure_session()?; + let extrinsic = snapshot.unsigned(&call, extensions, &proof)?; + self.checkpoint(&fresh.draft.wal_entry_id, &snapshot) + .await?; + let finalized = self.broadcast(&rpc, &extrinsic, &snapshot).await?; + let finalized_snapshot = self.snapshot(&rpc, finalized).await?; + let values = rpc::query(&rpc, &alias_keys(&finalized_snapshot)?, finalized).await?; + for value in values { + if value + .as_deref() + .map(decode_exact::) + .transpose()? + != Some(AliasState::Unloaded) + { + return Err("finalized Coinage unload did not consume every alias".into()); + } + } + self.verify_outputs(&rpc, &coins, &owners, &finalized_snapshot) + .await + } +} + +#[derive(DecodeAsType)] +struct CollectionInfo { + ring_size: RingExponent, +} +#[derive(DecodeAsType)] +enum RingExponent { + R2e9, + R2e10, + R2e14, +} + +async fn ring_snapshot( + rpc: &RpcClient, + snapshot: &Snapshot, + collection: &[u8; 32], + index: u32, + required: &[[u8; 32]], +) -> Result, String> { + let keys = [ + storage_key( + &snapshot.storage, + "Members", + "Collections", + &[collection.encode()], + )?, + storage_key( + &snapshot.storage, + "Members", + "RingKeysStatus", + &[collection.encode(), index.encode()], + )?, + storage_key( + &snapshot.storage, + "Members", + "Root", + &[collection.encode(), index.encode()], + )?, + ]; + let values = rpc::query(rpc, &keys, snapshot.at).await?; + let [Some(collection_info), Some(status), Some(root)] = values.as_slice() else { + return Ok(None); + }; + let type_id = snapshot + .metadata + .storage_value_type("Members", "Collections") + .ok_or("Members collection type unavailable")?; + let mut input = collection_info.as_slice(); + let info = CollectionInfo::decode_as_type(&mut input, type_id, snapshot.metadata.registry()) + .map_err(|_| "invalid Members collection")?; + if !input.is_empty() { + return Err("Members collection has trailing bytes".into()); + } + let exponent = match info.ring_size { + RingExponent::R2e9 => 9, + RingExponent::R2e10 => 10, + RingExponent::R2e14 => 14, + }; + let status: truapi_coinage::members::RingStatus = decode_exact(status)?; + let root: truapi_coinage::members::RingRoot = decode_exact(root)?; + if status.included == 0 || status.included > status.total || status.total > (1 << exponent) { + return Ok(None); + } + let prefix = storage_key( + &snapshot.storage, + "Members", + "RingKeys", + &[collection.encode(), index.encode()], + )?; + let mut pages = rpc::keys(rpc, &prefix, snapshot.at).await?; + pages.sort_by_key(|key| { + key.get(key.len().saturating_sub(4)..) + .and_then(|bytes| <[u8; 4]>::try_from(bytes).ok()) + .map(u32::from_le_bytes) + }); + for (page, key) in pages.iter().enumerate() { + let expected = storage_key( + &snapshot.storage, + "Members", + "RingKeys", + &[collection.encode(), index.encode(), (page as u32).encode()], + )?; + if key != &expected { + return Err("Members ring page sequence is incomplete".into()); + } + } + let mut members = Vec::with_capacity(status.included as usize); + for value in rpc::query(rpc, &pages, snapshot.at).await? { + let value = value.ok_or("Members ring page missing")?; + let page: Vec<[u8; 32]> = decode_exact(&value)?; + members.extend( + page.into_iter() + .take((status.included as usize).saturating_sub(members.len())), + ); + if members.len() == status.included as usize { + break; + } + } + if members.len() != status.included as usize + || required.iter().any(|member| !members.contains(member)) + { + return Ok(None); + } + if members.iter().collect::>().len() != members.len() { + return Err("Members ring contains duplicate keys".into()); + } + Ok(Some(RingProofParams { + ring_exponent: exponent, + ring_index: index, + ring_revision: root.revision, + ring_members: members, + })) +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain/tests.rs b/rust/crates/truapi-server/src/runtime/coinage_chain/tests.rs new file mode 100644 index 000000000..c7427d565 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain/tests.rs @@ -0,0 +1,580 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-ffi/src/coinage_transfer.rs tests. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +use super::transaction::{AS_COINAGE, Snapshot, constant, mortal_era, replace}; +use super::*; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; +use serde_json::json; +use subxt::ext::scale_encode::{EncodeAsFields, Field}; +use subxt::ext::scale_value::{Primitive, Value as ScaleValue}; + +const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); + +fn snapshot() -> Snapshot { + Snapshot::new( + METADATA.to_vec(), + [3; 32], + 1_025, + [4; 32], + 3_000_000, + 1, + Some(0), + ) + .unwrap() +} +fn source() -> schnorrkel::Keypair { + schnorrkel::MiniSecretKey::from_bytes(&[17; 32]) + .unwrap() + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) +} +fn request() -> ExternalCoinTransferRequest { + let key = source(); + ExternalCoinTransferRequest { + source_secret: truapi_coinage::MemoEntry(key.secret.to_bytes()), + source_public: key.public.to_bytes(), + recipient: [9; 32], + exponent: 2, + asset_unit: 10, + amount_planks: 40, + } +} + +#[test] +fn preflight_rejects_denomination_drift_and_reused_destination() { + let context = DenominationBreakdownContext { + asset_unit: 10, + min_exponent: 0, + max_exponent: 4, + precision: 2, + }; + let live = Some(OnChainCoin { + exponent: 2, + age: 3, + }); + let mut request = request(); + assert!(validate_live_transfer(&request, &context, &[live, None]).is_ok()); + request.asset_unit = 11; + assert!(validate_live_transfer(&request, &context, &[live, None]).is_err()); + request.asset_unit = 10; + request.amount_planks = 41; + assert!(validate_live_transfer(&request, &context, &[live, None]).is_err()); + request.amount_planks = 40; + assert!(validate_live_transfer(&request, &context, &[None, None]).is_err()); + assert!(validate_live_transfer(&request, &context, &[live, live]).is_err()); +} + +#[test] +fn finality_requires_consumed_source_and_exact_recipient_denomination() { + let request = request(); + let coin = OnChainCoin { + exponent: 2, + age: 0, + }; + assert_eq!( + validate_finalized_transfer(&request, &[None, Some(coin)]).unwrap(), + coin + ); + assert!(validate_finalized_transfer(&request, &[Some(coin), Some(coin)]).is_err()); + assert!(validate_finalized_transfer(&request, &[None, None]).is_err()); + assert!( + validate_finalized_transfer( + &request, + &[ + None, + Some(OnChainCoin { + exponent: 1, + age: 0 + }) + ] + ) + .is_err() + ); +} + +#[test] +fn storage_snapshot_requires_explicit_rows_and_requested_finalized_hash() { + let at = [7; 32]; + let keys = vec![vec![1], vec![2]]; + let good = json!([{"block":hex0x(&at),"changes":[["0x02",null],["0x01","0xab"]]}]); + assert_eq!( + rpc::decode_query(&good, &keys, at).unwrap(), + vec![Some(vec![0xab]), None] + ); + let missing = json!([{"block":hex0x(&at),"changes":[["0x01","0xab"]]}]); + assert!(rpc::decode_query(&missing, &keys, at).is_err()); + assert!(rpc::decode_query(&good, &keys, [8; 32]).is_err()); + let duplicate = + json!([{"block":hex0x(&at),"changes":[["0x01",null],["0x01",null],["0x02",null]]}]); + assert!(rpc::decode_query(&duplicate, &keys, at).is_err()); +} + +#[test] +fn free_unload_allowance_rejects_an_ambiguous_same_width_runtime_type() { + use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + let mut metadata = frame_metadata::RuntimeMetadataPrefixed::decode(&mut &METADATA[..]).unwrap(); + let frame_metadata::RuntimeMetadata::V16(runtime) = &mut metadata.1 else { + panic!("expected V16 fixture"); + }; + let u64_type = runtime + .types + .types + .iter() + .find(|ty| matches!(ty.ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U64))) + .unwrap() + .id; + let function = runtime + .pallets + .iter_mut() + .find(|p| p.name == "Coinage") + .unwrap() + .view_functions + .iter_mut() + .find(|function| function.name == "get_free_unload_token_info") + .unwrap(); + // A u64 response has the same eight bytes as (u32, u32), but must not be + // interpreted as two origin-specific spending allowances. + function.output = u64_type.into(); + let snapshot = Snapshot::new( + metadata.encode(), + [3; 32], + 1_025, + [4; 32], + 3_000_000, + 1, + Some(0), + ) + .unwrap(); + let rpc = subxt_rpcs::RpcClient::new(ScriptedRpc::default()); + assert!(futures::executor::block_on(rpc::free_unload_token_limits(&rpc, &snapshot)).is_err()); +} + +#[test] +fn coin_queries_follow_the_queried_blocks_storage_hashers() { + use frame_metadata::v16::{StorageEntryType, StorageHasher}; + let owner = [17; 32]; + for hasher in [StorageHasher::Blake2_128Concat, StorageHasher::Twox64Concat] { + let mut metadata = + frame_metadata::RuntimeMetadataPrefixed::decode(&mut &METADATA[..]).unwrap(); + let frame_metadata::RuntimeMetadata::V16(runtime) = &mut metadata.1 else { + panic!("V16 fixture") + }; + let storage = runtime + .pallets + .iter_mut() + .find(|p| p.name == "Coinage") + .unwrap() + .storage + .as_mut() + .unwrap(); + let entry = storage + .entries + .iter_mut() + .find(|entry| entry.name == "CoinsByOwner") + .unwrap(); + let StorageEntryType::Map { hashers, .. } = &mut entry.ty else { + panic!("coin map") + }; + hashers[0] = hasher.clone(); + let snapshot = Snapshot::new( + metadata.encode(), + [3; 32], + 1_025, + [4; 32], + 3_000_000, + 1, + Some(0), + ) + .unwrap(); + let requested = snapshot + .storage + .coinage_key(&truapi_coinage::CoinageStorageKey::Coin(owner)) + .unwrap(); + let mut stored_key = sp_crypto_hashing::twox_128(b"Coinage").to_vec(); + stored_key.extend_from_slice(&sp_crypto_hashing::twox_128(b"CoinsByOwner")); + match hasher { + StorageHasher::Blake2_128Concat => { + stored_key.extend_from_slice(&sp_crypto_hashing::blake2_128(&owner)) + } + StorageHasher::Twox64Concat => { + stored_key.extend_from_slice(&sp_crypto_hashing::twox_64(&owner)) + } + _ => unreachable!(), + } + stored_key.extend_from_slice(&owner); + // A storage backend returns None for an obsolete physical key. A live + // coin must remain visible across this metadata-only runtime change. + let row = (requested == stored_key).then(|| (3i8, 5u16).encode()); + assert_eq!( + row.as_deref() + .map(decode_exact::<(i8, u16)>) + .transpose() + .unwrap(), + Some((3, 5)) + ); + } +} + +#[test] +fn coin_queries_reject_incompatible_metadata_key_types() { + let mut metadata = frame_metadata::RuntimeMetadataPrefixed::decode(&mut &METADATA[..]).unwrap(); + let frame_metadata::RuntimeMetadata::V16(runtime) = &mut metadata.1 else { + panic!("V16 fixture") + }; + let integer = runtime + .types + .types + .iter() + .find(|ty| matches!(ty.ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U64))) + .unwrap() + .id; + let entry = runtime + .pallets + .iter_mut() + .find(|p| p.name == "Coinage") + .unwrap() + .storage + .as_mut() + .unwrap() + .entries + .iter_mut() + .find(|entry| entry.name == "CoinsByOwner") + .unwrap(); + let frame_metadata::v16::StorageEntryType::Map { key, .. } = &mut entry.ty else { + panic!("coin map") + }; + key.id = integer; + assert!( + Snapshot::new( + metadata.encode(), + [3; 32], + 1_025, + [4; 32], + 3_000_000, + 1, + Some(0) + ) + .is_err() + ); +} + +#[test] +fn instance_runtime_never_guesses_an_asset_and_rejects_other_assets() { + assert!( + Snapshot::new( + METADATA.to_vec(), + [3; 32], + 1_025, + [4; 32], + 3_000_000, + 1, + None + ) + .is_err() + ); + let snapshot = snapshot(); + assert!( + snapshot.denomination_context().is_err(), + "instance asset unit must come from storage at the queried block" + ); + let key = truapi_coinage::CoinageStorageKey::Coin([7; 32]); + let mut selected = (0u32, 3i8, 5u16).encode(); + snapshot + .storage + .normalize_value(&key, &mut selected) + .unwrap(); + assert_eq!(decode_exact::<(i8, u16)>(&selected).unwrap(), (3, 5)); + let mut foreign = (1u32, 3i8, 5u16).encode(); + assert!( + snapshot + .storage + .normalize_value(&key, &mut foreign) + .is_err() + ); + let recycler = truapi_coinage::CoinageStorageKey::Recycler([7; 32]); + let mut selected = (0u32, 3i8).encode(); + snapshot + .storage + .normalize_value(&recycler, &mut selected) + .unwrap(); + assert_eq!(decode_exact::(&selected).unwrap(), 3); + let mut foreign = (1u32, 3i8).encode(); + assert!( + snapshot + .storage + .normalize_value(&recycler, &mut foreign) + .is_err() + ); +} + +#[test] +fn presence_only_recycler_runtime_is_rejected_before_queries_or_spending() { + // This older snapshot predates RecyclerAliasStates entirely. Treating its + // missing lock-state map as empty would make unsafe recovery decisions. + let metadata = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + assert!( + Snapshot::new( + metadata.to_vec(), + [3; 32], + 1_025, + [4; 32], + 1_000_032, + 1, + None + ) + .is_err() + ); +} + +#[test] +fn coin_signature_binds_runtime_coin_origin_nonce_checkpoint_and_call() { + let snapshot = snapshot(); + let source = source(); + let [pallet, index] = snapshot + .metadata + .call_indices("Coinage", "transfer") + .unwrap(); + let call = truapi_coinage::pallet::transfer_call(pallet, index, &[9; 32]); + let transaction = snapshot.signed(&source, &call, 7).unwrap(); + let mut extensions = snapshot.extensions(7).unwrap(); + replace( + &mut extensions, + AS_COINAGE, + snapshot.as_coin().unwrap(), + vec![], + ) + .unwrap(); + let verify = extensions + .iter() + .position(|extension| extension.id == "VerifyMultiSignature") + .unwrap(); + let mut body = transaction.as_slice(); + let size = Compact::::decode(&mut body).unwrap().0 as usize; + assert_eq!(size, body.len()); + let offset = 2 + extensions[..verify] + .iter() + .map(|extension| extension.extra.len()) + .sum::(); + let signature = schnorrkel::Signature::from_bytes(&body[offset + 2..offset + 66]).unwrap(); + let mut implication = vec![snapshot.metadata.extension_version()]; + implication.extend_from_slice(&call); + for extension in &extensions[verify + 1..] { + implication.extend_from_slice(&extension.extra); + } + for extension in &extensions[verify + 1..] { + implication.extend_from_slice(&extension.additional_signed); + } + source + .public + .verify_simple( + b"substrate", + &sp_crypto_hashing::blake2_256(&implication), + &signature, + ) + .unwrap(); + *implication.last_mut().unwrap() ^= 1; + assert!( + source + .public + .verify_simple( + b"substrate", + &sp_crypto_hashing::blake2_256(&implication), + &signature + ) + .is_err() + ); + assert!(body.ends_with(&call)); +} + +#[test] +fn mortal_era_expires_before_the_wal_releases_its_inputs() { + let snapshot = snapshot(); + let era = u16::from_le_bytes(mortal_era(snapshot.period, snapshot.number).unwrap()); + let period = 2u64 << (era & 15); + let phase = u64::from(era >> 4); + assert_eq!(period, snapshot.period); + assert_eq!(phase, snapshot.number % period); + assert!(snapshot.valid_until() <= snapshot.number + truapi_coinage::WAL_MORTALITY_BLOCKS); + assert!(mortal_era(512, 1_025).is_err()); + assert!(mortal_era(3, 1_025).is_err()); +} + +#[test] +fn exact_scale_constant_decode_rejects_trailing_bytes() { + assert_eq!(decode_exact::(&7u32.encode()).unwrap(), 7); + let mut bytes = 7u32.encode(); + bytes.push(0); + assert!(decode_exact::(&bytes).is_err()); + let snapshot = snapshot(); + let period: u32 = constant( + &snapshot.metadata, + "Coinage", + "UnloadTokenTimePeriodPeopleLitePeople", + ) + .unwrap(); + assert_ne!(period, 0); + let key = transaction::storage_key( + &snapshot.storage, + "Coinage", + "ConsumedFreeUnloadTokens", + &[7u32.encode(), [8u8; 32].encode()], + ) + .unwrap(); + let other = transaction::storage_key( + &snapshot.storage, + "Coinage", + "ConsumedFreeUnloadTokens", + &[8u32.encode(), [8u8; 32].encode()], + ) + .unwrap(); + assert_ne!(key, other); +} + +#[test] +fn only_explicit_success_at_the_submitted_extrinsic_index_completes() { + let snapshot = snapshot(); + assert!( + rpc::dispatch_success( + &snapshot, + 1_026, + events(&snapshot, "ExtrinsicSuccess", 3), + 3 + ) + .is_ok() + ); + assert!( + rpc::dispatch_success( + &snapshot, + 1_026, + events(&snapshot, "ExtrinsicSuccess", 2), + 3 + ) + .is_err() + ); + assert!( + rpc::dispatch_success(&snapshot, 1_026, events(&snapshot, "ExtrinsicFailed", 3), 3) + .is_err() + ); + assert!(rpc::dispatch_success(&snapshot, 1_026, Compact(0u32).encode(), 3).is_err()); +} + +#[test] +fn expired_session_cannot_derive_voucher_material_or_create_proofs() { + let crypto = HostVoucherCryptography::new(Arc::new(|| false)); + let seed = truapi_coinage::VoucherSeed([1; 32]); + assert!(crypto.member_key(&seed).is_err()); + assert!(crypto.sign(&seed, b"message").is_err()); + assert!(crypto.alias(&seed, b"context").is_err()); + assert!( + crypto + .ring_vrf_proof(&seed, 9, &[], b"context", b"message") + .is_err() + ); +} + +#[test] +fn voucher_ownership_signature_verifies_and_ring_proof_rejects_nonmembers() { + use verifiable::{GenerateVerifiable, ring::bandersnatch::BandersnatchVrfVerifiable}; + let crypto = HostVoucherCryptography::new(Arc::new(|| true)); + let seed = truapi_coinage::VoucherSeed([7; 32]); + let member = crypto.member_key(&seed).unwrap(); + let signature = crypto.sign(&seed, b"coin-owner").unwrap(); + assert!(BandersnatchVrfVerifiable::verify_signature( + &signature, + b"coin-owner", + &member + )); + assert!(!BandersnatchVrfVerifiable::verify_signature( + &signature, + b"different-owner", + &member + )); + let outsider = crypto + .member_key(&truapi_coinage::VoucherSeed([8; 32])) + .unwrap(); + assert!( + crypto + .ring_vrf_proof(&seed, 9, &[outsider], b"recycler-context", b"implication") + .is_err() + ); +} + +fn events(snapshot: &Snapshot, name: &str, index: u32) -> Vec { + let system = snapshot.subxt.pallet_by_name("System").unwrap(); + let event = system + .event_variants() + .unwrap() + .iter() + .find(|event| event.name == name) + .unwrap(); + let values = ScaleValue::unnamed_composite( + event + .fields + .iter() + .map(|field| default_value(snapshot.subxt.types(), field.ty.id)), + ); + let mut fields = event + .fields + .iter() + .map(|field| Field::new(field.ty.id, field.name.as_deref())); + let mut bytes = Compact(1u32).encode(); + subxt::events::Phase::ApplyExtrinsic(index).encode_to(&mut bytes); + system.event_index().encode_to(&mut bytes); + event.index.encode_to(&mut bytes); + values + .encode_as_fields_to(&mut fields, snapshot.subxt.types(), &mut bytes) + .unwrap(); + Vec::<[u8; 32]>::new().encode_to(&mut bytes); + bytes +} + +fn default_value(types: &PortableRegistry, id: u32) -> ScaleValue { + match &types.resolve(id).unwrap().type_def { + TypeDef::Composite(value) => ScaleValue::unnamed_composite( + value + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ), + TypeDef::Tuple(value) => ScaleValue::unnamed_composite( + value + .fields + .iter() + .map(|field| default_value(types, field.id)), + ), + TypeDef::Variant(value) => { + let variant = value.variants.first().unwrap(); + ScaleValue::unnamed_variant( + variant.name.clone(), + variant + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ) + } + TypeDef::Sequence(_) => ScaleValue::unnamed_composite([]), + TypeDef::Array(value) => ScaleValue::unnamed_composite( + (0..value.len).map(|_| default_value(types, value.type_param.id)), + ), + TypeDef::Compact(_) => ScaleValue::u128(0), + TypeDef::BitSequence(_) => ScaleValue::bit_sequence(subxt::ext::scale_bits::Bits::new()), + TypeDef::Primitive(value) => match value { + TypeDefPrimitive::Bool => ScaleValue::bool(false), + TypeDefPrimitive::Char => ScaleValue::char('\0'), + TypeDefPrimitive::Str => ScaleValue::string(""), + TypeDefPrimitive::U8 + | TypeDefPrimitive::U16 + | TypeDefPrimitive::U32 + | TypeDefPrimitive::U64 + | TypeDefPrimitive::U128 => ScaleValue::u128(0), + TypeDefPrimitive::I8 + | TypeDefPrimitive::I16 + | TypeDefPrimitive::I32 + | TypeDefPrimitive::I64 + | TypeDefPrimitive::I128 => ScaleValue::i128(0), + TypeDefPrimitive::U256 => ScaleValue::primitive(Primitive::U256([0; 32])), + TypeDefPrimitive::I256 => ScaleValue::primitive(Primitive::I256([0; 32])), + }, + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_chain/transaction.rs b/rust/crates/truapi-server/src/runtime/coinage_chain/transaction.rs new file mode 100644 index 000000000..5bd267a2a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_chain/transaction.rs @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-ffi/src/{coinage_sender,coinage_transfer}.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v5}; +use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; +use parity_scale_codec::{Compact, Decode, Encode}; +use subxt::ext::frame_decode::extrinsics::ExtrinsicTypeInfo; +use truapi::latest::TxPayloadExtension; +use truapi_coinage::DenominationBreakdownContext; + +pub(super) const AS_COINAGE: &str = "AsCoinage"; + +#[derive(Clone)] +pub(super) struct Snapshot { + pub storage: std::sync::Arc, + pub metadata: std::sync::Arc, + pub subxt: subxt::metadata::ArcMetadata, + pub state: ChainState, + pub at: [u8; 32], + pub number: u64, + pub period: u64, + asset_unit: Option, +} + +impl Snapshot { + pub fn new( + raw: Vec, + at: [u8; 32], + number: u64, + genesis_hash: [u8; 32], + spec_version: u32, + transaction_version: u32, + configured_instance: Option, + ) -> Result { + let metadata = Metadata::decode(&raw).map_err(|_| "invalid Coinage runtime metadata")?; + let subxt = subxt::Metadata::decode_from(&raw[..]) + .map_err(|_| "invalid transaction metadata")? + .arc(); + let hashes: u32 = constant(&metadata, "System", "BlockHashCount")?; + let limit = u64::from(hashes) + .min(truapi_coinage::WAL_MORTALITY_BLOCKS) + .min(256); + if limit < 4 { + return Err("insufficient block retention for mortal Coinage transactions".into()); + } + let period = 1u64 << (63 - limit.leading_zeros()); + let storage = StorageLayouts::new(subxt.clone(), configured_instance)?; + let asset_unit = if storage.instance_id.is_none() { + Some(constant(&metadata, "Coinage", "UnderlyingAssetUnit")?) + } else { + None + }; + let snapshot = Self { + storage: std::sync::Arc::new(storage), + metadata: std::sync::Arc::new(metadata), + subxt, + state: ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + restrict_origins: false, + }, + at, + number, + period, + asset_unit, + }; + if snapshot.asset_unit.is_some() { + snapshot.denomination_context()?; + } + Ok(snapshot) + } + + pub fn instance_asset_key(&self) -> Result>, String> { + self.storage + .instance_id + .map(|instance| { + self.storage + .typed_key("Coinage", "Instances", &[&instance.to_le_bytes()]) + }) + .transpose() + } + + /// Instance configuration is mutable storage, not runtime metadata. Re-read + /// it at every queried head, even when the runtime version is unchanged. + pub fn bind_instance_asset(&mut self, bytes: &[u8]) -> Result<(), String> { + #[derive(scale_decode::DecodeAsType)] + struct InstanceRecord { + asset_unit: u128, + } + let record: InstanceRecord = self.storage.decode_value("Coinage", "Instances", bytes)?; + self.asset_unit = Some(record.asset_unit); + self.denomination_context()?; + Ok(()) + } + + pub fn valid_until(&self) -> u64 { + self.number.saturating_add(self.period) + } + + pub fn denomination_context(&self) -> Result { + let context = DenominationBreakdownContext { + asset_unit: self + .asset_unit + .ok_or("Coinage asset configuration has not been read at this block")?, + max_exponent: i16::from(constant::( + &self.metadata, + "Coinage", + "MaximumExponent", + )?), + min_exponent: i16::from(constant::( + &self.metadata, + "Coinage", + "MinimumExponent", + )?), + precision: truapi_coinage::CASH_ASSET_PRECISION, + }; + if context.asset_unit == 0 || context.max_exponent < context.min_exponent { + return Err("invalid Coinage denomination constants".into()); + } + for exponent in context.min_exponent..=context.max_exponent { + denomination_value(&context, exponent)?; + } + Ok(context) + } + + pub fn extensions(&self, nonce: u32) -> Result, String> { + let mut state = self.state; + state.nonce = nonce; + let info = self + .subxt + .extrinsic_extension_info(Some(self.metadata.extension_version())) + .map_err(|_| "invalid extension pipeline")?; + let encoded = self.metadata.encode_signed_extensions(&state); + if encoded.len() != info.extension_ids.len() { + return Err("extension pipeline mismatch".into()); + } + let mut extensions: Vec<_> = info + .extension_ids + .iter() + .zip(encoded) + .map(|(definition, bytes)| TxPayloadExtension { + id: definition.name.to_string(), + extra: bytes.extra, + additional_signed: bytes.additional_signed, + }) + .collect(); + let era = mortal_era(self.period, self.number)?; + replace( + &mut extensions, + "CheckMortality", + era.to_vec(), + self.at.to_vec(), + )?; + if !extensions.iter().any(|e| e.id == AS_COINAGE) + || !extensions.iter().any(|e| e.id == "VerifyMultiSignature") + { + return Err("Coinage authorization extensions missing".into()); + } + // Unknown non-empty extensions must not silently authorize or charge anything. + for extension in &extensions { + let known = matches!( + extension.id.as_str(), + "CheckNonce" + | "CheckSpecVersion" + | "CheckTxVersion" + | "CheckGenesis" + | "CheckMortality" + | "VerifyMultiSignature" + | "ChargeAssetTxPayment" + | "ChargeTransactionPayment" + | "RestrictOrigins" + | "CheckMetadataHash" + | "AsCoinage" + ); + if !known + && extension + .extra + .iter() + .chain(&extension.additional_signed) + .any(|byte| *byte != 0) + { + return Err("Coinage runtime requires an unrecognized extension".into()); + } + } + Ok(extensions) + } + + pub fn as_coin(&self) -> Result, String> { + if self + .metadata + .extension_info_field_count(AS_COINAGE, "AsCoin") + .map_err(|_| "invalid AsCoinage.AsCoin shape")? + != 0 + { + return Err("invalid AsCoinage.AsCoin fields".into()); + } + Ok(vec![ + 1, + self.metadata + .extension_info_variant_index(AS_COINAGE, "AsCoin") + .map_err(|_| "missing AsCoinage.AsCoin")?, + ]) + } + + pub fn signed( + &self, + keypair: &schnorrkel::Keypair, + call: &[u8], + nonce: u32, + ) -> Result, String> { + self.validate_call(call)?; + let mut extensions = self.extensions(nonce)?; + replace(&mut extensions, AS_COINAGE, self.as_coin()?, Vec::new())?; + self.validate_extensions(&extensions)?; + extensions.retain(|extension| extension.id != "VerifyMultiSignature"); + build_signed_extrinsic_v5( + &Sr25519Signer::from_keypair(keypair), + self.state.genesis_hash, + call, + &extensions, + self.subxt.clone(), + ) + .map_err(|_| "Coinage transaction cannot be signed against runtime metadata".into()) + } + + pub fn implication( + &self, + call: &[u8], + extensions: &[TxPayloadExtension], + ) -> Result, String> { + self.validate_call(call)?; + self.validate_extensions(extensions)?; + let index = extensions + .iter() + .position(|extension| extension.id == AS_COINAGE) + .ok_or("missing Coinage extension")?; + let mut result = vec![self.metadata.extension_version()]; + result.extend_from_slice(call); + for extension in &extensions[index + 1..] { + result.extend_from_slice(&extension.extra); + } + for extension in &extensions[index + 1..] { + result.extend_from_slice(&extension.additional_signed); + } + Ok(result) + } + + pub fn unsigned( + &self, + call: &[u8], + mut extensions: Vec, + proof: &truapi_coinage::UnloadTokenProof, + ) -> Result, String> { + let mut extra = proof.as_coinage_extension().encode(); + let name = match proof.person_origin { + truapi_coinage::PersonOriginKind::Full => "AsUnloadTokenPeople", + truapi_coinage::PersonOriginKind::Lite => "AsUnloadTokenLitePeople", + }; + let variant = self + .metadata + .extension_info_variant_index(AS_COINAGE, name) + .map_err(|_| "missing Coinage unload origin")?; + if extra.len() < 2 { + return Err("invalid Coinage unload proof encoding".into()); + } + extra[1] = variant; + replace(&mut extensions, AS_COINAGE, extra, Vec::new())?; + self.validate_call(call)?; + self.validate_extensions(&extensions)?; + let mut body = vec![0x45, self.metadata.extension_version()]; + for extension in &extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call); + let mut output = + Compact(u32::try_from(body.len()).map_err(|_| "Coinage transaction too large")?) + .encode(); + output.extend_from_slice(&body); + Ok(output) + } + + fn validate_extensions(&self, extensions: &[TxPayloadExtension]) -> Result<(), String> { + use subxt::ext::scale_decode::visitor::{IgnoreVisitor, decode_with_visitor}; + let info = self + .subxt + .extrinsic_extension_info(Some(self.metadata.extension_version())) + .map_err(|_| "invalid extension pipeline")?; + if info.extension_ids.len() != extensions.len() { + return Err("extension pipeline mismatch".into()); + } + for (definition, extension) in info.extension_ids.iter().zip(extensions) { + if definition.name != extension.id { + return Err("extension order mismatch".into()); + } + let mut bytes = extension.extra.as_slice(); + decode_with_visitor( + &mut bytes, + definition.id, + self.subxt.types(), + IgnoreVisitor::new(), + ) + .map_err(|_| "invalid Coinage extension encoding")?; + if !bytes.is_empty() { + return Err("Coinage extension has trailing bytes".into()); + } + let mut implicit = extension.additional_signed.as_slice(); + decode_with_visitor( + &mut implicit, + definition.implicit_id, + self.subxt.types(), + IgnoreVisitor::new(), + ) + .map_err(|_| "invalid Coinage extension implicit")?; + if !implicit.is_empty() { + return Err("Coinage extension implicit has trailing bytes".into()); + } + } + Ok(()) + } + + fn validate_call(&self, call: &[u8]) -> Result<(), String> { + use subxt::ext::scale_decode::visitor::{IgnoreVisitor, decode_with_visitor}; + let [pallet, index, ..] = call else { + return Err("Coinage call is truncated".into()); + }; + let info = self + .subxt + .extrinsic_call_info_by_index(*pallet, *index) + .map_err(|_| "Coinage call metadata missing")?; + if info.pallet_name != "Coinage" { + return Err("Coinage adapter cannot submit another pallet".into()); + } + let mut bytes = &call[2..]; + for argument in &info.args { + decode_with_visitor( + &mut bytes, + argument.id, + self.subxt.types(), + IgnoreVisitor::new(), + ) + .map_err(|_| "Coinage call shape changed")?; + } + if !bytes.is_empty() { + return Err("Coinage call has trailing arguments".into()); + } + Ok(()) + } +} + +pub(super) fn replace( + extensions: &mut [TxPayloadExtension], + name: &str, + extra: Vec, + additional_signed: Vec, +) -> Result<(), String> { + let extension = extensions + .iter_mut() + .find(|extension| extension.id == name) + .ok_or("required Coinage extension missing")?; + extension.extra = extra; + extension.additional_signed = additional_signed; + Ok(()) +} + +pub(super) fn mortal_era(period: u64, height: u64) -> Result<[u8; 2], String> { + if !(4..=256).contains(&period) || !period.is_power_of_two() { + return Err("invalid Coinage mortality period".into()); + } + let phase = height % period; + Ok(((period.trailing_zeros() - 1) as u16 | ((phase as u16) << 4)).to_le_bytes()) +} + +pub(super) fn denomination_value( + context: &DenominationBreakdownContext, + exponent: i16, +) -> Result { + if exponent < context.min_exponent || exponent > context.max_exponent { + return Err("denomination outside runtime range".into()); + } + let value = if exponent >= 0 { + let factor = 1u128 + .checked_shl(exponent as u32) + .ok_or("denomination overflow")?; + context + .asset_unit + .checked_mul(factor) + .ok_or("denomination overflow")? + } else { + context + .asset_unit + .checked_shr(u32::from(exponent.unsigned_abs())) + .ok_or("invalid denomination shift")? + }; + if value == 0 { + return Err("zero denomination".into()); + } + Ok(value) +} + +pub(super) fn constant( + metadata: &Metadata, + pallet: &str, + name: &str, +) -> Result { + decode_exact( + metadata + .constant(pallet, name) + .ok_or("required Coinage runtime constant missing")?, + ) +} + +pub(super) fn decode_exact(bytes: &[u8]) -> Result { + let mut input = bytes; + let value = T::decode(&mut input).map_err(|_| "invalid chain SCALE value")?; + if !input.is_empty() { + return Err("chain SCALE value has trailing bytes".into()); + } + Ok(value) +} + +pub(super) fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} +pub(super) fn parse_hash(value: &str) -> Result<[u8; 32], String> { + hex::decode( + value + .strip_prefix("0x") + .ok_or("block hash lacks hex prefix")?, + ) + .map_err(|_| "invalid block hash hex")? + .try_into() + .map_err(|_| "invalid block hash width".into()) +} + +/// Resolve storage hashing from the same finalized metadata used for the proof. +pub(super) struct StorageLayouts { + metadata: subxt::metadata::ArcMetadata, + pub instance_id: Option, +} + +impl StorageLayouts { + fn new( + metadata: subxt::metadata::ArcMetadata, + configured_instance: Option, + ) -> Result { + let instances = metadata + .pallet_by_name("Coinage") + .and_then(|pallet| pallet.storage()) + .and_then(|storage| storage.entry_by_name("Instances")) + .is_some(); + let instance_id = if instances { + Some( + configured_instance + .ok_or("Host must configure the Coinage asset instance for this runtime")?, + ) + } else { + None + }; + let layouts = Self { + metadata, + instance_id, + }; + layouts.validate_coinage_keys()?; + Ok(layouts) + } + + fn entry( + &self, + pallet: &str, + entry: &str, + ) -> Result<(&str, &subxt::metadata::StorageEntryMetadata), String> { + let storage = self + .metadata + .pallet_by_name(pallet) + .and_then(|pallet| pallet.storage()) + .ok_or_else(|| format!("missing storage metadata for {pallet}"))?; + let definition = storage + .entry_by_name(entry) + .ok_or_else(|| format!("missing storage metadata for {pallet}.{entry}"))?; + Ok((storage.prefix(), definition)) + } + + fn validate_coinage_keys(&self) -> Result<(), String> { + use truapi_coinage::CoinageStorageKey as Key; + let owner = [7; 32]; + for key in [ + Key::Coin(owner), + Key::Recycler(owner), + Key::RecyclerAlias { + exponent: 0, + ring_index: 1, + alias: owner, + }, + Key::Member { + exponent: 0, + member: owner, + }, + Key::Root { + exponent: 0, + ring_index: 1, + }, + Key::RingKeysStatus { + exponent: 0, + ring_index: 1, + }, + ] { + self.coinage_key(&key)?; + } + Ok(()) + } + + pub(super) fn collection(&self, exponent: i16) -> [u8; 32] { + truapi_coinage::pallet::recycler_collection_identifier(self.instance_id, exponent) + } + + /// Encode semantic queries against metadata from the queried block, including + /// historical recovery probes. Never infer a spent coin from an obsolete key. + pub(super) fn coinage_key( + &self, + key: &truapi_coinage::CoinageStorageKey, + ) -> Result, String> { + use truapi_coinage::CoinageStorageKey as Key; + match key { + Key::Coin(owner) => self.typed_key("Coinage", "CoinsByOwner", &[owner]), + Key::Recycler(member) => { + self.typed_key("Coinage", "RecyclersCoinToRecycler", &[member]) + } + Key::RecyclerAlias { + exponent, + ring_index, + alias, + } => match self.instance_id { + Some(instance) => self.typed_key( + "Coinage", + "RecyclerAliasStates", + &[ + &instance.to_le_bytes(), + &exponent.to_le_bytes(), + &ring_index.to_le_bytes(), + alias, + ], + ), + None => self.typed_key( + "Coinage", + "RecyclerAliasStates", + &[&exponent.to_le_bytes(), &ring_index.to_le_bytes(), alias], + ), + }, + Key::Member { exponent, member } => { + self.typed_key("Members", "Members", &[&self.collection(*exponent), member]) + } + Key::Root { + exponent, + ring_index, + } => self.typed_key( + "Members", + "Root", + &[&self.collection(*exponent), &ring_index.to_le_bytes()], + ), + Key::RingKeysStatus { + exponent, + ring_index, + } => self.typed_key( + "Members", + "RingKeysStatus", + &[&self.collection(*exponent), &ring_index.to_le_bytes()], + ), + } + } + + fn typed_key( + &self, + pallet: &str, + entry: &str, + components: &[&[u8]], + ) -> Result, String> { + let (_, definition) = self.entry(pallet, entry)?; + if components.len() != definition.keys().len() { + return Err(format!("storage key arity changed for {pallet}.{entry}")); + } + self.encode(pallet, entry, components.iter().copied()) + } + + fn encode<'a>( + &self, + pallet: &str, + entry: &str, + components: impl ExactSizeIterator, + ) -> Result, String> { + use subxt::ext::frame_decode::storage::StorageHasher; + use subxt::ext::scale_decode::visitor::{IgnoreVisitor, decode_with_visitor}; + let (prefix, definition) = self.entry(pallet, entry)?; + if components.len() > definition.keys().len() { + return Err("too many storage key components".into()); + } + let mut output = sp_crypto_hashing::twox_128(prefix.as_bytes()).to_vec(); + output.extend_from_slice(&sp_crypto_hashing::twox_128(entry.as_bytes())); + for (info, key) in definition.keys().zip(components) { + let mut remaining = key; + decode_with_visitor( + &mut remaining, + info.key_id, + self.metadata.types(), + IgnoreVisitor::new(), + ) + .map_err(|_| format!("storage key type changed for {pallet}.{entry}"))?; + if !remaining.is_empty() { + return Err(format!( + "storage key has trailing bytes for {pallet}.{entry}" + )); + } + match info.hasher { + StorageHasher::Identity => output.extend_from_slice(key), + StorageHasher::Blake2_128Concat => { + output.extend_from_slice(&sp_crypto_hashing::blake2_128(key)); + output.extend_from_slice(key); + } + StorageHasher::Twox64Concat => { + output.extend_from_slice(&sp_crypto_hashing::twox_64(key)); + output.extend_from_slice(key); + } + StorageHasher::Blake2_128 => { + output.extend_from_slice(&sp_crypto_hashing::blake2_128(key)) + } + StorageHasher::Blake2_256 => { + output.extend_from_slice(&sp_crypto_hashing::blake2_256(key)) + } + StorageHasher::Twox128 => { + output.extend_from_slice(&sp_crypto_hashing::twox_128(key)) + } + StorageHasher::Twox256 => { + output.extend_from_slice(&sp_crypto_hashing::twox_256(key)) + } + } + } + Ok(output) + } + + fn decode_value( + &self, + pallet: &str, + entry: &str, + bytes: &[u8], + ) -> Result { + let (_, definition) = self.entry(pallet, entry)?; + let mut remaining = bytes; + let decoded = + T::decode_as_type(&mut remaining, definition.value_ty(), self.metadata.types()) + .map_err(|_| format!("invalid storage value for {pallet}.{entry}"))?; + if !remaining.is_empty() { + return Err(format!( + "storage value has trailing bytes for {pallet}.{entry}" + )); + } + Ok(decoded) + } + + /// Only the selected asset can enter the main-purse engine. The canonical + /// engine row deliberately contains no guest-selectable asset or purse. + pub(super) fn normalize_value( + &self, + key: &truapi_coinage::CoinageStorageKey, + bytes: &mut Vec, + ) -> Result<(), String> { + use truapi_coinage::CoinageStorageKey as Key; + match key { + Key::Coin(_) => { + #[derive(scale_decode::DecodeAsType)] + struct LegacyCoin { + value: i8, + age: u16, + } + #[derive(scale_decode::DecodeAsType)] + struct InstanceCoin { + instance_id: u32, + value: i8, + age: u16, + } + let coin = match self.instance_id { + Some(expected) => { + let coin: InstanceCoin = + self.decode_value("Coinage", "CoinsByOwner", bytes)?; + if coin.instance_id != expected { + return Err("coin belongs to another Coinage asset instance".into()); + } + LegacyCoin { + value: coin.value, + age: coin.age, + } + } + None => self.decode_value("Coinage", "CoinsByOwner", bytes)?, + }; + bytes.clear(); + (coin.value, coin.age).encode_to(bytes); + } + Key::Recycler(_) => { + let exponent = match self.instance_id { + Some(expected) => { + let (instance, exponent): (u32, i8) = + self.decode_value("Coinage", "RecyclersCoinToRecycler", bytes)?; + if instance != expected { + return Err("voucher belongs to another Coinage asset instance".into()); + } + exponent + } + None => self.decode_value::("Coinage", "RecyclersCoinToRecycler", bytes)?, + }; + bytes.clear(); + exponent.encode_to(bytes); + } + _ => { + use subxt::ext::scale_decode::visitor::{IgnoreVisitor, decode_with_visitor}; + let (pallet, entry) = match key { + Key::RecyclerAlias { .. } => ("Coinage", "RecyclerAliasStates"), + Key::Member { .. } => ("Members", "Members"), + Key::Root { .. } => ("Members", "Root"), + Key::RingKeysStatus { .. } => ("Members", "RingKeysStatus"), + _ => unreachable!(), + }; + let (_, definition) = self.entry(pallet, entry)?; + let mut remaining = bytes.as_slice(); + decode_with_visitor( + &mut remaining, + definition.value_ty(), + self.metadata.types(), + IgnoreVisitor::new(), + ) + .map_err(|_| format!("invalid storage value for {pallet}.{entry}"))?; + if !remaining.is_empty() { + return Err("Coinage storage value has trailing bytes".into()); + } + } + } + Ok(()) + } +} + +pub(super) fn storage_key( + layouts: &StorageLayouts, + pallet_name: &str, + entry_name: &str, + keys: &[Vec], +) -> Result, String> { + layouts.encode(pallet_name, entry_name, keys.iter().map(Vec::as_slice)) +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_store.rs b/rust/crates/truapi-server/src/runtime/coinage_store.rs new file mode 100644 index 000000000..7f62c74c5 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_store.rs @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-owned Coinage persistence implementing the Brevity Coinage contracts. +//! +//! The repository model and transaction semantics originate in +//! `brevity-dozer/core/crates/brevity-coinage` (AGPL-3.0); this adapter retains +//! those terms. No purse state belongs to a product's storage namespace. +//! +//! One live owner per backing platform and wallet/network is enforced in process. +//! CoreStorage must provide atomic, durable replacement of the whole value. +//! Mutations run as owned tasks so an RPC disconnect cannot cancel a durable +//! write before in-memory publication. The supplied executor must outlive these +//! tasks. Cross-process writers still require an external ownership protocol. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{ + Arc, LazyLock, Weak, + atomic::{AtomicBool, Ordering}, +}; + +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{AeadInPlace, KeyInit}, +}; +use futures::lock::Mutex; +use truapi_coinage::{ + claim_plan::ClaimPlan, + model::{Coin, Voucher}, + wal::TransferWalEntry, +}; +use truapi_platform::{CoreStorage, CoreStorageKey, Platform}; +use zeroize::Zeroizing; + +use crate::subscription::Spawner; + +mod codec; +mod repositories; +#[cfg(test)] +mod tests; + +const MAGIC: &[u8; 4] = b"HCPS"; +// Version 3 binds all persisted indices/WAL records to the current iOS +// MAIN_PURSE/page-0 derivations. Version 2 used //pps; never reinterpret its +// reservations or counters under another key tree, even for the same wallet. +const VERSION: u16 = 3; +const HEADER_BYTES: usize = 4 + 2 + 24; +const TAG_BYTES: usize = 16; +const MAX_SNAPSHOT_BYTES: usize = 32 * 1024 * 1024; +const MAX_ASSETS: usize = 65_536; +const MAX_RECORDS: usize = 4_096; +const MAX_ITEMS: usize = 4_096; +const MAX_ID_BYTES: usize = 1_024; +const MAX_OPERATION_BYTES: usize = 1024 * 1024; + +/// Payload-free persistence errors; never contain plaintext or upstream logs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub enum StoreError { + /// The Host could not read its durable slot. + #[display("main purse storage read failed")] + Read, + /// The Host could not atomically replace its durable slot. + #[display("main purse storage write failed")] + Write, + /// The snapshot belongs to a newer or unrecognized format. + #[display("unsupported main purse snapshot version")] + Version, + /// The envelope or snapshot is malformed or internally inconsistent. + #[display("invalid main purse snapshot")] + Corrupt, + /// Authentication failed, including a wrong wallet, network, or key. + #[display("main purse snapshot authentication failed")] + Authentication, + /// OS cryptographic randomness was unavailable. + #[display("main purse nonce generation failed")] + Randomness, + /// An input or snapshot exceeds the bounded persistence format. + #[display("main purse storage bound exceeded")] + Capacity, + /// An expected asset, journal, or plan does not exist. + #[display("main purse record does not exist")] + Missing, + /// A live slot owner, reservation, immutable identity, or index conflicts. + #[display("main purse state conflict")] + Conflict, + /// A monotonic derivation counter cannot be advanced further. + #[display("main purse derivation index exhausted")] + Exhausted, + /// The Host executor terminated a durable operation before completion. + #[display("main purse persistence task stopped")] + ExecutorStopped, +} + +#[derive(Clone, Default)] +struct Snapshot { + // Outer None is unbound; Some(None) is the legacy single-asset runtime. + asset_instance: Option>, + coin_index: Option, + voucher_index: Option, + coins: BTreeMap, + vouchers: BTreeMap, + wal: BTreeMap, + plans: BTreeMap<[u8; 32], ClaimPlan>, + operations: BTreeMap<[u8; 32], Zeroizing>>, +} + +type SlotKey = (usize, [u8; 32], [u8; 32]); + +struct Slot { + storage: Weak, + owner: Weak, + // Nonsecret uncertainty survives the last store and every owned operation. + poisoned: Arc, +} + +static SLOTS: LazyLock>> = + LazyLock::new(|| parking_lot::Mutex::new(HashMap::new())); + +struct SlotOwner { + key: SlotKey, + poisoned: Arc, +} + +impl SlotOwner { + fn acquire( + storage: &Arc, + root: [u8; 32], + genesis: [u8; 32], + ) -> Result, StoreError> { + let key = (Arc::as_ptr(storage) as *const () as usize, root, genesis); + let mut slots = SLOTS.lock(); + let slot = slots.entry(key).or_insert_with(|| Slot { + storage: Arc::downgrade(storage), + owner: Weak::new(), + poisoned: Arc::new(AtomicBool::new(false)), + }); + if slot.storage.strong_count() == 0 { + slot.storage = Arc::downgrade(storage); + slot.poisoned = Arc::new(AtomicBool::new(false)); + } + if slot.owner.strong_count() != 0 { + return Err(StoreError::Conflict); + } + let owner = Arc::new(Self { + key, + poisoned: slot.poisoned.clone(), + }); + slot.owner = Arc::downgrade(&owner); + Ok(owner) + } +} + +impl Drop for SlotOwner { + fn drop(&mut self) { + let mut slots = SLOTS.lock(); + if slots.get(&self.key).is_some_and(|slot| { + std::ptr::eq(slot.owner.as_ptr(), self) && !self.poisoned.load(Ordering::Acquire) + }) { + slots.remove(&self.key); + } + } +} + +/// Singleton wallet/network repositories sharing one durable transaction gate. +/// +/// The key and operation buffers are zeroized on drop. Coinage model records +/// contain derivation indices and public evidence, not coin/voucher secret keys. +/// Do not expose this type or its auxiliary storage through product APIs. +#[derive(Clone)] +pub struct HostCoinageStore { + storage: Arc, + storage_key: CoreStorageKey, + encryption_key: Arc>, + associated_data: Arc>, + state: Arc>, + owner: Arc, + spawner: Spawner, +} + +impl HostCoinageStore { + /// Claim and authenticate this wallet/network. A live owner conflicts; an + /// absent slot is empty only if no prior write left its durability uncertain. + pub async fn open( + platform: Arc, + root_public_key: [u8; 32], + genesis_hash: [u8; 32], + encryption_key: Zeroizing<[u8; 32]>, + spawner: Spawner, + ) -> Result, StoreError> { + Self::open_storage( + platform, + root_public_key, + genesis_hash, + encryption_key, + spawner, + ) + .await + } + + async fn open_storage( + storage: Arc, + root_public_key: [u8; 32], + genesis_hash: [u8; 32], + encryption_key: Zeroizing<[u8; 32]>, + spawner: Spawner, + ) -> Result, StoreError> { + let storage_key = CoreStorageKey::MainPurseCoinage { + root_public_key, + genesis_hash, + }; + // Reserve before the first await. Clones in owned tasks retain this + // token even after their caller, registry, or session has been dropped. + let owner = SlotOwner::acquire(&storage, root_public_key, genesis_hash)?; + let mut associated_data = b"truapi/host/main-purse-coinage\0".to_vec(); + associated_data.extend_from_slice(MAGIC); + associated_data.extend_from_slice(&VERSION.to_le_bytes()); + associated_data.extend_from_slice(&root_public_key); + associated_data.extend_from_slice(&genesis_hash); + let stored = storage + .read_core_storage(storage_key.clone()) + .await + .map_err(|_| StoreError::Read)?; + let store = Self { + storage, + storage_key, + encryption_key: Arc::new(encryption_key), + associated_data: Arc::new(associated_data), + state: Arc::new(Mutex::new(Snapshot::default())), + owner, + spawner, + }; + match stored { + Some(stored) => *store.state.lock().await = store.decrypt(&stored)?, + None if store.owner.poisoned.load(Ordering::Acquire) => { + return Err(StoreError::Missing); + } + None => {} + } + // An authenticated read alone does not repair a failed durable write. + if store.owner.poisoned.load(Ordering::Acquire) { + store.reauthenticate().await?; + } + Ok(Arc::new(store)) + } + + fn decrypt(&self, stored: &[u8]) -> Result { + if stored.len() < HEADER_BYTES + TAG_BYTES || &stored[..4] != MAGIC { + return Err(StoreError::Corrupt); + } + if u16::from_le_bytes([stored[4], stored[5]]) != VERSION { + return Err(StoreError::Version); + } + if stored.len() > HEADER_BYTES + MAX_SNAPSHOT_BYTES + TAG_BYTES { + return Err(StoreError::Capacity); + } + let mut plaintext = Zeroizing::new(stored[HEADER_BYTES..].to_vec()); + XChaCha20Poly1305::new((&**self.encryption_key).into()) + .decrypt_in_place( + XNonce::from_slice(&stored[6..HEADER_BYTES]), + &self.associated_data, + &mut *plaintext, + ) + .map_err(|_| StoreError::Authentication)?; + codec::decode(&plaintext) + } + + fn encrypt(&self, state: &Snapshot) -> Result, StoreError> { + let mut plaintext = codec::encode(state, TAG_BYTES)?; + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut nonce).map_err(|_| StoreError::Randomness)?; + XChaCha20Poly1305::new((&**self.encryption_key).into()) + .encrypt_in_place( + XNonce::from_slice(&nonce), + &self.associated_data, + &mut *plaintext, + ) + .map_err(|_| StoreError::Capacity)?; + let mut envelope = Vec::with_capacity(HEADER_BYTES + plaintext.len()); + envelope.extend_from_slice(MAGIC); + envelope.extend_from_slice(&VERSION.to_le_bytes()); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&plaintext); + Ok(envelope) + } + + async fn mutate(&self, change: F) -> Result + where + T: Send + 'static, + F: FnOnce(&mut Snapshot) -> Result + Send + 'static, + { + let store = self.clone(); + let (result_tx, result_rx) = futures::channel::oneshot::channel(); + (self.spawner)(Box::pin(async move { + let result = async { + let mut published = store.checked_state().await?; + let mut candidate = published.clone(); + let result = change(&mut candidate)?; + codec::validate(&candidate)?; + let encrypted = store.encrypt(&candidate)?; + // A replacement can become visible before fsync reports an + // error. Never overwrite it from our stale published state. + store.owner.poisoned.store(true, Ordering::Release); + if store + .storage + .write_core_storage(store.storage_key.clone(), encrypted) + .await + .is_err() + { + // Keep the slot poisoned even if this was its last owner. + return Err(StoreError::Write); + } + // No await between successful durable replacement and publication. + *published = candidate; + store.owner.poisoned.store(false, Ordering::Release); + Ok(result) + } + .await; + // A canceled caller does not cancel the owned persistence task. + drop(store); + let _ = result_tx.send(result); + })); + result_rx.await.map_err(|_| StoreError::ExecutorStopped)? + } + + /// Persist the trusted asset selection before inventory, claims or approvals. + /// Changing configuration cannot reinterpret this wallet's durable operations. + pub async fn bind_asset_instance(&self, instance: Option) -> Result<(), StoreError> { + if let Some(bound) = self.checked_state().await?.asset_instance { + return if bound == instance { + Ok(()) + } else { + Err(StoreError::Conflict) + }; + } + self.mutate(move |state| { + match state.asset_instance { + Some(bound) if bound != instance => return Err(StoreError::Conflict), + Some(_) => {} + None => state.asset_instance = Some(instance), + } + Ok(()) + }) + .await + } + + async fn checked_state(&self) -> Result, StoreError> { + let state = self.state.lock().await; + if self.owner.poisoned.load(Ordering::Acquire) { + return Err(StoreError::Write); + } + Ok(state) + } + + /// Recover an ambiguous replacement only by authenticating the actual + /// durable slot under the same transaction gate. + pub async fn reauthenticate(&self) -> Result<(), StoreError> { + let store = self.clone(); + let (tx, rx) = futures::channel::oneshot::channel(); + (self.spawner)(Box::pin(async move { + let result = async { + let mut published = store.state.lock().await; + if !store.owner.poisoned.load(Ordering::Acquire) { + return Ok(()); + } + let observed = store + .storage + .read_core_storage(store.storage_key.clone()) + .await + .map_err(|_| StoreError::Read)?; + // Absence after an ambiguous write is never proof that its + // derivation reservations or accepted custody did not happen. + let bytes = observed.ok_or(StoreError::Missing)?; + let durable = store.decrypt(&bytes)?; + // Visibility after rename does not establish durability after + // a failed directory fsync. Complete a successful replacement + // before publishing and permitting another spend. + store + .storage + .write_core_storage(store.storage_key.clone(), bytes) + .await + .map_err(|_| StoreError::Write)?; + *published = durable; + store.owner.poisoned.store(false, Ordering::Release); + Ok(()) + } + .await; + drop(store); + let _ = tx.send(result); + })); + rx.await.map_err(|_| StoreError::ExecutorStopped)? + } + + /// Read Host-private approval/handoff bytes. The caller must zeroize its copy. + pub async fn read_operation(&self, id: [u8; 32]) -> Result>, StoreError> { + Ok(self + .checked_state() + .await? + .operations + .get(&id) + .map(|bytes| bytes.to_vec())) + } + + /// Atomically persist an operation alongside all repositories, never product storage. + pub fn write_operation( + &self, + id: [u8; 32], + bytes: Vec, + ) -> impl core::future::Future> + Send + '_ { + // Wrap before constructing the future, including when it is never polled. + let bytes = Zeroizing::new(bytes); + async move { + if bytes.len() > MAX_OPERATION_BYTES { + return Err(StoreError::Capacity); + } + self.mutate(move |state| { + state.operations.insert(id, bytes); + Ok(()) + }) + .await + } + } + + /// Enumerate the encrypted snapshot's actual operation map, without a + /// separately written catalog that could lose an accepted operation. + pub async fn list_operations(&self) -> Result>)>, StoreError> { + Ok(self + .checked_state() + .await? + .operations + .iter() + .map(|(id, bytes)| (*id, bytes.clone())) + .collect()) + } + + /// Commit one native recovery scan batch, its horizon, and monotonic + /// derivation counters together. Existing reservations always win. + pub async fn commit_inventory_batch( + &self, + progress_id: [u8; 32], + expected: Option>, + progress: Vec, + coins: Vec, + vouchers: Vec, + coin_index: Option, + voucher_index: Option, + ) -> Result<(), StoreError> { + let expected = expected.map(Zeroizing::new); + let progress = Zeroizing::new(progress); + self.mutate(move |state| { + if state.operations.get(&progress_id).map(|v| v.as_slice()) + != expected.as_ref().map(|v| v.as_slice()) + { + return Err(StoreError::Conflict); + } + for coin in coins { + state.coins.entry(coin.derivation_index).or_insert(coin); + } + for voucher in vouchers { + state + .vouchers + .entry(voucher.derivation_index) + .or_insert(voucher); + } + if let Some(index) = coin_index { + state.coin_index = Some(state.coin_index.map_or(index, |old| old.max(index))); + } + if let Some(index) = voucher_index { + state.voucher_index = Some(state.voucher_index.map_or(index, |old| old.max(index))); + } + state.operations.insert(progress_id, progress); + Ok(()) + }) + .await + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_store/codec.rs b/rust/crates/truapi-server/src/runtime/coinage_store/codec.rs new file mode 100644 index 000000000..65aedc19f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_store/codec.rs @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Bounded snapshot format for the Brevity Coinage repository contracts. + +use std::collections::BTreeSet; + +use parity_scale_codec::{Decode, Encode, Output}; +use truapi_coinage::{ + claim_plan::{ClaimPlan, ClaimPlanStatus, CodableClaimPlanEntry}, + model::{Coin, CoinState, Voucher, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}, + wal::{CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload}, +}; +use zeroize::Zeroizing; + +use super::{ + MAX_ASSETS, MAX_ID_BYTES, MAX_ITEMS, MAX_OPERATION_BYTES, MAX_RECORDS, MAX_SNAPSHOT_BYTES, + Snapshot, StoreError, +}; + +fn bound(len: usize, max: usize) -> Result<(), StoreError> { + if len > max { + Err(StoreError::Capacity) + } else { + Ok(()) + } +} + +pub(super) fn validate_id(id: &str) -> Result<(), StoreError> { + bound(id.len(), MAX_ID_BYTES)?; + if id.is_empty() { + Err(StoreError::Corrupt) + } else { + Ok(()) + } +} + +pub(super) fn advance(counter: &mut Option, index: u32) { + *counter = Some(counter.map_or(index, |old| old.max(index))); +} + +fn within(counter: Option, index: u32) -> Result<(), StoreError> { + if counter.is_some_and(|high| index <= high) { + Ok(()) + } else { + Err(StoreError::Corrupt) + } +} + +fn references(refs: &[WalCoinRef], counter: Option) -> Result<(), StoreError> { + bound(refs.len(), MAX_ITEMS)?; + let mut seen = BTreeSet::new(); + for item in refs { + within(counter, item.derivation_index)?; + if !seen.insert(item.derivation_index) { + return Err(StoreError::Corrupt); + } + } + Ok(()) +} + +pub(super) fn validate(state: &Snapshot) -> Result<(), StoreError> { + bound(state.coins.len(), MAX_ASSETS)?; + bound(state.vouchers.len(), MAX_ASSETS)?; + bound(state.wal.len(), MAX_RECORDS)?; + bound(state.plans.len(), MAX_RECORDS)?; + bound(state.operations.len(), MAX_RECORDS)?; + for (index, coin) in &state.coins { + within(state.coin_index, *index)?; + if *index != coin.derivation_index || coin.age.is_some_and(|age| age < 0) { + return Err(StoreError::Corrupt); + } + } + for (index, voucher) in &state.vouchers { + within(state.voucher_index, *index)?; + if *index != voucher.derivation_index || voucher.ready_at_ms < voucher.allocated_at_ms { + return Err(StoreError::Corrupt); + } + } + for (id, entry) in &state.wal { + validate_id(id)?; + if id != &entry.entry_id { + return Err(StoreError::Corrupt); + } + let payload = &entry.payload; + references(&payload.input_coins, state.coin_index)?; + references(&payload.input_vouchers, state.voucher_index)?; + references(&payload.output_coins, state.coin_index)?; + references(&payload.output_vouchers, state.voucher_index)?; + references(&payload.destination_coins, state.coin_index)?; + let outputs: std::collections::BTreeMap<_, _> = payload + .output_coins + .iter() + .map(|coin| (coin.derivation_index, coin.exponent)) + .collect(); + if payload + .destination_coins + .iter() + .any(|coin| outputs.get(&coin.derivation_index) != Some(&coin.exponent)) + { + return Err(StoreError::Corrupt); + } + let inputs: BTreeSet<_> = payload + .input_coins + .iter() + .map(|coin| coin.derivation_index) + .collect(); + if payload + .output_coins + .iter() + .any(|coin| inputs.contains(&coin.derivation_index)) + { + return Err(StoreError::Corrupt); + } + let inputs: BTreeSet<_> = payload + .input_vouchers + .iter() + .map(|coin| coin.derivation_index) + .collect(); + if payload + .output_vouchers + .iter() + .any(|coin| inputs.contains(&coin.derivation_index)) + { + return Err(StoreError::Corrupt); + } + for coin in payload.input_coins.iter().chain(&payload.output_coins) { + if state + .coins + .get(&coin.derivation_index) + .is_some_and(|row| row.exponent != coin.exponent) + { + return Err(StoreError::Corrupt); + } + } + for voucher in payload + .input_vouchers + .iter() + .chain(&payload.output_vouchers) + { + if state + .vouchers + .get(&voucher.derivation_index) + .is_some_and(|row| row.exponent != voucher.exponent) + { + return Err(StoreError::Corrupt); + } + } + } + for (key, plan) in &state.plans { + if key != &plan.memo_key { + return Err(StoreError::Corrupt); + } + if let Some(id) = &plan.message_id { + validate_id(id)?; + } + bound(plan.entries.len(), MAX_ITEMS)?; + bound(plan.outgoing_public_keys.len(), MAX_ITEMS)?; + let mut entry_ids = BTreeSet::new(); + let mut indices = BTreeSet::new(); + for entry in &plan.entries { + within(state.coin_index, entry.derivation_index)?; + if entry.entry_index < 0 + || !entry_ids.insert(entry.entry_index) + || !indices.insert(entry.derivation_index) + { + return Err(StoreError::Corrupt); + } + if state + .coins + .get(&entry.derivation_index) + .is_some_and(|row| row.exponent != entry.exponent) + { + return Err(StoreError::Corrupt); + } + } + let keys: BTreeSet<_> = plan.outgoing_public_keys.iter().collect(); + if keys.len() != plan.outgoing_public_keys.len() { + return Err(StoreError::Corrupt); + } + } + for bytes in state.operations.values() { + bound(bytes.len(), MAX_OPERATION_BYTES)?; + } + Ok(()) +} + +fn count(out: &mut impl Output, len: usize) { + (len as u32).encode_to(out); +} +fn bytes(out: &mut impl Output, value: &[u8]) { + count(out, value.len()); + out.write(value); +} +fn refs(out: &mut impl Output, values: &[WalCoinRef]) { + count(out, values.len()); + for item in values { + item.derivation_index.encode_to(out); + item.exponent.encode_to(out); + } +} + +fn encode_to(state: &Snapshot, out: &mut impl Output) { + state.asset_instance.encode_to(out); + state.coin_index.encode_to(out); + state.voucher_index.encode_to(out); + count(out, state.coins.len()); + for coin in state.coins.values() { + coin.derivation_index.encode_to(out); + coin.exponent.encode_to(out); + coin.age.encode_to(out); + coin.state.as_raw().encode_to(out); + } + count(out, state.vouchers.len()); + for voucher in state.vouchers.values() { + voucher.derivation_index.encode_to(out); + voucher.exponent.encode_to(out); + voucher.allocated_at_ms.encode_to(out); + voucher.ready_at_ms.encode_to(out); + let (remote, recycler) = match voucher.remote_state { + VoucherRemoteState::Unlocated => (0u8, None), + VoucherRemoteState::Onboarding => (1, None), + VoucherRemoteState::InRecycler { recycler_index } => (2, Some(recycler_index)), + VoucherRemoteState::Unloaded => (3, None), + }; + remote.encode_to(out); + recycler.encode_to(out); + voucher.local_state.as_raw().encode_to(out); + match voucher.privacy { + VoucherPrivacyLevel::Degraded => 0u8, + VoucherPrivacyLevel::Full => 1, + } + .encode_to(out); + } + count(out, state.wal.len()); + for entry in state.wal.values() { + bytes(out, entry.entry_id.as_bytes()); + entry.operation.as_raw().encode_to(out); + refs(out, &entry.payload.input_coins); + refs(out, &entry.payload.input_vouchers); + refs(out, &entry.payload.output_coins); + refs(out, &entry.payload.output_vouchers); + refs(out, &entry.payload.destination_coins); + let checkpoint = match entry.checkpoint { + CheckpointBlock::Pending => None, + CheckpointBlock::Known { number, hash } => Some((number, hash)), + }; + checkpoint.encode_to(out); + entry.created_at_ms.encode_to(out); + } + count(out, state.plans.len()); + for plan in state.plans.values() { + plan.memo_key.encode_to(out); + plan.message_id.is_some().encode_to(out); + if let Some(id) = &plan.message_id { + bytes(out, id.as_bytes()); + } + count(out, plan.entries.len()); + for entry in &plan.entries { + entry.entry_index.encode_to(out); + entry.exponent.encode_to(out); + entry.derivation_index.encode_to(out); + } + count(out, plan.outgoing_public_keys.len()); + for key in &plan.outgoing_public_keys { + key.encode_to(out); + } + plan.detection_anchor.encode_to(out); + plan.status.as_raw().encode_to(out); + plan.claimed_amount.encode_to(out); + plan.total_value.encode_to(out); + } + count(out, state.operations.len()); + for (id, value) in &state.operations { + id.encode_to(out); + bytes(out, value); + } +} + +struct Size(usize); +impl Output for Size { + fn write(&mut self, bytes: &[u8]) { + self.0 = self.0.saturating_add(bytes.len()); + } +} + +pub(super) fn encode(state: &Snapshot, spare: usize) -> Result>, StoreError> { + let mut size = Size(0); + encode_to(state, &mut size); + bound(size.0, MAX_SNAPSHOT_BYTES)?; + // Size first: growing a secret-bearing Vec could free uncleared allocations. + let mut output = Zeroizing::new(Vec::with_capacity(size.0 + spare)); + encode_to(state, &mut *output); + Ok(output) +} + +struct Reader<'a>(&'a [u8]); +impl Reader<'_> { + // Only fixed-size primitives and options/tuples thereof use generic Decode. + fn value(&mut self) -> Result { + T::decode(&mut self.0).map_err(|_| StoreError::Corrupt) + } + fn count(&mut self, max: usize) -> Result { + let value = self.value::()? as usize; + bound(value, max)?; + Ok(value) + } + fn bytes(&mut self, max: usize) -> Result<&[u8], StoreError> { + let len = self.count(max)?; + if len > self.0.len() { + return Err(StoreError::Corrupt); + } + let (value, rest) = self.0.split_at(len); + self.0 = rest; + Ok(value) + } + fn id(&mut self) -> Result { + let id = + core::str::from_utf8(self.bytes(MAX_ID_BYTES)?).map_err(|_| StoreError::Corrupt)?; + validate_id(id)?; + Ok(id.to_owned()) + } + fn refs(&mut self) -> Result, StoreError> { + let count = self.count(MAX_ITEMS)?; + let mut values = Vec::with_capacity(count); + for _ in 0..count { + values.push(WalCoinRef { + derivation_index: self.value()?, + exponent: self.value()?, + }); + } + Ok(values) + } +} + +pub(super) fn decode(plaintext: &[u8]) -> Result { + bound(plaintext.len(), MAX_SNAPSHOT_BYTES)?; + let mut input = Reader(plaintext); + let mut state = Snapshot { + asset_instance: input.value()?, + coin_index: input.value()?, + voucher_index: input.value()?, + ..Snapshot::default() + }; + for _ in 0..input.count(MAX_ASSETS)? { + let coin = Coin { + derivation_index: input.value()?, + exponent: input.value()?, + age: input.value()?, + state: CoinState::from_raw(input.value()?).ok_or(StoreError::Corrupt)?, + }; + if state.coins.insert(coin.derivation_index, coin).is_some() { + return Err(StoreError::Corrupt); + } + } + for _ in 0..input.count(MAX_ASSETS)? { + let derivation_index = input.value()?; + let exponent = input.value()?; + let allocated_at_ms = input.value()?; + let ready_at_ms = input.value()?; + let remote = input.value::()?; + let recycler = input.value::>()?; + let remote_state = match (remote, recycler) { + (0, None) => VoucherRemoteState::Unlocated, + (1, None) => VoucherRemoteState::Onboarding, + (2, Some(recycler_index)) => VoucherRemoteState::InRecycler { recycler_index }, + (3, None) => VoucherRemoteState::Unloaded, + _ => return Err(StoreError::Corrupt), + }; + let local_state = VoucherLocalState::from_raw(input.value()?).ok_or(StoreError::Corrupt)?; + let privacy = match input.value::()? { + 0 => VoucherPrivacyLevel::Degraded, + 1 => VoucherPrivacyLevel::Full, + _ => return Err(StoreError::Corrupt), + }; + let voucher = Voucher { + derivation_index, + exponent, + allocated_at_ms, + ready_at_ms, + remote_state, + local_state, + privacy, + }; + if state.vouchers.insert(derivation_index, voucher).is_some() { + return Err(StoreError::Corrupt); + } + } + for _ in 0..input.count(MAX_RECORDS)? { + let entry_id = input.id()?; + let operation = WalOperation::from_raw(input.value()?).ok_or(StoreError::Corrupt)?; + let payload = WalPayload { + input_coins: input.refs()?, + input_vouchers: input.refs()?, + output_coins: input.refs()?, + output_vouchers: input.refs()?, + destination_coins: input.refs()?, + }; + let checkpoint = match input.value::>()? { + None => CheckpointBlock::Pending, + Some((number, hash)) => CheckpointBlock::Known { number, hash }, + }; + let entry = TransferWalEntry { + entry_id: entry_id.clone(), + operation, + payload, + checkpoint, + created_at_ms: input.value()?, + }; + if state.wal.insert(entry_id, entry).is_some() { + return Err(StoreError::Corrupt); + } + } + for _ in 0..input.count(MAX_RECORDS)? { + let memo_key = input.value()?; + let message_id = if input.value::()? { + Some(input.id()?) + } else { + None + }; + let count = input.count(MAX_ITEMS)?; + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + entries.push(CodableClaimPlanEntry { + entry_index: input.value()?, + exponent: input.value()?, + derivation_index: input.value()?, + }); + } + let count = input.count(MAX_ITEMS)?; + let mut outgoing_public_keys = Vec::with_capacity(count); + for _ in 0..count { + outgoing_public_keys.push(input.value()?); + } + let plan = ClaimPlan { + memo_key, + message_id, + entries, + outgoing_public_keys, + detection_anchor: input.value()?, + status: ClaimPlanStatus::from_raw(input.value()?).ok_or(StoreError::Corrupt)?, + claimed_amount: input.value()?, + total_value: input.value()?, + }; + if state.plans.insert(memo_key, plan).is_some() { + return Err(StoreError::Corrupt); + } + } + for _ in 0..input.count(MAX_RECORDS)? { + let id = input.value()?; + let bytes = Zeroizing::new(input.bytes(MAX_OPERATION_BYTES)?.to_vec()); + if state.operations.insert(id, bytes).is_some() { + return Err(StoreError::Corrupt); + } + } + if !input.0.is_empty() { + return Err(StoreError::Corrupt); + } + validate(&state)?; + Ok(state) +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_store/repositories.rs b/rust/crates/truapi-server/src/runtime/coinage_store/repositories.rs new file mode 100644 index 000000000..660155631 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_store/repositories.rs @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host snapshot implementations of Brevity Coinage's repository contracts. + +use std::collections::BTreeSet; + +use async_trait::async_trait; +use truapi_coinage::{ + claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore}, + index_store::{CoinageIndexStore, IndexKind}, + model::{Coin, CoinState, Voucher, VoucherLocalState, VoucherRemoteState}, + repo::{CoinRepository, TransferStateCommitter, VoucherRepository}, + wal::{CheckpointBlock, TransferWalEntry, WalOperation, WalStore}, +}; + +use super::{HostCoinageStore, MAX_ITEMS, Snapshot, StoreError, codec}; + +impl Snapshot { + fn put_coin(&mut self, coin: Coin) -> Result<(), StoreError> { + if self + .coins + .get(&coin.derivation_index) + .is_some_and(|old| old.exponent != coin.exponent) + { + return Err(StoreError::Conflict); + } + codec::advance(&mut self.coin_index, coin.derivation_index); + self.coins.insert(coin.derivation_index, coin); + Ok(()) + } + + fn put_voucher(&mut self, voucher: Voucher) -> Result<(), StoreError> { + if self + .vouchers + .get(&voucher.derivation_index) + .is_some_and(|old| old.exponent != voucher.exponent) + { + return Err(StoreError::Conflict); + } + codec::advance(&mut self.voucher_index, voucher.derivation_index); + self.vouchers.insert(voucher.derivation_index, voucher); + Ok(()) + } + + fn put_wal(&mut self, entry: TransferWalEntry) -> Result<(), StoreError> { + codec::validate_id(&entry.entry_id)?; + if let Some(old) = self.wal.get(&entry.entry_id) { + // Receipt/checkpoint progress is mutable, spend identity is not. + let transition = old.operation == entry.operation + || matches!( + (old.operation, entry.operation), + ( + WalOperation::TransferPrepared, + WalOperation::TransferAccepted | WalOperation::TransferRejected + ) | ( + WalOperation::TransferAccepted, + WalOperation::TransferCompleted + ) + ); + if old.payload != entry.payload + || old.created_at_ms != entry.created_at_ms + || !transition + { + return Err(StoreError::Conflict); + } + } + for coin in entry + .payload + .input_coins + .iter() + .chain(&entry.payload.output_coins) + .chain(&entry.payload.destination_coins) + { + codec::advance(&mut self.coin_index, coin.derivation_index); + } + for voucher in entry + .payload + .input_vouchers + .iter() + .chain(&entry.payload.output_vouchers) + { + codec::advance(&mut self.voucher_index, voucher.derivation_index); + } + self.wal.insert(entry.entry_id.clone(), entry); + Ok(()) + } + + fn counter(&mut self, kind: IndexKind) -> &mut Option { + match kind { + IndexKind::Coin => &mut self.coin_index, + IndexKind::Voucher => &mut self.voucher_index, + } + } +} + +fn unique(indices: &[u32]) -> Result<(), StoreError> { + if indices.len() > MAX_ITEMS { + return Err(StoreError::Capacity); + } + let mut seen = BTreeSet::new(); + if indices.iter().all(|index| seen.insert(*index)) { + Ok(()) + } else { + Err(StoreError::Conflict) + } +} + +#[async_trait] +impl CoinRepository for HostCoinageStore { + async fn list(&self) -> Result, String> { + Ok(self + .checked_state() + .await + .map_err(|e| e.to_string())? + .coins + .values() + .cloned() + .collect()) + } + + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + let coin = coin.clone(); + self.mutate(move |state| state.put_coin(coin)) + .await + .map_err(|error| error.to_string()) + } + + async fn set_state(&self, derivation_index: u32, new_state: CoinState) -> Result<(), String> { + self.mutate(move |state| { + state + .coins + .get_mut(&derivation_index) + .ok_or(StoreError::Missing)? + .state = new_state; + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.mutate(move |state| { + state.coins.remove(&derivation_index); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} + +#[async_trait] +impl VoucherRepository for HostCoinageStore { + async fn list(&self) -> Result, String> { + Ok(self + .checked_state() + .await + .map_err(|e| e.to_string())? + .vouchers + .values() + .cloned() + .collect()) + } + + async fn upsert(&self, voucher: &Voucher) -> Result<(), String> { + let voucher = voucher.clone(); + self.mutate(move |state| state.put_voucher(voucher)) + .await + .map_err(|error| error.to_string()) + } + + async fn set_local_state( + &self, + derivation_index: u32, + new_state: VoucherLocalState, + ) -> Result<(), String> { + self.mutate(move |state| { + state + .vouchers + .get_mut(&derivation_index) + .ok_or(StoreError::Missing)? + .local_state = new_state; + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn set_remote_state( + &self, + derivation_index: u32, + new_state: VoucherRemoteState, + ) -> Result<(), String> { + self.mutate(move |state| { + state + .vouchers + .get_mut(&derivation_index) + .ok_or(StoreError::Missing)? + .remote_state = new_state; + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.mutate(move |state| { + state.vouchers.remove(&derivation_index); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} + +#[async_trait] +impl WalStore for HostCoinageStore { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let entry = entry.clone(); + self.mutate(move |state| state.put_wal(entry)) + .await + .map_err(|error| error.to_string()) + } + + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + if entries.len() > super::MAX_RECORDS { + return Err(StoreError::Capacity.to_string()); + } + let entries = entries.to_vec(); + self.mutate(move |state| { + let mut seen = BTreeSet::new(); + for entry in entries { + if !seen.insert(entry.entry_id.clone()) { + return Err(StoreError::Conflict); + } + state.put_wal(entry)?; + } + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let entry_id = entry_id.to_owned(); + self.mutate(move |state| { + let entry = state.wal.get_mut(&entry_id).ok_or(StoreError::Missing)?; + if entry.checkpoint != CheckpointBlock::Pending && entry.checkpoint != checkpoint { + return Err(StoreError::Conflict); + } + entry.checkpoint = checkpoint; + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self + .checked_state() + .await + .map_err(|e| e.to_string())? + .wal + .values() + .cloned() + .collect()) + } + + async fn delete(&self, entry_id: &str) -> Result<(), String> { + let entry_id = entry_id.to_owned(); + self.mutate(move |state| { + state.wal.remove(&entry_id); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} + +#[async_trait] +impl ClaimPlanStore for HostCoinageStore { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + let plan = plan.clone(); + self.mutate(move |state| { + for entry in &plan.entries { + codec::advance(&mut state.coin_index, entry.derivation_index); + } + state.plans.insert(plan.memo_key, plan); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self + .checked_state() + .await + .map_err(|e| e.to_string())? + .plans + .get(memo_key) + .cloned()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self + .checked_state() + .await + .map_err(|e| e.to_string())? + .plans + .values() + .cloned() + .collect()) + } + + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let memo_key = *memo_key; + self.mutate(move |state| { + let plan = state.plans.get_mut(&memo_key).ok_or(StoreError::Missing)?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + let memo_key = *memo_key; + self.mutate(move |state| { + state.plans.remove(&memo_key); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} + +#[async_trait] +impl CoinageIndexStore for HostCoinageStore { + async fn get_next_index(&self, kind: IndexKind) -> Result { + self.mutate(move |state| { + let counter = state.counter(kind); + let next = match *counter { + None => 0, + Some(current) => current.checked_add(1).ok_or(StoreError::Exhausted)?, + }; + *counter = Some(next); + Ok(next) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn current_index(&self, kind: IndexKind) -> Result, String> { + let state = self.checked_state().await.map_err(|e| e.to_string())?; + Ok(match kind { + IndexKind::Coin => state.coin_index, + IndexKind::Voucher => state.voucher_index, + }) + } + + async fn set_index(&self, kind: IndexKind, index: u32) -> Result<(), String> { + self.mutate(move |state| { + let counter = state.counter(kind); + if counter.is_some_and(|old| index < old) { + return Err(StoreError::Conflict); + } + *counter = Some(index); + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} + +#[async_trait] +impl TransferStateCommitter for HostCoinageStore { + async fn reserve(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + unique(coins) + .and_then(|()| unique(vouchers)) + .map_err(|error| error.to_string())?; + let coins = coins.to_vec(); + let vouchers = vouchers.to_vec(); + self.mutate(move |state| { + for index in coins { + let coin = state.coins.get_mut(&index).ok_or(StoreError::Missing)?; + if coin.state != CoinState::Available { + return Err(StoreError::Conflict); + } + coin.state = CoinState::PendingTransfer; + } + for index in vouchers { + let voucher = state.vouchers.get_mut(&index).ok_or(StoreError::Missing)?; + if voucher.local_state != VoucherLocalState::Available { + return Err(StoreError::Conflict); + } + voucher.local_state = VoucherLocalState::PendingTransfer; + } + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn revert(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + unique(coins) + .and_then(|()| unique(vouchers)) + .map_err(|error| error.to_string())?; + let coins = coins.to_vec(); + let vouchers = vouchers.to_vec(); + self.mutate(move |state| { + for index in coins { + if let Some(coin) = state.coins.get_mut(&index) { + if coin.state == CoinState::PendingTransfer { + coin.state = CoinState::Available; + } + } + } + for index in vouchers { + if let Some(voucher) = state.vouchers.get_mut(&index) { + if voucher.local_state == VoucherLocalState::PendingTransfer { + voucher.local_state = VoucherLocalState::Available; + } + } + } + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } + + async fn commit( + &self, + spent_coins: &[u32], + spent_vouchers: &[u32], + change: &[Coin], + destination: &[Coin], + ) -> Result<(), String> { + unique(spent_coins) + .and_then(|()| unique(spent_vouchers)) + .map_err(|error| error.to_string())?; + if change.len().saturating_add(destination.len()) > MAX_ITEMS { + return Err(StoreError::Capacity.to_string()); + } + let spent_coins = spent_coins.to_vec(); + let spent_vouchers = spent_vouchers.to_vec(); + let change = change.to_vec(); + let destination = destination.to_vec(); + self.mutate(move |state| { + let mut indices: BTreeSet<_> = spent_coins.iter().copied().collect(); + for coin in change.iter().chain(&destination) { + if !indices.insert(coin.derivation_index) { + return Err(StoreError::Conflict); + } + if state.coins.contains_key(&coin.derivation_index) { + return Err(StoreError::Conflict); + } + } + for index in spent_coins { + let coin = state.coins.get_mut(&index).ok_or(StoreError::Missing)?; + if coin.state != CoinState::PendingTransfer { + return Err(StoreError::Conflict); + } + coin.state = CoinState::Spent; + } + for index in spent_vouchers { + let voucher = state.vouchers.get(&index).ok_or(StoreError::Missing)?; + if voucher.local_state != VoucherLocalState::PendingTransfer { + return Err(StoreError::Conflict); + } + state.vouchers.remove(&index); + } + for mut coin in change { + coin.state = CoinState::Available; + state.put_coin(coin)?; + } + for mut coin in destination { + coin.state = CoinState::Spent; + state.put_coin(coin)?; + } + Ok(()) + }) + .await + .map_err(|error| error.to_string()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/coinage_store/tests.rs b/rust/crates/truapi-server/src/runtime/coinage_store/tests.rs new file mode 100644 index 000000000..d66313cd1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/coinage_store/tests.rs @@ -0,0 +1,878 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Observable durability, atomicity, cancellation, and authenticated restore tests. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::{channel::oneshot, executor::block_on}; +use parity_scale_codec::Encode; +use parking_lot::Mutex as SyncMutex; +use truapi::latest::GenericError; +use truapi_coinage::{ + claim_plan::{ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry}, + index_store::{CoinageIndexStore, IndexKind}, + model::{CoinState, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}, + repo::{CoinRepository, TransferStateCommitter, VoucherRepository}, + wal::{CheckpointBlock, WalCoinRef, WalOperation, WalPayload, WalStore}, +}; + +use super::*; + +#[derive(Default)] +struct DurableSlot { + values: SyncMutex, Vec>>, + fail_next: AtomicBool, + fail_after_replace: AtomicBool, + pause_next: SyncMutex, oneshot::Receiver<()>)>>, +} + +#[async_trait::async_trait] +impl CoreStorage for DurableSlot { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self.values.lock().get(&key.encode()).cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + let pause = self.pause_next.lock().take(); + if let Some((entered, resume)) = pause { + let _ = entered.send(()); + resume.await.unwrap(); + } + if self.fail_next.swap(false, Ordering::SeqCst) { + return Err(GenericError { + reason: "injected atomic write failure".into(), + }); + } + self.values.lock().insert(key.encode(), value); + if self.fail_after_replace.swap(false, Ordering::SeqCst) { + return Err(GenericError { + reason: "injected durability failure after replacement".into(), + }); + } + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.values.lock().remove(&key.encode()); + Ok(()) + } +} + +async fn open(storage: Arc) -> Arc { + try_open(storage).await.unwrap() +} + +async fn try_open(storage: Arc) -> Result, StoreError> { + HostCoinageStore::open_storage( + storage, + [1; 32], + [2; 32], + Zeroizing::new([3; 32]), + crate::test_support::test_spawner(), + ) + .await +} + +async fn reopen_after_owned_work(storage: Arc) -> Arc { + let reopen = async { + loop { + match try_open(storage.clone()).await { + Ok(store) => break store, + Err(StoreError::Conflict) => { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + Err(error) => panic!("unexpected reopen failure: {error}"), + } + } + }; + let timeout = futures_timer::Delay::new(std::time::Duration::from_secs(5)); + futures::pin_mut!(reopen, timeout); + match futures::future::select(reopen, timeout).await { + futures::future::Either::Left((store, _)) => store, + futures::future::Either::Right(_) => { + panic!("owned commit did not release storage ownership") + } + } +} + +fn coin(index: u32) -> Coin { + Coin { + derivation_index: index, + exponent: 2, + age: Some(3), + state: CoinState::Available, + } +} + +fn voucher(index: u32) -> Voucher { + Voucher { + derivation_index: index, + exponent: -2, + allocated_at_ms: 100, + ready_at_ms: 200, + local_state: VoucherLocalState::Available, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 7 }, + privacy: VoucherPrivacyLevel::Full, + } +} + +fn journal(id: &str) -> TransferWalEntry { + TransferWalEntry { + entry_id: id.into(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 0, + exponent: 2, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 100, + } +} + +#[test] +fn asset_binding_survives_restart_without_retargeting_reserved_operations() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + store.bind_asset_instance(Some(0)).await.unwrap(); + store + .write_operation([9; 32], b"pending private handoff".to_vec()) + .await + .unwrap(); + drop(store); + let restored = open(storage).await; + assert_eq!( + restored.bind_asset_instance(Some(1)).await, + Err(StoreError::Conflict) + ); + assert_eq!( + restored.bind_asset_instance(None).await, + Err(StoreError::Conflict) + ); + restored.bind_asset_instance(Some(0)).await.unwrap(); + assert_eq!( + restored.read_operation([9; 32]).await.unwrap(), + Some(b"pending private handoff".to_vec()) + ); + }); +} + +#[test] +fn a_bound_legacy_asset_is_not_an_uninitialized_asset_selection() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + store.bind_asset_instance(None).await.unwrap(); + drop(store); + let restored = open(storage).await; + assert_eq!( + restored.bind_asset_instance(Some(0)).await, + Err(StoreError::Conflict) + ); + }); +} + +#[test] +fn restart_restores_atomic_spend_repositories_and_host_operations() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + CoinRepository::upsert(&*store, &coin(0)).await.unwrap(); + VoucherRepository::upsert(&*store, &voucher(4)) + .await + .unwrap(); + store.reserve(&[0], &[4]).await.unwrap(); + WalStore::save(&*store, &journal("handoff")).await.unwrap(); + store + .update_checkpoint( + "handoff", + CheckpointBlock::Known { + number: 42, + hash: [8; 32], + }, + ) + .await + .unwrap(); + ClaimPlanStore::save( + &*store, + &ClaimPlan { + memo_key: [9; 32], + message_id: Some("payment".into()), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 2, + derivation_index: 2, + }], + outgoing_public_keys: vec![[10; 32]], + detection_anchor: Some([8; 32]), + status: ClaimPlanStatus::Detected, + claimed_amount: None, + total_value: 400, + }, + ) + .await + .unwrap(); + store + .write_operation([7; 32], b"host-private handoff material".to_vec()) + .await + .unwrap(); + store + .commit(&[0], &[4], &[coin(1)], &[coin(2)]) + .await + .unwrap(); + drop(store); + + let restored = open(storage.clone()).await; + let coins = CoinRepository::list(&*restored).await.unwrap(); + assert_eq!( + coins + .iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(), + vec![ + (0, CoinState::Spent), + (1, CoinState::Available), + (2, CoinState::Spent) + ] + ); + assert!( + VoucherRepository::list(&*restored) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + restored.current_index(IndexKind::Voucher).await.unwrap(), + Some(4) + ); + assert_eq!(restored.get_next_index(IndexKind::Coin).await.unwrap(), 3); + assert_eq!( + WalStore::load_all(&*restored).await.unwrap()[0].checkpoint, + CheckpointBlock::Known { + number: 42, + hash: [8; 32] + } + ); + assert_eq!( + restored + .plan(&[9; 32]) + .await + .unwrap() + .unwrap() + .outgoing_public_keys, + vec![[10; 32]] + ); + assert_eq!( + restored.read_operation([7; 32]).await.unwrap(), + Some(b"host-private handoff material".to_vec()) + ); + // No decrypted operation material is persisted in the Host slot. + assert!(!storage.values.lock().values().any(|bytes| { + bytes + .windows(b"host-private handoff material".len()) + .any(|part| part == b"host-private handoff material") + })); + }); +} + +#[test] +fn failed_writes_and_invalid_cross_repository_changes_publish_nothing() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + CoinRepository::upsert(&*store, &coin(0)).await.unwrap(); + VoucherRepository::upsert(&*store, &voucher(0)) + .await + .unwrap(); + assert!(store.reserve(&[0], &[99]).await.is_err()); + assert_eq!( + CoinRepository::list(&*store).await.unwrap()[0].state, + CoinState::Available + ); + storage.fail_next.store(true, Ordering::SeqCst); + assert!(store.reserve(&[0], &[0]).await.is_err()); + assert!(CoinRepository::list(&*store).await.is_err()); + assert!(VoucherRepository::list(&*store).await.is_err()); + drop(store); + let store = open(storage.clone()).await; + assert_eq!( + VoucherRepository::list(&*store).await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + store.reserve(&[0], &[0]).await.unwrap(); + storage.fail_next.store(true, Ordering::SeqCst); + assert!(store.commit(&[0], &[0], &[coin(1)], &[]).await.is_err()); + assert!(CoinRepository::list(&*store).await.is_err()); + assert!(VoucherRepository::list(&*store).await.is_err()); + drop(store); + let restored = open(storage).await; + assert_eq!( + CoinRepository::list(&*restored).await.unwrap()[0].state, + CoinState::PendingTransfer + ); + assert_eq!( + VoucherRepository::list(&*restored).await.unwrap()[0].local_state, + VoucherLocalState::PendingTransfer + ); + restored.revert(&[0], &[0]).await.unwrap(); + assert_eq!( + CoinRepository::list(&*restored).await.unwrap()[0].state, + CoinState::Available + ); + assert_eq!( + VoucherRepository::list(&*restored).await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + }); +} + +#[test] +fn counters_are_monotonic_durable_and_unique_under_concurrency() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let mut allocated = + futures::future::join_all((0..32).map(|_| store.get_next_index(IndexKind::Coin))) + .await + .into_iter() + .collect::, _>>() + .unwrap(); + allocated.sort_unstable(); + assert_eq!(allocated, (0..32).collect::>()); + assert!(store.set_index(IndexKind::Coin, 1).await.is_err()); + storage.fail_next.store(true, Ordering::SeqCst); + assert!(store.get_next_index(IndexKind::Coin).await.is_err()); + assert!(store.current_index(IndexKind::Coin).await.is_err()); + drop(store); + let restored = open(storage).await; + assert_eq!(restored.get_next_index(IndexKind::Coin).await.unwrap(), 32); + restored + .set_index(IndexKind::Voucher, u32::MAX) + .await + .unwrap(); + assert!(restored.get_next_index(IndexKind::Voucher).await.is_err()); + assert_eq!( + restored.current_index(IndexKind::Voucher).await.unwrap(), + Some(u32::MAX) + ); + }); +} + +#[test] +fn canceled_caller_cannot_interrupt_commit_and_publication() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + *storage.pause_next.lock() = Some((entered_tx, resume_rx)); + let mut caller = Box::pin(store.write_operation([9; 32], vec![4, 5, 6])); + assert!(futures::poll!(&mut caller).is_pending()); + entered_rx.await.unwrap(); + drop(caller); + resume_tx.send(()).unwrap(); + // Reads wait for the owned task, not for the disconnected caller. + assert_eq!( + store.read_operation([9; 32]).await.unwrap(), + Some(vec![4, 5, 6]) + ); + drop(store); + assert_eq!( + reopen_after_owned_work(storage) + .await + .read_operation([9; 32]) + .await + .unwrap(), + Some(vec![4, 5, 6]) + ); + }); +} + +#[test] +fn journal_batch_and_checkpoint_failures_do_not_erase_recovery_evidence() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + WalStore::save(&*store, &journal("first")).await.unwrap(); + let mut conflicting = journal("first"); + conflicting.created_at_ms += 1; + assert!( + store + .save_all(&[journal("second"), conflicting]) + .await + .is_err() + ); + assert_eq!( + WalStore::load_all(&*store).await.unwrap(), + vec![journal("first")] + ); + assert!( + store + .update_checkpoint("absent", CheckpointBlock::Pending) + .await + .is_err() + ); + storage.fail_next.store(true, Ordering::SeqCst); + assert!( + store + .update_checkpoint( + "first", + CheckpointBlock::Known { + number: 10, + hash: [6; 32] + } + ) + .await + .is_err() + ); + assert!(WalStore::load_all(&*store).await.is_err()); + drop(store); + assert_eq!( + WalStore::load_all(&*open(storage).await).await.unwrap(), + vec![journal("first")] + ); + }); +} + +#[test] +fn legacy_purse_snapshot_cannot_rebind_reserved_indices_to_current_keys() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let slot = CoreStorageKey::MainPurseCoinage { + root_public_key: [1; 32], + genesis_hash: [2; 32], + }; + let legacy = Snapshot { + coin_index: Some(7), + coins: BTreeMap::from([(7, coin(7))]), + ..Default::default() + }; + let mut plaintext = codec::encode(&legacy, TAG_BYTES).unwrap(); + let mut aad = b"truapi/host/main-purse-coinage\0".to_vec(); + aad.extend_from_slice(b"HCPS"); + aad.extend_from_slice(&2u16.to_le_bytes()); + aad.extend_from_slice(&[1; 32]); + aad.extend_from_slice(&[2; 32]); + let nonce = [9; 24]; + XChaCha20Poly1305::new((&[3; 32]).into()) + .encrypt_in_place(XNonce::from_slice(&nonce), &aad, &mut *plaintext) + .unwrap(); + let mut encoded = b"HCPS".to_vec(); + encoded.extend_from_slice(&2u16.to_le_bytes()); + encoded.extend_from_slice(&nonce); + encoded.extend_from_slice(&plaintext); + storage.values.lock().insert(slot.encode(), encoded.clone()); + let restored = HostCoinageStore::open_storage( + storage.clone(), + [1; 32], + [2; 32], + Zeroizing::new([3; 32]), + crate::test_support::test_spawner(), + ) + .await; + assert!(matches!(restored, Err(StoreError::Version))); + assert_eq!(storage.values.lock().get(&slot.encode()), Some(&encoded)); + }); +} + +#[test] +fn authentication_binds_wallet_network_version_and_rejects_corruption() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + store.write_operation([4; 32], vec![42; 32]).await.unwrap(); + let first = storage + .values + .lock() + .get(&store.storage_key.encode()) + .unwrap() + .clone(); + store.write_operation([4; 32], vec![42; 32]).await.unwrap(); + let second = storage + .values + .lock() + .get(&store.storage_key.encode()) + .unwrap() + .clone(); + assert_ne!(&first[6..HEADER_BYTES], &second[6..HEADER_BYTES]); + drop(store); + let mut tampered = first.clone(); + *tampered.last_mut().unwrap() ^= 1; + let mut unknown = first.clone(); + unknown[4..6].copy_from_slice(&(VERSION + 1).to_le_bytes()); + for (root, genesis, key, bytes, error) in [ + ( + [8; 32], + [2; 32], + [3; 32], + first.clone(), + StoreError::Authentication, + ), + ( + [1; 32], + [8; 32], + [3; 32], + first.clone(), + StoreError::Authentication, + ), + ([1; 32], [2; 32], [8; 32], first, StoreError::Authentication), + ( + [1; 32], + [2; 32], + [3; 32], + tampered, + StoreError::Authentication, + ), + ([1; 32], [2; 32], [3; 32], unknown, StoreError::Version), + ([1; 32], [2; 32], [3; 32], Vec::new(), StoreError::Corrupt), + ] { + storage.values.lock().insert( + CoreStorageKey::MainPurseCoinage { + root_public_key: root, + genesis_hash: genesis, + } + .encode(), + bytes, + ); + assert!(matches!( + HostCoinageStore::open_storage( + storage.clone(), + root, + genesis, + Zeroizing::new(key), + crate::test_support::test_spawner(), + ) + .await, + Err(observed) if observed == error + )); + } + }); +} + +#[test] +fn authenticated_but_inconsistent_and_unbounded_snapshots_fail_closed() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let mut inconsistent = Snapshot::default(); + inconsistent.coins.insert(0, coin(0)); // Missing high-water mark would reissue key zero. + let encrypted = store.encrypt(&inconsistent).unwrap(); + storage + .values + .lock() + .insert(store.storage_key.encode(), encrypted); + drop(store); + assert!(matches!( + HostCoinageStore::open_storage( + storage, + [1; 32], + [2; 32], + Zeroizing::new([3; 32]), + crate::test_support::test_spawner() + ) + .await, + Err(StoreError::Corrupt) + )); + + let mut excessive_rows = vec![0, 0, 0]; // Unbound asset and absent counters. + ((MAX_ASSETS + 1) as u32).encode_to(&mut excessive_rows); + assert!(matches!( + codec::decode(&excessive_rows), + Err(StoreError::Capacity) + )); + let mut duplicate = Vec::new(); + None::>.encode_to(&mut duplicate); + Some(0u32).encode_to(&mut duplicate); + None::.encode_to(&mut duplicate); + 2u32.encode_to(&mut duplicate); + for _ in 0..2 { + 0u32.encode_to(&mut duplicate); + 2i16.encode_to(&mut duplicate); + Some(3i16).encode_to(&mut duplicate); + CoinState::Available.as_raw().encode_to(&mut duplicate); + } + assert!(matches!( + codec::decode(&duplicate), + Err(StoreError::Corrupt) + )); + let mut trailing = codec::encode(&Snapshot::default(), 1).unwrap(); + trailing.push(0); + assert!(matches!(codec::decode(&trailing), Err(StoreError::Corrupt))); + }); +} + +#[test] +fn post_replacement_failure_blocks_stale_reads_and_writes_until_recovery() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + CoinRepository::upsert(&*store, &coin(0)).await.unwrap(); + VoucherRepository::upsert(&*store, &voucher(0)) + .await + .unwrap(); + WalStore::save(&*store, &journal("guard")).await.unwrap(); + store.write_operation([7; 32], vec![1, 2, 3]).await.unwrap(); + store.reserve(&[0], &[0]).await.unwrap(); + storage.fail_after_replace.store(true, Ordering::SeqCst); + assert!(store.commit(&[0], &[0], &[coin(1)], &[]).await.is_err()); + + // The old published view cannot authorize reads or overwrite the new + // snapshot while durability is unresolved, including "absent" queries. + assert!(CoinRepository::list(&*store).await.is_err()); + assert!(VoucherRepository::list(&*store).await.is_err()); + assert!(WalStore::load_all(&*store).await.is_err()); + assert!(ClaimPlanStore::load_all(&*store).await.is_err()); + assert!(store.plan(&[9; 32]).await.is_err()); + assert!(store.current_index(IndexKind::Coin).await.is_err()); + assert_eq!(store.read_operation([7; 32]).await, Err(StoreError::Write)); + assert_eq!(store.read_operation([8; 32]).await, Err(StoreError::Write)); + assert!(store.list_operations().await.is_err()); + assert_eq!( + store.write_operation([8; 32], vec![9]).await, + Err(StoreError::Write) + ); + assert!(store.get_next_index(IndexKind::Coin).await.is_err()); + assert!(store.revert(&[0], &[0]).await.is_err()); + + // An unauthenticated replacement must not clear the failure latch. + let replaced = storage + .values + .lock() + .get(&store.storage_key.encode()) + .unwrap() + .clone(); + let mut corrupted = replaced.clone(); + *corrupted.last_mut().unwrap() ^= 1; + storage + .values + .lock() + .insert(store.storage_key.encode(), corrupted); + assert_eq!( + store.reauthenticate().await, + Err(StoreError::Authentication) + ); + assert_eq!(store.read_operation([7; 32]).await, Err(StoreError::Write)); + storage + .values + .lock() + .insert(store.storage_key.encode(), replaced); + storage.fail_next.store(true, Ordering::SeqCst); + assert_eq!(store.reauthenticate().await, Err(StoreError::Write)); + assert!(store.current_index(IndexKind::Coin).await.is_err()); + + // Reauthentication must finish its durability repair even when its + // caller disappears while the replacement is being written. + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + *storage.pause_next.lock() = Some((entered_tx, resume_rx)); + let mut recovery = Box::pin(store.reauthenticate()); + assert!(futures::poll!(&mut recovery).is_pending()); + entered_rx.await.unwrap(); + drop(recovery); + resume_tx.send(()).unwrap(); + + let coins = CoinRepository::list(&*store).await.unwrap(); + assert_eq!( + coins + .iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(), + vec![(0, CoinState::Spent), (1, CoinState::Available)] + ); + assert!(VoucherRepository::list(&*store).await.unwrap().is_empty()); + assert_eq!( + store.read_operation([7; 32]).await.unwrap(), + Some(vec![1, 2, 3]) + ); + assert_eq!(store.read_operation([8; 32]).await.unwrap(), None); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 2); + drop(store); + let restored = open(storage).await; + assert_eq!(CoinRepository::list(&*restored).await.unwrap(), coins); + assert_eq!( + restored.current_index(IndexKind::Coin).await.unwrap(), + Some(2) + ); + assert_eq!( + restored.read_operation([7; 32]).await.unwrap(), + Some(vec![1, 2, 3]) + ); + }); +} + +#[test] +fn live_owner_and_abandoned_owned_write_exclude_a_second_allocator() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 0); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Conflict) + )); + + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + *storage.pause_next.lock() = Some((entered_tx, resume_rx)); + let mut allocate = Box::pin(store.get_next_index(IndexKind::Coin)); + assert!(futures::poll!(&mut allocate).is_pending()); + entered_rx.await.unwrap(); + drop(allocate); + drop(store); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Conflict) + )); + resume_tx.send(()).unwrap(); + let restored = reopen_after_owned_work(storage).await; + assert_eq!(restored.get_next_index(IndexKind::Coin).await.unwrap(), 2); + }); +} + +#[test] +fn uncertain_owner_release_requires_durable_repair_and_never_initializes_absence() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + storage.fail_after_replace.store(true, Ordering::SeqCst); + assert!(store.get_next_index(IndexKind::Coin).await.is_err()); + let key = store.storage_key.encode(); + let observed = storage.values.lock().get(&key).unwrap().clone(); + drop(store); + + storage.fail_next.store(true, Ordering::SeqCst); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Write) + )); + storage.values.lock().remove(&key); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Missing) + )); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Missing) + )); + assert!(!storage.values.lock().contains_key(&key)); + storage.values.lock().insert(key, observed); + let restored = open(storage).await; + assert_eq!(restored.get_next_index(IndexKind::Coin).await.unwrap(), 1); + }); +} + +#[test] +fn uncertain_first_write_cannot_be_reset_by_dropping_its_owner() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + storage.fail_next.store(true, Ordering::SeqCst); + assert!(store.get_next_index(IndexKind::Coin).await.is_err()); + assert_eq!(store.reauthenticate().await, Err(StoreError::Missing)); + drop(store); + assert!(matches!( + try_open(storage.clone()).await, + Err(StoreError::Missing) + )); + assert!(storage.values.lock().is_empty()); + }); +} + +#[test] +fn scan_batch_atomically_advances_progress_without_releasing_reservations() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + CoinRepository::upsert(&*store, &coin(5)).await.unwrap(); + store.reserve(&[5], &[]).await.unwrap(); + store + .commit_inventory_batch( + [4; 32], + None, + vec![1], + vec![coin(5), coin(6)], + Vec::new(), + Some(6), + None, + ) + .await + .unwrap(); + // A stale scan cannot import a row or move the allocation counter. + assert_eq!( + store + .commit_inventory_batch( + [4; 32], + None, + vec![2], + vec![coin(9)], + Vec::new(), + Some(9), + None + ) + .await, + Err(StoreError::Conflict) + ); + drop(store); + let restored = open(storage).await; + assert_eq!( + restored.read_operation([4; 32]).await.unwrap(), + Some(vec![1]) + ); + assert_eq!( + CoinRepository::list(&*restored) + .await + .unwrap() + .iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(), + vec![(5, CoinState::PendingTransfer), (6, CoinState::Available)] + ); + assert_eq!(restored.get_next_index(IndexKind::Coin).await.unwrap(), 7); + }); +} + +#[test] +fn operation_receipt_progress_preserves_identity_and_survives_restart() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let mut receipt = TransferWalEntry { + entry_id: truapi_coinage::wal::operation_entry_id("payment", "parent"), + operation: WalOperation::TransferPrepared, + payload: WalPayload { + output_coins: vec![WalCoinRef { + derivation_index: 5, + exponent: 2, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 10, + }; + WalStore::save(&*store, &receipt).await.unwrap(); + receipt.operation = WalOperation::TransferAccepted; + WalStore::save(&*store, &receipt).await.unwrap(); + let mut rebound = receipt.clone(); + rebound.payload.output_coins[0].derivation_index = 6; + assert!(WalStore::save(&*store, &rebound).await.is_err()); + receipt.operation = WalOperation::TransferCompleted; + WalStore::save(&*store, &receipt).await.unwrap(); + drop(store); + let restored = open(storage).await; + assert_eq!( + restored.load_operation("payment").await.unwrap(), + vec![receipt.clone()] + ); + receipt.operation = WalOperation::TransferPrepared; + assert!(WalStore::save(&*restored, &receipt).await.is_err()); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs new file mode 100644 index 000000000..a5da7a1bd --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -0,0 +1,444 @@ +//! Host-owned native Chat transport and main-purse payment authority. +//! +//! A dropped product call never cancels an already-started durable operation. +//! The registry is wallet/network scoped, not product storage, and keeps one +//! writer for each purse and each installation-owned Chat device. + +mod actor; +mod background; +mod hop; +mod hop_access; +mod identity; +mod payments; +mod store; + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use futures::{channel::oneshot, lock::Mutex}; +use parity_scale_codec::{DecodeAll, Encode}; +use truapi::latest::{ + HostProductDeviceChatError as ChatError, HostProductDeviceChatRequest as Request, + HostProductDeviceChatResponse as Response, +}; +use truapi_platform::{CoreStorageKey, normalize_product_identifier}; +use zeroize::Zeroizing; + +use super::{authority::AuthoritySession, services::RuntimeServices}; +use actor::NativeChatActor; +use payments::WalletCoinage; + +/// Authority captured for one product call, rechecked before every new effect. +#[derive(Clone)] +pub(crate) struct NativeChatContext { + pub(crate) services: Arc, + pub(crate) session: AuthoritySession, + pub(crate) entropy: Zeroizing>, + pub(crate) session_valid: Arc bool + Send + Sync>, + pub(crate) network_suffix: String, + pub(crate) genesis_hash: [u8; 32], + pub(crate) coinage_instance_id: Option, +} + +impl NativeChatContext { + pub(super) fn require_current(&self) -> Result<(), ChatError> { + if (self.session_valid)() { + Ok(()) + } else { + Err(ChatError::NotConnected) + } + } +} + +type WalletKey = ([u8; 32], [u8; 32]); +type DeviceKey = (WalletKey, String); + +#[derive(Default)] +struct SessionCache { + // Hold initialization gates across open; owned work retains this cache + // after release, but cannot repopulate the next session's cache. + wallets: Mutex>>, + chats: Mutex>>, +} + +#[derive(Default)] +struct RegistryState { + cache: parking_lot::Mutex>, + receivers: parking_lot::Mutex>, + // Nonsecret uncertainty must survive session cache eviction. + products: Mutex>, +} + +/// Shared authority-owned state, never instantiated per product request. +#[derive(Clone, Default)] +pub(crate) struct NativeChatRegistry { + state: Arc, +} + +impl NativeChatRegistry { + /// Stop network ownership immediately on logout or session replacement. + /// Already-owned durable commits retain their existing completion semantics. + pub(crate) fn stop_receiving(&self) { + self.state.receivers.lock().clear(); + } + + /// Release session secrets without cancelling already-owned durable work. + /// Store ownership excludes a new allocator until that work ends. + pub(crate) fn release(&self) { + self.stop_receiving(); + *self.state.cache.lock() = Arc::default(); + } + + /// Restore only previously initialized, currently authorized products. + pub(crate) fn resume_receiving(&self, context: NativeChatContext) { + let registry = self.clone(); + let spawner = context.services.spawner.clone(); + spawner(Box::pin(async move { + let mut retry = std::time::Duration::from_secs(1); + loop { + match registry.restore_receiving(&context).await { + Ok(()) | Err(ChatError::NotConnected) => break, + Err(error) => { + tracing::warn!(?error, "native Chat receiver restoration failed"); + } + } + futures_timer::Delay::new(retry).await; + retry = (retry * 2).min(background::MAX_RETRY); + if context.require_current().is_err() { + break; + } + } + })); + } + + async fn restore_receiving(&self, context: &NativeChatContext) -> Result<(), ChatError> { + let mut uncertain = self.state.products.lock().await; + let products = self.load_products(context).await?; + let mut failure = None; + if uncertain.contains(&(context.session.public_key, context.genesis_hash)) { + failure = self + .persist_products(context, &products, &mut uncertain) + .await + .err(); + } + for product in products { + context.require_current()?; + // One bad device/grant must not suppress unrelated valid products. + let restored = async { + background::require_authorized(context, &product).await?; + // Restoration must never generate a replacement for a lost device. + if context + .services + .platform + .read_core_storage(CoreStorageKey::NativeChatDevice { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + product_id: product.clone(), + }) + .await + .map_err(|_| ChatError::StorageUnavailable)? + .is_none() + { + return Err(ChatError::StorageUnavailable); + } + let actor = self.chat(context, &product).await?; + self.ensure_receiving(context, &product, actor).await; + Ok(()) + } + .await; + match restored { + Ok(()) | Err(ChatError::AccessNotGranted) => {} + Err(ChatError::NotConnected) => return Err(ChatError::NotConnected), + Err(error) => { + tracing::warn!(?error, %product, "native Chat product restoration failed"); + failure.get_or_insert(error); + } + } + } + failure.map_or(Ok(()), Err) + } + + fn products_key(context: &NativeChatContext) -> CoreStorageKey { + CoreStorageKey::NativeChatProducts { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + } + } + + async fn load_products(&self, context: &NativeChatContext) -> Result, ChatError> { + context.require_current()?; + let stored = context + .services + .platform + .read_core_storage(Self::products_key(context)) + .await + .map_err(|_| ChatError::StorageUnavailable)?; + context.require_current()?; + let Some(bytes) = stored else { + return Ok(Vec::new()); + }; + if bytes.len() > 256 * 260 + 8 { + return Err(ChatError::StorageUnavailable); + } + let (version, products) = <(u8, Vec)>::decode_all(&mut bytes.as_slice()) + .map_err(|_| ChatError::StorageUnavailable)?; + if version != 1 + || products.len() > 256 + || products.windows(2).any(|pair| pair[0] >= pair[1]) + || products + .iter() + .any(|product| normalize_product_identifier(product).as_ref() != Ok(product)) + { + return Err(ChatError::StorageUnavailable); + } + Ok(products) + } + + async fn persist_products( + &self, + context: &NativeChatContext, + products: &[String], + uncertain: &mut HashSet, + ) -> Result<(), ChatError> { + context.require_current()?; + let wallet = (context.session.public_key, context.genesis_hash); + uncertain.insert(wallet); + context + .services + .platform + .write_core_storage(Self::products_key(context), (1u8, products).encode()) + .await + .map_err(|_| ChatError::StorageUnavailable)?; + uncertain.remove(&wallet); + Ok(()) + } + + async fn remember_product( + &self, + context: &NativeChatContext, + product: &str, + ) -> Result<(), ChatError> { + let mut uncertain = self.state.products.lock().await; + let mut products = self.load_products(context).await?; + match products.binary_search_by(|value| value.as_str().cmp(product)) { + Ok(_) if !uncertain.contains(&(context.session.public_key, context.genesis_hash)) => { + return Ok(()); + } + Ok(_) => {} + Err(index) => { + if products.len() >= 256 { + return Err(ChatError::StorageUnavailable); + } + products.insert(index, product.to_owned()); + } + } + self.persist_products(context, &products, &mut uncertain) + .await + } + + /// Forgetting owns its write even if the host administration call is dropped. + pub(crate) async fn forget_product( + &self, + context: &NativeChatContext, + product: &str, + ) -> Result<(), ChatError> { + let registry = self.clone(); + let context = context.clone(); + let product = product.to_owned(); + let spawner = context.services.spawner.clone(); + let (send, receive) = oneshot::channel(); + spawner(Box::pin(async move { + // Rebind unrelated products even if the index operation fails or + // its caller disappears; no second cold-restoration loop is needed. + registry.rebind_receiving(&context, &product).await; + let result = registry.forget_product_owned(&context, &product).await; + let _ = send.send(result); + })); + receive.await.unwrap_or(Err(ChatError::StorageUnavailable)) + } + + async fn forget_product_owned( + &self, + context: &NativeChatContext, + product: &str, + ) -> Result<(), ChatError> { + let mut uncertain = self.state.products.lock().await; + self.state.receivers.lock().remove(&( + (context.session.public_key, context.genesis_hash), + product.to_owned(), + )); + let mut products = self.load_products(context).await?; + match products.binary_search_by(|value| value.as_str().cmp(product)) { + Ok(index) => { + products.remove(index); + } + Err(_) if !uncertain.contains(&(context.session.public_key, context.genesis_hash)) => { + return Ok(()); + } + Err(_) => {} + } + self.persist_products(context, &products, &mut uncertain) + .await + } + + pub(crate) async fn execute( + &self, + context: NativeChatContext, + calling_product_id: String, + request: Request, + ) -> Result { + context.require_current()?; + let owned = self.clone(); + let spawner = context.services.spawner.clone(); + let (send, receive) = oneshot::channel(); + spawner(Box::pin(async move { + let result = owned + .execute_owned(context, calling_product_id, request) + .await; + let _ = send.send(result); + })); + receive.await.unwrap_or(Err(ChatError::StorageUnavailable)) + } + + async fn chat( + &self, + context: &NativeChatContext, + product: &str, + ) -> Result, ChatError> { + let key = ( + (context.session.public_key, context.genesis_hash), + product.to_owned(), + ); + let cache = self.state.cache.lock().clone(); + let mut chats = cache.chats.lock().await; + context.require_current()?; + if let Some(actor) = chats.get(&key) { + return Ok(actor.clone()); + } + if chats.len() >= 256 { + return Err(ChatError::StorageUnavailable); + } + context.require_current()?; + let opened = NativeChatActor::open(context, product).await?; + context.require_current()?; + // Failed opens are retryable; the store owns durable uncertainty. + chats.insert(key, opened.clone()); + Ok(opened) + } + + pub(super) async fn wallet( + &self, + context: &NativeChatContext, + ) -> Result, ChatError> { + let key = (context.session.public_key, context.genesis_hash); + let cache = self.state.cache.lock().clone(); + let mut wallets = cache.wallets.lock().await; + context.require_current()?; + if let Some(wallet) = wallets.get(&key) { + return Ok(wallet.clone()); + } + if wallets.len() >= 16 { + return Err(ChatError::StorageUnavailable); + } + context.require_current()?; + let wallet = WalletCoinage::open(context).await?; + context.require_current()?; + wallets.insert(key, wallet.clone()); + Ok(wallet) + } + + async fn execute_owned( + &self, + context: NativeChatContext, + product: String, + request: Request, + ) -> Result { + context.require_current()?; + let chat = self.chat(&context, &product).await?; + self.remember_product(&context, &product).await?; + context.require_current()?; + let operation = async { + match request { + Request::Initialize => {} + Request::Invite { username, text } => chat.invite(&context, username, text).await?, + Request::Receive { statement } => chat.receive(&context, self, statement).await?, + Request::AcceptInvitation { invitation_id } => { + chat.accept(&context, invitation_id).await?; + } + Request::RejectInvitation { invitation_id } => { + chat.reject(&context, invitation_id).await? + } + Request::Send { + peer_identity, + request_id, + messages, + } => { + chat.send(&context, peer_identity, request_id, messages) + .await?; + } + Request::SendAttachments { + peer_identity, + request_id, + text, + } => { + background::require_upload_authorized(&context, &product).await?; + chat.send_attachments(&context, peer_identity, request_id, text) + .await?; + } + Request::OpenAttachment { attachment_id } => { + chat.open_attachment(&context, attachment_id).await?; + } + Request::SendPayment { + peer_identity, + request_id, + amount_cents, + } => { + let (intent, transport) = chat + .payment(&context, peer_identity, request_id, amount_cents) + .await?; + self.wallet(&context) + .await? + .send(&context, intent, transport) + .await?; + chat.flush(&context).await?; + } + Request::PaymentStatus { operation_id } => { + let wallet = self.wallet(&context).await?; + if !wallet + .views(&product) + .await? + .iter() + .any(|view| view.operation_id == operation_id) + { + return Err(ChatError::OperationNotFound); + } + } + Request::Reconcile => { + chat.reconcile(&context, self).await?; + } + } + Ok::<(), ChatError>(()) + } + .await; + // Even a transport error can follow a durable peer/roster mutation. + // Refresh ownership and topics before returning that error to a guest. + self.ensure_receiving(&context, &product, chat.clone()) + .await; + operation?; + context.require_current()?; + let cache = self.state.cache.lock().clone(); + let wallet = cache + .wallets + .lock() + .await + .get(&(context.session.public_key, context.genesis_hash)) + .cloned(); + let payments = match wallet { + Some(wallet) => wallet.views(&product).await?, + None => Vec::new(), + }; + chat.public_view(&context, payments).await + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs new file mode 100644 index 000000000..44911e2a4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -0,0 +1,1252 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Native Chat custody and durable transport. Wire algorithms derive from the +//! AGPL useragent-chat-v2 implementation; no spendable plaintext crosses TrUAPI. + +mod files; +mod history; +mod receive; +#[cfg(test)] +mod tests; + +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::Keypair; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use truapi::latest::*; +use useragent_chat_v2 as wire; +use zeroize::{Zeroize, Zeroizing}; + +use super::{ + NativeChatContext, NativeChatRegistry, identity, + payments::{PaymentIntent, PaymentTransport}, + store::ChatStateStore, +}; +use crate::host_logic::statement_store::{ + current_unix_secs, decode_signed_statement, sign_statement_fields, signed_statement_to_scale, + statement_fields_from_v01, +}; +use crate::host_logic::{product_account::*, sso::pairing::derive_identity_chat_private_key}; +use crate::runtime::{ + chat_device::{HostChatDevice, PeerDevice, validate_guest_messages}, + chat_identity::*, +}; + +type Error = HostProductDeviceChatError; +const MAX_PEERS: usize = 256; +const MAX_OUTBOX: usize = 256; +const MAX_RECEIPTS: usize = 4096; +const MAX_HISTORY_BATCHES: usize = 256; +const LIFETIME: u64 = 2 * 86_400; +const CLOCK_SKEW: u64 = 300; + +#[derive(Clone, Encode, Decode)] +struct Secret32([u8; 32]); +impl Drop for Secret32 { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Clone, Encode, Decode)] +struct DeviceRecord { + account: [u8; 32], + key: Option<[u8; 32]>, + active: bool, + timestamp: u64, + message_id: String, +} + +#[derive(Clone, Encode, Decode)] +struct Peer { + identity: [u8; 32], + root_key: [u8; 32], + username: Option, + devices: Vec, + invitation: Option, + invitation_text: Option, + invitation_timestamp: Option, + established: bool, + revocation_request: Option, + revocation_acked: bool, + revocation_acks: Vec<[u8; 32]>, + revision: u64, +} + +impl Peer { + fn active_devices(&self) -> Vec { + self.devices + .iter() + .filter_map(|device| { + if !device.active { + return None; + } + Some(PeerDevice { + account_id: device.account, + public_key: device.key?, + }) + }) + .collect() + } + fn ready(&self) -> bool { + self.established && self.revocation_acked && self.devices.iter().any(|device| device.active) + } + fn payment_devices(&self) -> impl Iterator + '_ { + self.devices.iter().filter_map(|device| { + if !device.active || !self.revocation_acks.contains(&device.account) { + return None; + } + Some(PeerDevice { + account_id: device.account, + public_key: device.key?, + }) + }) + } + fn ready_for_payments(&self) -> bool { + self.established && self.payment_devices().next().is_some() + } +} + +#[derive(Clone, Encode, Decode)] +struct Invitation { + id: [u8; 32], + peer: [u8; 32], + root_key: [u8; 32], + username: Option, + device_account: [u8; 32], + device_key: [u8; 32], + message_id: String, + timestamp: u64, + text: String, +} + +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +enum OutgoingKind { + Invitation, + Acceptance, + Revocation, + Ordinary, + Payment([u8; 32]), + Acknowledgment, + Rich([u8; 32]), +} + +#[derive(Clone, Encode, Decode)] +struct Outgoing { + peer: [u8; 32], + request_id: String, + digest: [u8; 32], + kind: OutgoingKind, + roster_revision: u64, + statement: SignedStatement, + last_attempt: u64, +} + +#[derive(Clone, Encode, Decode)] +struct Receipt { + peer: [u8; 32], + request_id: String, + digest: [u8; 32], + timestamp: u64, +} + +#[derive(Clone, Encode, Decode)] +struct SentReceipt { + peer: [u8; 32], + request_id: String, + wire_request_id: String, + digest: [u8; 32], +} + +#[derive(Clone, Encode, Decode)] +struct State { + secret: Secret32, + index: [u8; 32], + peers: Vec, + invitations: Vec, + outbox: Vec, + received: Vec, + sent: Vec, + accepted_payments: Vec<[u8; 32]>, + payment_acknowledgments: Vec<[u8; 32]>, + messages: Vec, + acknowledgments: Vec, + last_expiry: u64, + history_imports: Vec, + files: Vec, + rich_messages: Vec, +} + +impl State { + fn initial() -> Result { + Ok(Self { + secret: Secret32(random_bytes()?), + index: random_bytes()?, + peers: Vec::new(), + invitations: Vec::new(), + outbox: Vec::new(), + received: Vec::new(), + sent: Vec::new(), + accepted_payments: Vec::new(), + payment_acknowledgments: Vec::new(), + messages: Vec::new(), + acknowledgments: Vec::new(), + last_expiry: 0, + history_imports: Vec::new(), + files: Vec::new(), + rich_messages: Vec::new(), + }) + } + fn peer(&self, identity: &[u8; 32]) -> Result<&Peer, Error> { + self.peers + .iter() + .find(|peer| &peer.identity == identity) + .ok_or(Error::PeerNotReady) + } + fn peer_mut(&mut self, identity: &[u8; 32]) -> Result<&mut Peer, Error> { + self.peers + .iter_mut() + .find(|peer| &peer.identity == identity) + .ok_or(Error::PeerNotReady) + } + fn expire_receipts(&mut self, now: u64) { + self.received + .retain(|receipt| now <= receipt.timestamp.saturating_add(LIFETIME)); + self.invitations + .retain(|invite| fresh(invite.timestamp, now)); + } + fn queue(&mut self, outgoing: Outgoing) -> Result<(), Error> { + if let Some(existing) = self.outbox.iter_mut().find(|entry| { + entry.peer == outgoing.peer + && entry.request_id == outgoing.request_id + && entry.kind == outgoing.kind + }) { + if existing.digest != outgoing.digest { + return Err(Error::OperationConflict); + } + if existing.kind == OutgoingKind::Acknowledgment + && (existing.statement.topics != outgoing.statement.topics + || existing.statement.channel != outgoing.statement.channel) + { + // Authenticated replay repairs queued ACKs from the old reversed + // route without replacing any message or payment commitment. + *existing = outgoing; + } + return Ok(()); + } + if self.outbox.len() >= MAX_OUTBOX { + return Err(Error::StorageUnavailable); + } + self.outbox.push(outgoing); + Ok(()) + } + fn record_messages(&mut self, messages: HostNativeChatMessages) { + if messages.messages.is_empty() { + return; + } + if self.messages.len() == MAX_HISTORY_BATCHES { + self.messages.remove(0); + } + self.messages.push(messages); + } +} + +pub(super) struct NativeChatActor { + product: String, + public: HostNativeChatDevice, + legacy_account: [u8; 32], + root_secret: Zeroizing<[u8; 32]>, + signer: Keypair, + device: HostChatDevice, + store: Arc>, + delivering: AtomicBool, + receiving: futures::lock::Mutex<()>, + delivery_gate: futures::lock::Mutex<()>, + history_ack_gate: futures::lock::Mutex<()>, + file_selection_gate: futures::lock::Mutex<()>, + file_transfer_gate: futures::lock::Mutex<()>, + file_export_gate: futures::lock::Mutex<()>, + file_cursor: AtomicUsize, +} + +impl NativeChatActor { + pub(super) async fn open( + context: &NativeChatContext, + product: &str, + ) -> Result, Error> { + let store = ChatStateStore::open(context, product, State::initial).await?; + let (index, secret) = store + .read(|state| (state.index, Zeroizing::new(state.secret.0))) + .await?; + let root = derive_root_keypair_from_entropy(&context.entropy) + .map_err(|_| Error::InvalidRequest)?; + let signer = + derive_product_keypair(&root, product, index).map_err(|_| Error::InvalidRequest)?; + let legacy_account = derive_product_keypair(&root, product, index_bytes(0)) + .map_err(|_| Error::InvalidRequest)? + .public + .to_bytes(); + let identity_account_id = + derive_identity_keypair(&context.entropy, &context.network_suffix) + .map_err(|_| Error::InvalidRequest)? + .public + .to_bytes(); + let root_secret = Zeroizing::new(derive_identity_chat_private_key(&context.entropy)); + let device = HostChatDevice::from_secret(signer.public.to_bytes(), *secret); + let public = HostNativeChatDevice { + identity_account_id, + identity_chat_public_key: wire::x25519_public_key(&root_secret), + product_account: ProductAccountId { + dot_ns_identifier: product.to_owned(), + derivation_index: DerivationIndex::Raw(index), + }, + account_id: signer.public.to_bytes(), + chat_public_key: device.public_key(), + }; + let actor = Arc::new(Self { + product: product.to_owned(), + public, + legacy_account, + root_secret, + signer, + device, + store, + delivering: AtomicBool::new(false), + receiving: futures::lock::Mutex::new(()), + delivery_gate: futures::lock::Mutex::new(()), + history_ack_gate: futures::lock::Mutex::new(()), + file_selection_gate: futures::lock::Mutex::new(()), + file_transfer_gate: futures::lock::Mutex::new(()), + file_export_gate: futures::lock::Mutex::new(()), + file_cursor: AtomicUsize::new(0), + }); + actor + .store + .read(|state| actor.validate_state(state)) + .await??; + Ok(actor) + } + + fn validate_state(&self, state: &State) -> Result<(), Error> { + if state.peers.len() > MAX_PEERS + || state.invitations.len() > 16 + || state.outbox.len() > MAX_OUTBOX + || state.received.len() > MAX_RECEIPTS + || state.sent.len() > MAX_RECEIPTS + || state.accepted_payments.len() > MAX_RECEIPTS + || state.messages.len() > MAX_HISTORY_BATCHES + || state.payment_acknowledgments.len() > MAX_RECEIPTS + || state.acknowledgments.len() > MAX_HISTORY_BATCHES + { + return Err(Error::StorageUnavailable); + } + history::validate_imports(&state.history_imports)?; + files::validate(state)?; + let mut identities = std::collections::HashSet::new(); + for peer in &state.peers { + if peer.identity == self.public.identity_account_id + || !identities.insert(peer.identity) + || peer.devices.len() > 64 + || peer.active_devices().len() > 16 + { + return Err(Error::StorageUnavailable); + } + chat_shared_secret(&self.root_secret, &peer.root_key) + .map_err(|_| Error::StorageUnavailable)?; + let mut accounts = std::collections::HashSet::new(); + for device in &peer.devices { + if !accounts.insert(device.account) || (device.active && device.key.is_none()) { + return Err(Error::StorageUnavailable); + } + if let Some(key) = device.key { + self.device + .identity_shared_secret(&key) + .map_err(|_| Error::StorageUnavailable)?; + } + } + } + Ok(()) + } + + fn peer_incoming_topics(&self, peer: &Peer) -> Result, Error> { + let shared = chat_shared_secret(&self.root_secret, &peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + // Native peers publish requests and responses on their own outgoing + // session. Never subscribe to our outgoing route to discover peer ACKs. + let mut topics = vec![chat_identity_session_id( + &shared, + &peer.identity, + &self.public.identity_account_id, + )]; + for device in peer.active_devices() { + let incoming = chat_shared_secret(&self.root_secret, &device.public_key) + .map_err(|_| Error::InvalidStatement)?; + topics.push(chat_identity_session_id( + &incoming, + &device.account_id, + &self.public.identity_account_id, + )); + } + topics.sort_unstable(); + topics.dedup(); + Ok(topics) + } + + pub(super) async fn incoming_topics(&self) -> Result, Error> { + self.store + .read(|state| { + let mut topics = vec![wire::chat_request_full_topic( + &self.public.identity_account_id, + )]; + for peer in &state.peers { + topics.extend(self.peer_incoming_topics(peer)?); + } + topics.sort_unstable(); + topics.dedup(); + Ok(topics) + }) + .await? + } + + pub(super) async fn public_view( + &self, + context: &NativeChatContext, + payments: Vec, + ) -> Result { + context.require_current()?; + self.store + .read(|state| { + let peers = state + .peers + .iter() + .map(|peer| { + let topics = self.peer_incoming_topics(peer)?; + let devices = peer.active_devices(); + Ok(HostNativeChatPeer { + identity_account_id: peer.identity, + username: peer.username.clone(), + devices: devices + .into_iter() + .map(|device| HostNativeChatPeerDevice { + account_id: device.account_id, + chat_public_key: device.public_key, + }) + .collect(), + incoming_channels: topics, + ready_for_payments: peer.ready_for_payments(), + }) + }) + .collect::, Error>>()?; + Ok(HostProductDeviceChatResponse { + device: self.public.clone(), + peers, + invitations: state + .invitations + .iter() + .filter(|invite| fresh(invite.timestamp, current_unix_secs())) + .map(|invite| HostNativeChatInvitation { + invitation_id: invite.id, + peer_identity: invite.peer, + username: invite.username.clone(), + timestamp: invite.timestamp, + text: invite.text.clone(), + }) + .collect(), + messages: state.messages.clone(), + acknowledgments: state.acknowledgments.clone(), + payments, + rich_messages: files::public_views(state)?, + }) + }) + .await? + } + + pub(super) async fn invite( + self: &Arc, + context: &NativeChatContext, + username: String, + text: String, + ) -> Result<(), Error> { + if text.len() > 8192 { + return Err(Error::InvalidRequest); + } + let resolved = identity::resolve_username(context, &username).await?; + context.require_current()?; + if resolved.identity_account_id == self.public.identity_account_id { + return Err(Error::InvalidRequest); + } + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + if let Some(peer) = state + .peers + .iter() + .find(|peer| peer.identity == resolved.identity_account_id) + { + if peer.root_key != resolved.chat_public_key { + return Err(Error::InvalidStatement); + } + if peer.invitation.is_some() || peer.established { + return if peer.invitation_text.as_deref() == Some(&text) { + Ok(()) + } else { + Err(Error::OperationConflict) + }; + } + } else { + if state.peers.len() >= MAX_PEERS { + return Err(Error::StorageUnavailable); + } + state.peers.push(Peer { + identity: resolved.identity_account_id, + root_key: resolved.chat_public_key, + username: resolved.username, + devices: Vec::new(), + invitation: None, + invitation_text: None, + invitation_timestamp: None, + established: false, + revocation_request: None, + revocation_acked: false, + revocation_acks: Vec::new(), + revision: 0, + }); + } + let peer = state.peer(&resolved.identity_account_id)?.clone(); + let shared = chat_shared_secret(&actor.root_secret, &peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let request_id = random_id()?; + let now = current_unix_secs(); + state.peer_mut(&peer.identity)?.invitation_text = Some(text.clone()); + if !text.is_empty() { + state.record_messages(HostNativeChatMessages { + peer_identity: peer.identity, + incoming: false, + request_id: request_id.clone(), + messages: vec![ + wire::encode_rich_text_message( + &request_id, + now.saturating_mul(1000), + Some(&text), + None, + ) + .map_err(|_| Error::InvalidRequest)?, + ], + }); + } + let message = wire::V2ChatRequestMessageV2 { + message_id: request_id.clone(), + timestamp: now.saturating_mul(1000), + content: wire::V2ChatRequestContentV2 { + identity_proof: wire::V2ChatRequestIdentityProof { + identity_account_id: actor.public.identity_account_id, + proof: chat_device_identity_proof( + &shared, + &actor.public.identity_account_id, + &actor.public.account_id, + ), + }, + device_enc_pub_key: actor.public.chat_public_key, + push_token: None, + welcome_text: (!text.is_empty()).then_some(text), + }, + }; + let payload = wire::encode_chat_request_v2_proof_payload(&message, &peer.identity) + .map_err(|_| Error::InvalidRequest)?; + let signature = actor + .signer + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &actor.signer.public) + .to_bytes(); + let request = wire::V2ChatRequestV2 { + message, + proof: wire::V2ChatRequestProof { + signature: signature.to_vec(), + signer: actor.public.account_id.to_vec(), + }, + }; + let ephemeral = Zeroizing::new(random_bytes()?); + let data = wire::seal_chat_request_v2_with_nonce( + &ephemeral, + &peer.root_key, + &request, + random_bytes()?, + ) + .map_err(|_| Error::InvalidRequest)?; + let channel = chat_request_channel_id( + &shared, + &actor.public.identity_account_id, + &peer.identity, + ); + let day = wire::chat_request_day_from_unix(now).ok_or(Error::InvalidRequest)?; + let statement = actor.sign( + state, + channel, + vec![ + wire::chat_request_full_topic(&peer.identity), + wire::chat_request_day_topic(&peer.identity, day), + ], + data, + )?; + state.peer_mut(&peer.identity)?.invitation = Some(request_id.clone()); + state.peer_mut(&peer.identity)?.invitation_timestamp = + Some(now.saturating_mul(1000)); + state.queue(Outgoing { + peer: peer.identity, + request_id, + digest: hash(&payload), + kind: OutgoingKind::Invitation, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) + }) + .await?; + self.start_delivery(context); + self.flush(context).await + } + + pub(super) async fn reject( + &self, + context: &NativeChatContext, + invitation_id: [u8; 32], + ) -> Result<(), Error> { + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let position = state + .invitations + .iter() + .position(|invite| invite.id == invitation_id) + .ok_or(Error::InvalidRequest)?; + state.invitations.remove(position); + Ok(()) + }) + .await + } + + pub(super) async fn send( + self: &Arc, + context: &NativeChatContext, + peer_identity: [u8; 32], + request_id: String, + messages: Vec>, + ) -> Result<(), Error> { + valid_id(&request_id)?; + validate_guest_messages(&messages).map_err(|_| Error::InvalidRequest)?; + let digest = hash(&messages.encode()); + let wire_request_id = format!( + "msg-{}", + hex::encode(hash( + &( + b"truapi/native-chat/ordinary/v1".as_slice(), + context.genesis_hash, + self.public.account_id, + &self.product, + peer_identity, + &request_id, + ) + .encode() + )) + ); + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + if let Some(old) = state + .sent + .iter() + .find(|old| old.peer == peer_identity && old.request_id == request_id) + { + return if old.digest == digest { + Ok(()) + } else { + Err(Error::OperationConflict) + }; + } + if state.sent.len() >= MAX_RECEIPTS { + return Err(Error::StorageUnavailable); + } + let peer = state.peer(&peer_identity)?.clone(); + let devices = peer.active_devices(); + if !peer.established || devices.is_empty() { + return Err(Error::PeerNotReady); + } + let statement = + actor.multi_statement(state, &peer, &devices, &wire_request_id, &messages)?; + state.sent.push(SentReceipt { + peer: peer_identity, + request_id, + wire_request_id: wire_request_id.clone(), + digest, + }); + state.queue(Outgoing { + peer: peer_identity, + request_id: wire_request_id, + digest, + kind: OutgoingKind::Ordinary, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) + }) + .await?; + self.start_delivery(context); + self.flush(context).await + } + + pub(super) async fn payment( + self: &Arc, + context: &NativeChatContext, + peer_identity: [u8; 32], + request_id: String, + amount_cents: u64, + ) -> Result<(PaymentIntent, Arc), Error> { + valid_id(&request_id)?; + if amount_cents == 0 { + return Err(Error::InvalidRequest); + } + let peer = self + .store + .read(|state| state.peer(&peer_identity).cloned()) + .await??; + if !peer.ready_for_payments() { + return Err(Error::PeerNotReady); + } + // Resolve the authoritative recipient again for the trusted review. + let resolved = identity::resolve_account(context, peer_identity).await?; + if resolved.chat_public_key != peer.root_key { + return Err(Error::InvalidStatement); + } + let intent = PaymentIntent { + product_id: self.product.clone(), + peer_identity, + recipient_username: resolved.username, + request_id, + amount_cents, + }; + let transport = Arc::new(ChatPaymentTransport { + actor: self.clone(), + context: context.clone(), + peer_identity, + }); + Ok((intent, transport)) + } + + fn multi_statement( + &self, + state: &mut State, + peer: &Peer, + devices: &[PeerDevice], + request_id: &str, + messages: &[Vec], + ) -> Result { + let plaintext = Zeroizing::new( + wire::encode_transport_request_plaintext(request_id, messages) + .map_err(|_| Error::InvalidRequest)?, + ); + let inner = Zeroizing::new( + self.device + .seal_multi_device(devices, &plaintext) + .map_err(|_| Error::InvalidStatement)?, + ); + let shared = self + .device + .identity_shared_secret(&peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let session = chat_identity_session_id(&shared, &self.public.account_id, &peer.identity); + let channel = + wire::chat_identity_request_topic(&session).map_err(|_| Error::InvalidStatement)?; + let encrypted = native_root_seal(&shared, &inner).map_err(|_| Error::InvalidStatement)?; + self.sign(state, channel, vec![session], encrypted) + } + + fn sign( + &self, + state: &mut State, + channel: [u8; 32], + topics: Vec<[u8; 32]>, + data: Vec, + ) -> Result { + let candidate = current_unix_secs() + .checked_add(LIFETIME) + .and_then(|value| value.checked_mul(1u64 << 32)) + .ok_or(Error::InvalidRequest)?; + let expiry = candidate.max( + state + .last_expiry + .checked_add(1) + .ok_or(Error::InvalidRequest)?, + ); + state.last_expiry = expiry; + let fields = statement_fields_from_v01(Statement { + proof: None, + decryption_key: None, + expiry: Some(expiry), + channel: Some(channel), + topics, + data: Some(data), + }) + .map_err(|_| Error::InvalidRequest)?; + let secret = Zeroizing::new(self.signer.secret.to_bytes()); + let fields = sign_statement_fields(*secret, self.public.account_id, fields) + .map_err(|_| Error::InvalidRequest)?; + decode_signed_statement(&fields.encode()).map_err(|_| Error::InvalidRequest) + } + + pub(super) async fn flush(&self, context: &NativeChatContext) -> Result<(), Error> { + let _delivery = self.delivery_gate.lock().await; + context.require_current()?; + let queued = self.store.read(|state| state.outbox.clone()).await?; + let mut failure = None; + for outgoing in queued { + match self.flush_outgoing(context, outgoing).await { + Ok(()) => {} + Err(error @ (Error::NetworkUnavailable | Error::AllowanceRequired)) => { + // One offline/full route must not starve another peer's + // acceptance, revocation, or acknowledgment. + failure.get_or_insert(error); + } + Err(error) => return Err(error), + } + } + failure.map_or(Ok(()), Err) + } + + async fn refresh_statement( + &self, + peer: [u8; 32], + request_id: &str, + kind: &OutgoingKind, + minimum_expiry: u64, + ) -> Result, Error> { + let key = (peer, request_id.to_owned(), kind.clone()); + let signer_secret = Zeroizing::new(self.signer.secret.to_bytes()); + let signer_public = self.public.account_id; + self.store + .update(move |state| { + let Some(position) = state.outbox.iter().position(|entry| { + entry.peer == key.0 && entry.request_id == key.1 && entry.kind == key.2 + }) else { + // An authenticated ACK may have retired it while the RPC + // was in flight. Never resurrect the acknowledged request. + return Ok(None); + }; + let old = &state.outbox[position].statement; + let expiry = current_unix_secs() + .checked_add(LIFETIME) + .and_then(|value| value.checked_mul(1u64 << 32)) + .ok_or(Error::InvalidRequest)? + .max( + state + .last_expiry + .checked_add(1) + .ok_or(Error::InvalidRequest)?, + ) + .max(minimum_expiry); + // Only the signed priority changes. Keep the committed + // ciphertext, routing, signer, request ID, and payment intact. + let fields = statement_fields_from_v01(Statement { + proof: None, + decryption_key: None, + expiry: Some(expiry), + channel: old.channel, + topics: old.topics.clone(), + data: old.data.clone(), + }) + .map_err(|_| Error::InvalidRequest)?; + let signed = decode_signed_statement( + &sign_statement_fields(*signer_secret, signer_public, fields) + .map_err(|_| Error::InvalidRequest)? + .encode(), + ) + .map_err(|_| Error::InvalidRequest)?; + state.last_expiry = expiry; + state.outbox[position].statement = signed.clone(); + Ok(Some(signed)) + }) + .await + } + + async fn flush_outgoing( + &self, + context: &NativeChatContext, + mut outgoing: Outgoing, + ) -> Result<(), Error> { + context.require_current()?; + let allowed = self + .store + .read(|state| { + state.peer(&outgoing.peer).is_ok_and(|peer| { + matches!( + outgoing.kind, + OutgoingKind::Invitation + | OutgoingKind::Acceptance + | OutgoingKind::Acknowledgment + ) || (peer.established + && peer.revision == outgoing.roster_revision + && !peer.active_devices().is_empty()) + }) + }) + .await?; + let now = current_unix_secs(); + if !allowed || (outgoing.last_attempt != 0 && now < outgoing.last_attempt.saturating_add(5)) + { + return Ok(()); + } + if outgoing + .statement + .expiry + .is_none_or(|expiry| (expiry >> 32) <= now) + { + let Some(statement) = self + .refresh_statement(outgoing.peer, &outgoing.request_id, &outgoing.kind, 0) + .await? + else { + return Ok(()); + }; + outgoing.statement = statement; + } + let rpc = context + .services + .statement_store + .client("native_chat.delivery") + .await + .map_err(|_| Error::NetworkUnavailable)?; + // A definite priority rejection permits one expiry-only retry. An + // ambiguous network failure does not authorize replacing the statement. + for attempt in 0..2 { + context.require_current()?; + let bytes = + signed_statement_to_scale(outgoing.statement).map_err(|_| Error::InvalidRequest)?; + match crate::runtime::statement_store_rpc::submit(&rpc, bytes).await { + Ok(()) => break, + Err(error) => { + if attempt == 0 + && let Some(expiry) = error.replacement_expiry() + { + context.require_current()?; + let Some(statement) = self + .refresh_statement( + outgoing.peer, + &outgoing.request_id, + &outgoing.kind, + expiry, + ) + .await? + else { + return Ok(()); + }; + outgoing.statement = statement; + continue; + } + return Err(if error.is_no_allowance() { + Error::AllowanceRequired + } else { + Error::NetworkUnavailable + }); + } + } + } + self.store + .update(move |state| { + if outgoing.kind == OutgoingKind::Acknowledgment { + state.outbox.retain(|entry| { + !(entry.peer == outgoing.peer + && entry.request_id == outgoing.request_id + && entry.kind == OutgoingKind::Acknowledgment) + }); + } else if let Some(entry) = state.outbox.iter_mut().find(|entry| { + entry.peer == outgoing.peer + && entry.request_id == outgoing.request_id + && entry.kind == outgoing.kind + }) { + entry.last_attempt = now; + } + Ok(()) + }) + .await + } + + fn start_delivery(self: &Arc, context: &NativeChatContext) { + if self + .delivering + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let actor = self.clone(); + let context = context.clone(); + let spawner = context.services.spawner.clone(); + spawner(Box::pin(async move { + struct Running(Arc); + impl Drop for Running { + fn drop(&mut self) { + self.0.delivering.store(false, Ordering::Release); + } + } + let _running = Running(actor.clone()); + while (context.session_valid)() { + if super::background::require_authorized(&context, &actor.product) + .await + .is_err() + { + break; + } + if actor + .store + .read(|state| { + state.outbox.is_empty() + && !history::has_pending(&state.history_imports) + && !files::has_pending(state) + }) + .await + .unwrap_or(true) + { + break; + } + let (_, _, files) = futures::join!( + actor.flush(&context), + actor.acknowledge_history(&context), + actor.drive_files(&context) + ); + let delay = if matches!(files, Ok(true)) { 1 } else { 5000 }; + futures_timer::Delay::new(std::time::Duration::from_millis(delay)).await; + } + })); + } + + pub(super) async fn reconcile( + self: &Arc, + context: &NativeChatContext, + registry: &NativeChatRegistry, + ) -> Result<(), Error> { + context.require_current()?; + self.store.reauthenticate().await?; + let wallet = registry.wallet(context).await?; + self.replay_payment_acknowledgments(context, registry) + .await?; + let accepted = self + .store + .read(|state| state.accepted_payments.clone()) + .await?; + for payment in wallet + .pending_handoffs(context, &self.product, &accepted) + .await? + { + let transport = Arc::new(ChatPaymentTransport { + actor: self.clone(), + context: context.clone(), + peer_identity: payment.peer_identity, + }); + wallet + .redeliver(context, &self.product, payment.operation_id, transport) + .await?; + } + self.start_delivery(context); + let (delivery, settlement, history) = futures::join!( + self.flush(context), + wallet.reconcile(context), + self.acknowledge_history(context) + ); + delivery?; + settlement?; + history + } +} + +struct ChatPaymentTransport { + actor: Arc, + context: NativeChatContext, + peer_identity: [u8; 32], +} + +#[async_trait::async_trait] +impl PaymentTransport for ChatPaymentTransport { + async fn accept( + &self, + payment: &HostNativeChatPayment, + memo: truapi_coinage::TransferMemo, + ) -> Result<(), ()> { + // Acceptance is irreversible. Rewrapping changes only the encrypted + // delivery to an authenticated roster, never the payment or reservation. + let accepted = match self + .actor + .store + .read(|state| state.accepted_payments.contains(&payment.operation_id)) + .await + { + Ok(accepted) => accepted, + Err(_) => return Ok(()), // unknown durable state: retain reservation + }; + let rejected = || if accepted { Ok(()) } else { Err(()) }; + if !(self.context.session_valid)() || payment.peer_identity != self.peer_identity { + return rejected(); + } + let peer = match self + .actor + .store + .read(|state| state.peer(&self.peer_identity).cloned()) + .await + { + Ok(Ok(peer)) if peer.ready_for_payments() => peer, + _ => return rejected(), + }; + let keys = Zeroizing::new( + memo.entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect::>(), + ); + let raw = match wire::encode_coinage_send_message( + &payment.message_id, + payment.timestamp, + &memo.total_value.to_string(), + &keys, + ) { + Ok(raw) => raw, + Err(_) => return rejected(), + }; + let messages = Zeroizing::new(vec![raw]); + let payment_id = payment.operation_id; + let request_id = format!("pay-{}", hex::encode(payment_id)); + let encoded = Zeroizing::new(messages.encode()); + let digest = hash(&encoded); + if accepted { + let unchanged = self + .actor + .store + .read(|state| { + state + .outbox + .iter() + .find(|entry| entry.kind == OutgoingKind::Payment(payment_id)) + .is_none_or(|entry| { + entry.peer == peer.identity + && entry.request_id == request_id + && entry.digest == digest + && entry.roster_revision == peer.revision + }) + }) + .await + .unwrap_or(true); + if unchanged { + self.actor.start_delivery(&self.context); + return Ok(()); + } + } + let actor = self.actor.clone(); + let valid = self.context.session_valid.clone(); + let write_attempted = Arc::new(AtomicBool::new(accepted)); + let attempted = write_attempted.clone(); + let outcome = self + .actor + .store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let current = state.peer(&peer.identity)?.clone(); + if !current.ready_for_payments() || current.revision != peer.revision { + return Err(Error::PeerNotReady); + } + if state.accepted_payments.contains(&payment_id) { + // Absence means its peer ACK was already durably recorded. + let Some(position) = state + .outbox + .iter() + .position(|entry| entry.kind == OutgoingKind::Payment(payment_id)) + else { + return Ok(()); + }; + let previous = &state.outbox[position]; + if previous.peer != peer.identity + || previous.request_id != request_id + || previous.digest != digest + { + return Err(Error::OperationConflict); + } + if previous.roster_revision == current.revision { + return Ok(()); + } + let statement = actor.multi_statement( + state, + ¤t, + ¤t.payment_devices().collect::>(), + &request_id, + &messages, + )?; + let outgoing = &mut state.outbox[position]; + outgoing.statement = statement; + outgoing.roster_revision = current.revision; + outgoing.last_attempt = 0; + } else { + if state.accepted_payments.len() >= MAX_RECEIPTS { + return Err(Error::StorageUnavailable); + } + let statement = actor.multi_statement( + state, + ¤t, + ¤t.payment_devices().collect::>(), + &request_id, + &messages, + )?; + state.queue(Outgoing { + peer: peer.identity, + request_id, + digest, + kind: OutgoingKind::Payment(payment_id), + roster_revision: current.revision, + statement, + last_attempt: 0, + })?; + state.accepted_payments.push(payment_id); + } + attempted.store(true, Ordering::Release); + Ok(()) + }) + .await; + if outcome.is_err() && !write_attempted.load(Ordering::Acquire) { + return Err(()); + } + self.actor.start_delivery(&self.context); + Ok(()) + } +} + +fn random_bytes() -> Result<[u8; N], Error> { + let mut bytes = [0; N]; + getrandom::getrandom(&mut bytes).map_err(|_| Error::StorageUnavailable)?; + Ok(bytes) +} +fn random_id() -> Result { + Ok(hex::encode(random_bytes::<16>()?)) +} +fn hash(bytes: &[u8]) -> [u8; 32] { + sp_crypto_hashing::blake2_256(bytes) +} +fn valid_id(value: &str) -> Result<(), Error> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + Err(Error::InvalidRequest) + } else { + Ok(()) + } +} +fn fresh(timestamp_ms: u64, now: u64) -> bool { + let timestamp = timestamp_ms / 1000; + timestamp >= wire::PROTOCOL_EPOCH_SECONDS + && timestamp <= now.saturating_add(CLOCK_SKEW) + && now <= timestamp.saturating_add(LIFETIME) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/files.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/files.rs new file mode 100644 index 000000000..4fe6a17a3 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/files.rs @@ -0,0 +1,653 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Private file capabilities, immutable send intents and safe rich projections. + +mod transfer; + +use super::*; +use crate::runtime::chat_device::{MAX_ATTACHMENTS, RichContent, validate_metadata}; +use crate::runtime::native_chat::{ + background::require_authorized, + hop::{self, FileTicket}, +}; +use truapi_platform::{NativeChatFileExportRequest, NativeChatFilePickRequest}; + +const MAX_FILES: usize = 4096; +const MAX_RICH_MESSAGES: usize = 4096; +const MAX_PICKED_FILES: u32 = 16; + +#[derive(Clone, Encode, Decode)] +pub(super) enum PreparedKind { + Inline, + Chunk { index: u32, byte_len: u32 }, + Root, +} + +#[derive(Clone, Encode, Decode)] +pub(super) struct PreparedEntry { + kind: PreparedKind, + slot: u32, + hash: [u8; 32], +} + +#[derive(Clone, Encode, Decode)] +pub(super) struct FileRecord { + id: [u8; 32], + cache_id: [u8; 32], + peer: [u8; 32], + incoming: bool, + metadata: HostNativeChatAttachmentMetadata, + ticket: Secret32, + endpoint: String, + root: Option<[u8; 32]>, + source: Option, + upload: Vec, + prepared: Option, + descriptor: Option>, + download: Vec, + cache_chunks: u32, + cache_bytes: u64, + pending_ack: Option>, + ready: bool, + recovering: bool, +} + +impl FileRecord { + fn pending(&self) -> bool { + !self.ready || self.pending_ack.is_some() || self.source.is_some() + } + + fn view(&self) -> Result { + let state = if self.ready { + HostNativeChatAttachmentState::Ready + } else if self.recovering { + HostNativeChatAttachmentState::Recovering + } else if self.incoming { + HostNativeChatAttachmentState::Downloading { + downloaded_bytes: self.cache_bytes as u32, + } + } else { + let uploaded_bytes = if self.upload.is_empty() { + 0 + } else { + hop::UploadProgress::restore(&self.upload) + .map(|progress| progress.uploaded_size() as u32) + .map_err(|_| Error::StorageUnavailable)? + }; + HostNativeChatAttachmentState::Uploading { uploaded_bytes } + }; + Ok(HostNativeChatAttachment { + attachment_id: self.id, + metadata: self.metadata.clone(), + state, + }) + } +} + +#[derive(Clone, Encode, Decode)] +pub(super) struct RichRecord { + key: [u8; 32], + peer: [u8; 32], + incoming: bool, + client_request_id: Option, + request_id: String, + message_id: String, + timestamp: u64, + kind: HostNativeChatRichMessageKind, + text: Option, + files: Vec<[u8; 32]>, + digest: [u8; 32], + selecting: bool, + published: bool, +} + +pub(super) struct IncomingRich { + messages: Vec, + files: Vec, +} + +pub(super) fn has_pending(state: &State) -> bool { + state.files.iter().any(FileRecord::pending) + || state + .rich_messages + .iter() + .any(|message| !message.incoming && !message.selecting && !message.published) +} + +pub(super) fn validate(state: &State) -> Result<(), Error> { + if state.files.len() > MAX_FILES || state.rich_messages.len() > MAX_RICH_MESSAGES { + return Err(Error::StorageUnavailable); + } + let mut ids = std::collections::BTreeSet::new(); + for file in &state.files { + if file.id == [0; 32] + || !ids.insert(file.id) + || file.cache_bytes > u64::from(file.metadata.size_bytes) + || (file.ready + && (file.root.is_none() || file.cache_bytes != u64::from(file.metadata.size_bytes))) + || (file.incoming && (file.source.is_some() || file.root.is_none())) + { + return Err(Error::StorageUnavailable); + } + validate_metadata(&file.metadata).map_err(|_| Error::StorageUnavailable)?; + if !file.endpoint.starts_with("wss://") || file.endpoint.len() > 4096 { + return Err(Error::StorageUnavailable); + } + if !file.upload.is_empty() { + let progress = hop::UploadProgress::restore(&file.upload) + .map_err(|_| Error::StorageUnavailable)?; + if progress.total_size() != file.metadata.size_bytes { + return Err(Error::StorageUnavailable); + } + } + if let Some(bytes) = &file.descriptor { + let root = + hop::RootDescriptor::restore(bytes).map_err(|_| Error::StorageUnavailable)?; + if Some(root.entry_hash()) != file.root + || root.total_size() != u64::from(file.metadata.size_bytes) + { + return Err(Error::StorageUnavailable); + } + if let hop::RootDescriptor::Chunked { .. } = root { + let progress = hop::DownloadProgress::restore(&file.download, &root) + .map_err(|_| Error::StorageUnavailable)?; + if progress.next_chunk != file.cache_chunks + || progress.downloaded_bytes != file.cache_bytes + { + return Err(Error::StorageUnavailable); + } + } + } + if let Some(ack) = &file.pending_ack { + hop::PendingAck::restore(ack).map_err(|_| Error::StorageUnavailable)?; + } + } + let mut messages = std::collections::BTreeSet::new(); + for message in &state.rich_messages { + if !messages.insert(message.key) + || message.files.len() > MAX_ATTACHMENTS + || message.files.iter().any(|id| !ids.contains(id)) + || (!message.selecting && message.files.is_empty()) + { + return Err(Error::StorageUnavailable); + } + let distinct: std::collections::BTreeSet<_> = message.files.iter().collect(); + if distinct.len() != message.files.len() { + return Err(Error::StorageUnavailable); + } + } + Ok(()) +} + +pub(super) fn public_views(state: &State) -> Result, Error> { + state + .rich_messages + .iter() + .filter(|message| !message.selecting) + .map(|message| { + let attachments = message + .files + .iter() + .map(|id| { + state + .files + .iter() + .find(|file| &file.id == id) + .ok_or(Error::StorageUnavailable)? + .view() + }) + .collect::, _>>()?; + Ok(HostNativeChatRichMessage { + peer_identity: message.peer, + incoming: message.incoming, + request_id: message.request_id.clone(), + message_id: message.message_id.clone(), + timestamp: message.timestamp, + kind: message.kind.clone(), + text: message.text.clone(), + attachments, + }) + }) + .collect() +} + +impl NativeChatActor { + pub(super) async fn prepare_rich( + &self, + context: &NativeChatContext, + peer: [u8; 32], + request_id: &str, + messages: Vec, + ) -> Result { + let mut prepared = IncomingRich { + messages: Vec::new(), + files: Vec::new(), + }; + if messages.is_empty() { + return Ok(prepared); + } + require_authorized(context, &self.product).await?; + let allowed = context + .services + .platform + .allowed_hop_endpoints(context.services.bulletin.genesis_hash()) + .await + .map_err(|_| Error::NetworkUnavailable)?; + context.require_current()?; + for message in messages { + let key = hash(&(peer, true, &message.message_id).encode()); + let mut file_ids = Vec::with_capacity(message.files.len()); + for reference in message.files { + truapi_platform::ensure_allowed_hop_endpoint(&reference.endpoint, &allowed) + .map_err(|_| Error::InvalidStatement)?; + let binding = Zeroizing::new( + ( + self.public.identity_account_id, + peer, + reference.identifier, + &*reference.ticket, + &reference.endpoint, + &reference.metadata, + ) + .encode(), + ); + let id = hash(&binding); + if id == [0; 32] || file_ids.contains(&id) { + return Err(Error::InvalidStatement); + } + file_ids.push(id); + prepared.files.push(FileRecord { + id, + cache_id: id, + peer, + incoming: true, + metadata: reference.metadata, + ticket: Secret32(*reference.ticket), + endpoint: reference.endpoint, + root: Some(reference.identifier), + source: None, + upload: Vec::new(), + prepared: None, + descriptor: None, + download: Vec::new(), + cache_chunks: 0, + cache_bytes: 0, + pending_ack: None, + ready: false, + recovering: false, + }); + } + prepared.messages.push(RichRecord { + key, + peer, + incoming: true, + client_request_id: None, + request_id: request_id.to_owned(), + message_id: message.message_id, + timestamp: message.timestamp, + kind: message.kind, + text: message.text, + files: file_ids, + digest: message.digest, + selecting: false, + published: true, + }); + } + Ok(prepared) + } + + pub(in crate::runtime::native_chat) async fn send_attachments( + self: &Arc, + context: &NativeChatContext, + peer: [u8; 32], + request_id: String, + text: Option, + ) -> Result<(), Error> { + let _selection = self.file_selection_gate.lock().await; + require_authorized(context, &self.product).await?; + if request_id.is_empty() + || request_id.len() > 128 + || text.as_ref().is_some_and(|text| text.len() > 8192) + { + return Err(Error::InvalidRequest); + } + let key = hash(&(self.public.account_id, &self.product, &request_id).encode()); + let existing = self + .store + .read(|state| { + state + .rich_messages + .iter() + .find(|message| message.key == key) + .cloned() + }) + .await?; + if let Some(existing) = &existing { + if existing.incoming + || existing.peer != peer + || existing.text != text + || existing.client_request_id.as_ref() != Some(&request_id) + { + return Err(Error::OperationConflict); + } + if !existing.selecting { + self.start_delivery(context); + return Ok(()); + } + } + let recipient = self + .store + .read(|state| state.peer(&peer).cloned()) + .await??; + if !recipient.ready() { + return Err(Error::PeerNotReady); + } + if existing.is_none() { + let valid = context.session_valid.clone(); + let text = text.clone(); + let request_id = request_id.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + if state.rich_messages.len() >= MAX_RICH_MESSAGES { + return Err(Error::StorageUnavailable); + } + state.rich_messages.push(RichRecord { + key, + peer, + incoming: false, + client_request_id: Some(request_id), + request_id: format!("file-{}", hex::encode(key)), + message_id: format!("attachment-{}", hex::encode(key)), + timestamp: current_unix_secs().saturating_mul(1000), + kind: HostNativeChatRichMessageKind::Message, + text, + files: Vec::new(), + digest: key, + selecting: true, + published: false, + }); + Ok(()) + }) + .await?; + } + let allowed = context + .services + .platform + .allowed_hop_endpoints(context.services.bulletin.genesis_hash()) + .await + .map_err(|_| Error::NetworkUnavailable)?; + let endpoint = allowed + .iter() + .find(|endpoint| { + truapi_platform::ensure_allowed_hop_endpoint(endpoint, &allowed).is_ok() + }) + .cloned() + .ok_or(Error::AttachmentsUnavailable)?; + require_authorized(context, &self.product).await?; + let selected = context + .services + .platform + .pick_chat_files(NativeChatFilePickRequest { + product_id: self.product.clone(), + peer_identity: peer, + peer_username: recipient.username, + max_files: MAX_PICKED_FILES, + }) + .await + .map_err(|_| Error::AttachmentsUnavailable)?; + let valid_selection = context.require_current().and_then(|_| { + if selected.is_empty() { + return Err(Error::UserRejected); + } + if selected.len() > MAX_PICKED_FILES as usize { + return Err(Error::InvalidRequest); + } + let mut sources = std::collections::BTreeSet::new(); + for file in &selected { + if !sources.insert(&file.source_id) { + return Err(Error::InvalidRequest); + } + if file.source_id.is_empty() + || file.source_id.len() > 1024 + || file.source_id.chars().any(char::is_control) + { + return Err(Error::InvalidRequest); + } + validate_metadata(&file.metadata).map_err(|_| Error::InvalidRequest)?; + } + Ok(()) + }); + if let Err(error) = valid_selection { + for file in selected { + let _ = context + .services + .platform + .release_chat_file(file.source_id) + .await; + } + return Err(error); + } + let prepared = selected + .iter() + .map(|selected| { + let id = random_bytes()?; + if id == [0; 32] { + return Err(Error::StorageUnavailable); + } + let ticket = FileTicket::generate().map_err(|_| Error::StorageUnavailable)?; + let upload = if selected.metadata.size_bytes as usize > hop::HOP_INLINE_MAX_BYTES { + hop::UploadProgress::new(selected.metadata.size_bytes) + .map_err(|_| Error::InvalidRequest)? + .encode() + } else { + Vec::new() + }; + Ok(FileRecord { + id, + cache_id: id, + peer, + incoming: false, + metadata: selected.metadata.clone(), + ticket: Secret32(*ticket.as_bytes()), + endpoint: endpoint.clone(), + root: None, + source: Some(selected.source_id.clone()), + upload, + prepared: None, + descriptor: None, + download: Vec::new(), + cache_chunks: 0, + cache_bytes: 0, + pending_ack: None, + ready: false, + recovering: false, + }) + }) + .collect::, Error>>(); + let files = match prepared { + Ok(files) => files, + Err(error) => { + for file in selected { + let _ = context + .services + .platform + .release_chat_file(file.source_id) + .await; + } + return Err(error); + } + }; + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + if state.files.len() + files.len() > MAX_FILES { + return Err(Error::StorageUnavailable); + } + let record = state + .rich_messages + .iter_mut() + .find(|record| record.key == key) + .ok_or(Error::OperationNotFound)?; + if !record.selecting { + return Err(Error::OperationConflict); + } + record.files = files.iter().map(|file| file.id).collect(); + record.selecting = false; + state.files.extend(files); + Ok(()) + }) + .await?; + self.start_delivery(context); + Ok(()) + } + + pub(in crate::runtime::native_chat) async fn open_attachment( + self: &Arc, + context: &NativeChatContext, + id: [u8; 32], + ) -> Result<(), Error> { + let _export = self.file_export_gate.lock().await; + require_authorized(context, &self.product).await?; + let file = self.file(id).await?; + let username = self + .store + .read(|state| { + state + .peer(&file.peer) + .ok() + .and_then(|peer| peer.username.clone()) + }) + .await?; + let handle = context + .services + .platform + .begin_chat_file_export(NativeChatFileExportRequest { + product_id: self.product.clone(), + peer_identity: file.peer, + peer_username: username, + metadata: file.metadata.clone(), + }) + .await + .map_err(|_| Error::AttachmentsUnavailable)? + .ok_or(Error::UserRejected)?; + let result = async { + loop { + require_authorized(context, &self.product).await?; + let current = self.file(id).await?; + if current.ready || !current.incoming { + break; + } + self.advance_file(context, id).await?; + } + // Keep source release and chunk progression from racing an export. + let _transfer = self.file_transfer_gate.lock().await; + let current = self.file(id).await?; + let mut offset = 0u64; + let mut index = 0u32; + while offset < u64::from(current.metadata.size_bytes) { + require_authorized(context, &self.product).await?; + let mut bytes = if current.ready || index < current.cache_chunks { + self.read_chunk(context, current.cache_id, index).await? + } else { + self.read_source( + context, + ¤t, + offset, + (u64::from(current.metadata.size_bytes) - offset) + .min(hop::HOP_CHUNK_BYTES as u64) as u32, + ) + .await? + }; + if bytes.is_empty() + || bytes.len() > hop::HOP_CHUNK_BYTES + || offset + bytes.len() as u64 > u64::from(current.metadata.size_bytes) + { + return Err(Error::StorageUnavailable); + } + require_authorized(context, &self.product).await?; + let length = bytes.len() as u64; + context + .services + .platform + .write_chat_file_export(handle.clone(), offset, core::mem::take(&mut *bytes)) + .await + .map_err(|_| Error::AttachmentsUnavailable)?; + context.require_current()?; + offset += length; + index += 1; + } + require_authorized(context, &self.product).await?; + context + .services + .platform + .finish_chat_file_export(handle.clone()) + .await + .map_err(|_| Error::AttachmentsUnavailable)?; + context.require_current() + } + .await; + if result.is_err() { + let _ = context + .services + .platform + .cancel_chat_file_export(handle) + .await; + } + result + } + + async fn file(&self, id: [u8; 32]) -> Result { + self.store + .read(|state| { + state + .files + .iter() + .find(|file| file.id == id) + .cloned() + .ok_or(Error::OperationNotFound) + }) + .await? + } +} + +pub(super) fn merge_received(state: &mut State, prepared: IncomingRich) -> Result<(), Error> { + for file in prepared.files { + if let Some(existing) = state.files.iter().find(|existing| existing.id == file.id) { + if existing.peer != file.peer + || existing.metadata != file.metadata + || existing.root != file.root + || existing.ticket.0 != file.ticket.0 + || existing.endpoint != file.endpoint + { + return Err(Error::InvalidStatement); + } + } else { + if state.files.len() >= MAX_FILES { + return Err(Error::StorageUnavailable); + } + state.files.push(file); + } + } + for message in prepared.messages { + if let Some(existing) = state + .rich_messages + .iter() + .find(|existing| existing.key == message.key) + { + if existing.digest != message.digest { + return Err(Error::InvalidStatement); + } + } else { + if state.rich_messages.len() >= MAX_RICH_MESSAGES { + return Err(Error::StorageUnavailable); + } + state.rich_messages.push(message); + } + } + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/files/transfer.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/files/transfer.rs new file mode 100644 index 000000000..d6a153dc3 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/files/transfer.rs @@ -0,0 +1,679 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! One bounded, fenced transfer step; durable bytes and progress always precede ACK. + +use super::*; +use crate::runtime::native_chat::{ + background::require_upload_authorized, + hop::{ + DownloadProgress, HopClient, HopError, PreparedUpload, RootDescriptor, + SenderProofProviding, UploadProgress, + }, + hop_access::SessionHopRpc, +}; +use std::sync::atomic::Ordering; + +const PREPARED_CHUNK: u32 = 1 << 31; +const PREPARED_INLINE: u32 = u32::MAX; +const PREPARED_ROOT: u32 = u32::MAX - 1; +const MAX_BLOB_BYTES: usize = hop::HOP_CHUNK_BYTES + 128; + +#[derive(Clone, Encode, Decode)] +struct PrivateBlob(Vec); +impl Drop for PrivateBlob { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +struct UploadSigner<'a> { + context: &'a NativeChatContext, + product: &'a str, +} +#[async_trait::async_trait] +impl SenderProofProviding for UploadSigner<'_> { + async fn proof(&self, data_hash: &[u8; 32]) -> Result { + require_upload_authorized(self.context, self.product) + .await + .map_err(|_| HopError::Transport("Chat upload authorization ended".into()))?; + let signer = derive_sr25519_hard_path( + &self.context.entropy, + &["allowance", "bulletin", self.product], + ) + .map_err(|_| HopError::Crypto)?; + let submit_timestamp = current_unix_secs() + .checked_mul(1000) + .ok_or(HopError::InvalidProgress)?; + let payload = hop::sender_proof_payload(data_hash, submit_timestamp); + self.context + .require_current() + .map_err(|_| HopError::Transport("Chat session ended".into()))?; + let signature = signer.sign_simple(b"substrate", &payload); + Ok(hop::SenderProof { + sender: hop::MultiSigner::Sr25519(signer.public.to_bytes()), + signature: hop::MultiSignature::Sr25519(signature.to_bytes()), + submit_timestamp, + }) + } +} + +fn remote_error(error: HopError) -> Error { + match error { + HopError::Crypto | HopError::Codec(_) | HopError::Integrity | HopError::InvalidProgress => { + Error::InvalidStatement + } + HopError::Rpc { .. } | HopError::Transport(_) | HopError::NotFound(_) => { + Error::NetworkUnavailable + } + } +} + +impl NativeChatActor { + async fn change_file( + &self, + context: &NativeChatContext, + id: [u8; 32], + change: impl FnOnce(&mut FileRecord) -> Result + Send + 'static, + ) -> Result { + context.require_current()?; + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let file = state + .files + .iter_mut() + .find(|file| file.id == id) + .ok_or(Error::OperationNotFound)?; + change(file) + }) + .await + } + + async fn blob( + &self, + context: &NativeChatContext, + id: [u8; 32], + slot: u32, + initial: impl FnOnce() -> Result, + ) -> Result>, Error> { + ChatStateStore::open_file_chunk(context, &self.product, id, slot, initial).await + } + + async fn write_chunk( + &self, + context: &NativeChatContext, + id: [u8; 32], + index: u32, + mut bytes: Zeroizing>, + ) -> Result<(), Error> { + if index >= PREPARED_CHUNK || bytes.len() > hop::HOP_CHUNK_BYTES { + return Err(Error::StorageUnavailable); + } + context.require_current()?; + let expected = hash(&bytes); + let stored = self + .blob(context, id, index, move || { + Ok(PrivateBlob(core::mem::take(&mut *bytes))) + }) + .await?; + stored + .read(|blob| { + if hash(&blob.0) == expected { + Ok(()) + } else { + Err(Error::OperationConflict) + } + }) + .await??; + context.require_current() + } + + pub(super) async fn read_chunk( + &self, + context: &NativeChatContext, + id: [u8; 32], + index: u32, + ) -> Result>, Error> { + let stored = self + .blob(context, id, index, || Err(Error::StorageUnavailable)) + .await?; + context.require_current()?; + stored + .read(|blob| { + if blob.0.len() > MAX_BLOB_BYTES { + return Err(Error::StorageUnavailable); + } + Ok(Zeroizing::new(blob.0.clone())) + }) + .await? + } + + pub(super) async fn read_source( + &self, + context: &NativeChatContext, + file: &FileRecord, + offset: u64, + length: u32, + ) -> Result>, Error> { + require_authorized(context, &self.product).await?; + if length as usize > hop::HOP_CHUNK_BYTES + || offset + .checked_add(u64::from(length)) + .is_none_or(|end| end > u64::from(file.metadata.size_bytes)) + { + return Err(Error::StorageUnavailable); + } + let source = file.source.as_ref().ok_or(Error::AttachmentsUnavailable)?; + let bytes = Zeroizing::new( + context + .services + .platform + .read_chat_file(source.clone(), offset, length) + .await + .map_err(|_| Error::AttachmentsUnavailable)?, + ); + context.require_current()?; + if bytes.len() != length as usize { + return Err(Error::AttachmentsUnavailable); + } + Ok(bytes) + } + + async fn prepare_entry( + &self, + context: &NativeChatContext, + file: &FileRecord, + kind: PreparedKind, + slot: u32, + create: impl FnOnce() -> Result, + cache_chunks: u32, + cache_bytes: u64, + ) -> Result<(), Error> { + // The immutable slot is created before the actor pointer. If that pointer + // commit is lost, reopening the slot reuses the original nonce and hash. + let stored = self + .blob(context, file.id, slot, || { + create() + .map(|prepared| PrivateBlob(prepared.encode())) + .map_err(remote_error) + }) + .await?; + let hash = stored + .read(|blob| { + PreparedUpload::restore(&blob.0) + .map(|prepared| prepared.hash()) + .map_err(|_| Error::StorageUnavailable) + }) + .await??; + self.change_file(context, file.id, move |file| { + file.prepared = Some(PreparedEntry { kind, slot, hash }); + file.cache_chunks = cache_chunks; + file.cache_bytes = cache_bytes; + file.recovering = false; + Ok(()) + }) + .await + } + + pub(super) async fn advance_file( + &self, + context: &NativeChatContext, + id: [u8; 32], + ) -> Result { + let _transfer = self.file_transfer_gate.lock().await; + require_authorized(context, &self.product).await?; + let file = self.file(id).await?; + let ticket = + FileTicket::from_bytes(&file.ticket.0).map_err(|_| Error::StorageUnavailable)?; + if let Some(bytes) = &file.pending_ack { + let ack = hop::PendingAck::restore(bytes).map_err(|_| Error::StorageUnavailable)?; + let rpc = SessionHopRpc::connect(context, &self.product, &file.endpoint).await?; + HopClient::new(&rpc) + .acknowledge(&ack, &ticket) + .await + .map_err(remote_error)?; + self.change_file(context, id, |file| { + file.pending_ack = None; + Ok(()) + }) + .await?; + return Ok(true); + } + if file.ready { + if let Some(source) = file.source { + context.require_current()?; + context + .services + .platform + .release_chat_file(source) + .await + .map_err(|_| Error::AttachmentsUnavailable)?; + self.change_file(context, id, |file| { + file.source = None; + Ok(()) + }) + .await?; + return Ok(true); + } + return Ok(false); + } + if file.incoming { + // Forwarded/reused references may already have been ACKed elsewhere + // in this actor. Reuse authenticated local bytes, not a deleted pool entry. + let equivalent = self + .store + .read(|state| { + state + .files + .iter() + .find(|other| { + other.id != file.id + && other.ready + && other.root == file.root + && other.ticket.0 == file.ticket.0 + && other.endpoint == file.endpoint + && other.metadata.size_bytes == file.metadata.size_bytes + }) + .cloned() + }) + .await?; + if let Some(other) = equivalent { + self.change_file(context, id, move |file| { + file.cache_id = other.cache_id; + file.cache_chunks = other.cache_chunks; + file.cache_bytes = other.cache_bytes; + file.descriptor = other.descriptor; + file.download = other.download; + file.ready = true; + file.recovering = false; + Ok(()) + }) + .await?; + return Ok(true); + } + let rpc = SessionHopRpc::connect(context, &self.product, &file.endpoint).await?; + let client = HopClient::new(&rpc); + if let Some(bytes) = &file.descriptor { + let root = RootDescriptor::restore(bytes).map_err(|_| Error::StorageUnavailable)?; + let progress = DownloadProgress::restore(&file.download, &root) + .map_err(|_| Error::StorageUnavailable)?; + let chunk = client + .claim_chunk(&root, progress, &ticket) + .await + .map_err(remote_error)? + .ok_or(Error::StorageUnavailable)?; + self.write_chunk(context, file.cache_id, chunk.index, chunk.data) + .await?; + let complete = chunk + .next_progress + .is_complete(&root) + .map_err(|_| Error::StorageUnavailable)?; + self.change_file(context, id, move |file| { + file.cache_chunks = chunk.next_progress.next_chunk; + file.cache_bytes = chunk.next_progress.downloaded_bytes; + file.download = chunk.next_progress.encode(); + file.pending_ack = chunk.pending_ack.map(|ack| ack.encode()); + file.ready = complete; + file.recovering = false; + Ok(()) + }) + .await?; + } else { + let claimed = client + .claim_root( + file.root.ok_or(Error::StorageUnavailable)?, + &ticket, + Some(file.metadata.size_bytes), + ) + .await + .map_err(remote_error)?; + let inline = claimed.inline.is_some(); + if let Some(bytes) = claimed.inline { + self.write_chunk(context, file.cache_id, 0, bytes).await?; + } + self.change_file(context, id, move |file| { + file.descriptor = Some(claimed.descriptor.encode()); + if inline { + file.cache_chunks = 1; + file.cache_bytes = u64::from(file.metadata.size_bytes); + file.ready = true; + } else { + file.download = DownloadProgress::default().encode(); + } + file.pending_ack = claimed.pending_ack.map(|ack| ack.encode()); + file.recovering = false; + Ok(()) + }) + .await?; + } + return Ok(true); + } + require_upload_authorized(context, &self.product).await?; + if !self + .store + .read(|state| state.peer(&file.peer).is_ok_and(Peer::ready)) + .await? + { + return Err(Error::PeerNotReady); + } + if let Some(prepared) = file.prepared { + let encoded = self.read_chunk(context, id, prepared.slot).await?; + let upload = + PreparedUpload::restore(&encoded).map_err(|_| Error::StorageUnavailable)?; + if upload.hash() != prepared.hash { + return Err(Error::StorageUnavailable); + } + let rpc = SessionHopRpc::connect(context, &self.product, &file.endpoint).await?; + HopClient::new(&rpc) + .submit( + &upload, + &UploadSigner { + context, + product: &self.product, + }, + ) + .await + .map_err(remote_error)?; + self.change_file(context, id, move |file| { + match prepared.kind { + PreparedKind::Inline | PreparedKind::Root => { + file.root = Some(prepared.hash); + file.ready = true; + } + PreparedKind::Chunk { index, byte_len } => { + let mut progress = UploadProgress::restore(&file.upload) + .map_err(|_| Error::StorageUnavailable)?; + if progress.next_chunk().is_none_or(|next| next.index != index) { + return Err(Error::StorageUnavailable); + } + progress + .record_chunk(prepared.hash, byte_len as usize) + .map_err(|_| Error::StorageUnavailable)?; + file.upload = progress.encode(); + } + } + file.prepared = None; + file.recovering = false; + Ok(()) + }) + .await?; + return Ok(true); + } + if file.upload.is_empty() { + let bytes = self + .read_source(context, &file, 0, file.metadata.size_bytes) + .await?; + let stored = self + .blob(context, id, PREPARED_INLINE, || { + PreparedUpload::inline(&bytes, &ticket) + .map(|upload| PrivateBlob(upload.encode())) + .map_err(remote_error) + }) + .await?; + self.write_chunk(context, file.cache_id, 0, bytes).await?; + let hash = stored + .read(|blob| { + PreparedUpload::restore(&blob.0) + .map(|upload| upload.hash()) + .map_err(|_| Error::StorageUnavailable) + }) + .await??; + self.change_file(context, id, move |file| { + file.prepared = Some(PreparedEntry { + kind: PreparedKind::Inline, + slot: PREPARED_INLINE, + hash, + }); + file.cache_chunks = 1; + file.cache_bytes = u64::from(file.metadata.size_bytes); + Ok(()) + }) + .await?; + } else { + let progress = + UploadProgress::restore(&file.upload).map_err(|_| Error::StorageUnavailable)?; + if let Some(next) = progress.next_chunk() { + let bytes = self + .read_source(context, &file, next.offset, next.byte_len as u32) + .await?; + let slot = PREPARED_CHUNK | next.index; + let stored = self + .blob(context, id, slot, || { + PreparedUpload::chunk(&bytes, &ticket) + .map(|upload| PrivateBlob(upload.encode())) + .map_err(remote_error) + }) + .await?; + self.write_chunk(context, file.cache_id, next.index, bytes) + .await?; + let hash = stored + .read(|blob| { + PreparedUpload::restore(&blob.0) + .map(|upload| upload.hash()) + .map_err(|_| Error::StorageUnavailable) + }) + .await??; + self.change_file(context, id, move |file| { + file.prepared = Some(PreparedEntry { + kind: PreparedKind::Chunk { + index: next.index, + byte_len: next.byte_len as u32, + }, + slot, + hash, + }); + file.cache_chunks = next.index + 1; + file.cache_bytes = next.offset + next.byte_len as u64; + Ok(()) + }) + .await?; + } else { + self.prepare_entry( + context, + &file, + PreparedKind::Root, + PREPARED_ROOT, + || progress.prepare_root(&ticket), + file.cache_chunks, + file.cache_bytes, + ) + .await?; + } + } + Ok(true) + } + + pub(in crate::runtime::native_chat) async fn drive_files( + self: &Arc, + context: &NativeChatContext, + ) -> Result { + require_authorized(context, &self.product).await?; + let cursor = self.file_cursor.fetch_add(1, Ordering::Relaxed); + let id = self + .store + .read(|state| { + let count = state.files.len(); + (0..count) + .map(|offset| &state.files[(cursor % count + offset) % count]) + .find(|file| file.pending()) + .map(|file| file.id) + }) + .await?; + let mut progressed = false; + if let Some(id) = id { + match self.advance_file(context, id).await { + Ok(changed) => progressed = changed, + Err(error) => { + if context.require_current().is_err() { + return Err(Error::NotConnected); + } + if !self.file(id).await?.recovering { + self.change_file(context, id, |file| { + file.recovering = true; + Ok(()) + }) + .await?; + } + if matches!(error, Error::AccessNotGranted) { + return Err(error); + } + } + } + } + Ok(self.publish_ready_rich(context).await? || progressed) + } + + async fn publish_ready_rich( + self: &Arc, + context: &NativeChatContext, + ) -> Result { + let candidates = self + .store + .read(|state| { + state + .rich_messages + .iter() + .filter(|message| { + !message.incoming + && !message.selecting + && state.peer(&message.peer).is_ok_and(Peer::ready) + && message.files.iter().all(|id| { + state.files.iter().any(|file| &file.id == id && file.ready) + }) + && (!message.published + || state.outbox.iter().any(|outgoing| { + outgoing.kind == OutgoingKind::Rich(message.key) + && state.peer(&message.peer).is_ok_and(|peer| { + peer.revision != outgoing.roster_revision + }) + })) + }) + .map(|message| message.key) + .collect::>() + }) + .await?; + let mut changed = false; + for key in candidates { + require_authorized(context, &self.product).await?; + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let record = state + .rich_messages + .iter() + .find(|message| message.key == key) + .cloned() + .ok_or(Error::StorageUnavailable)?; + let peer = state.peer(&record.peer)?.clone(); + if !peer.ready() { + return Err(Error::PeerNotReady); + } + let attachments = record + .files + .iter() + .map(|id| { + let file = state + .files + .iter() + .find(|file| &file.id == id && file.ready) + .ok_or(Error::StorageUnavailable)?; + to_wire(file) + }) + .collect::, Error>>()?; + let bytes = wire::encode_rich_text_message( + &record.message_id, + record.timestamp, + record.text.as_deref(), + Some(&attachments), + ) + .map_err(|_| Error::InvalidRequest)?; + let messages = Zeroizing::new(vec![bytes]); + let encoded = Zeroizing::new(messages.encode()); + let digest = hash(&encoded); + let kind = OutgoingKind::Rich(key); + let position = state.outbox.iter().position(|entry| entry.kind == kind); + if record.published && position.is_none() { + return Ok(()); + } + if let Some(position) = position { + if state.outbox[position].digest != digest { + return Err(Error::OperationConflict); + } + } + let statement = actor.multi_statement( + state, + &peer, + &peer.active_devices(), + &record.request_id, + &messages, + )?; + if let Some(position) = position { + let outgoing = &mut state.outbox[position]; + outgoing.statement = statement; + outgoing.roster_revision = peer.revision; + outgoing.last_attempt = 0; + } else { + state.queue(Outgoing { + peer: record.peer, + request_id: record.request_id, + digest, + kind, + roster_revision: peer.revision, + statement, + last_attempt: 0, + })?; + } + state + .rich_messages + .iter_mut() + .find(|message| message.key == key) + .ok_or(Error::StorageUnavailable)? + .published = true; + Ok(()) + }) + .await?; + changed = true; + } + Ok(changed) + } +} + +fn to_wire(file: &FileRecord) -> Result { + let general = wire::V2GeneralFileMeta { + mime_type: file.metadata.mime_type.clone(), + file_size: file.metadata.size_bytes, + }; + let meta = match &file.metadata.kind { + HostNativeChatAttachmentKind::File => wire::V2FileMeta::General(general), + HostNativeChatAttachmentKind::Image { + width, + height, + thumbnail, + } => wire::V2FileMeta::Image(wire::V2ImageFileMeta { + general, + width: *width, + height: *height, + thumbnail: thumbnail.clone(), + }), + HostNativeChatAttachmentKind::Video { + duration_seconds, + thumbnail, + } => wire::V2FileMeta::Video(wire::V2VideoFileMeta { + general, + duration: *duration_seconds, + thumbnail: thumbnail.clone(), + }), + }; + Ok(wire::V2FileVariant::P2pMixnet(wire::V2P2pMixnetFile { + identifier: file.root.ok_or(Error::StorageUnavailable)?.to_vec(), + claim_ticket: file.ticket.0.to_vec(), + node: wire::V2NodeEndpoint::WssUrl(file.endpoint.clone()), + meta, + })) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs new file mode 100644 index 000000000..c89c6ec18 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Expand authenticated HOP history privately; custody commits precede every ACK. + +use std::collections::BTreeSet; + +use super::*; +use crate::runtime::chat_device::{ + CompactedHistory, OpenedDeviceMessage, PaymentMemo, classify_message, +}; +use crate::runtime::native_chat::{ + hop::{FileTicket, HopClient, HopError, PendingAck}, + hop_access::SessionHopRpc, +}; + +const MAX_HISTORY_IMPORTS: usize = 4096; +const MAX_EXPANDED_BYTES: usize = 16 * 1024 * 1024; +const MAX_HISTORY_DEPTH: usize = 64; + +#[derive(Clone, Encode, Decode)] +struct HistoryAck { + endpoint: String, + ticket: Secret32, + encoded: Vec, +} + +#[derive(Clone, Encode, Decode)] +pub(super) struct HistoryImport { + peer: [u8; 32], + digest: [u8; 32], + pending: Option, +} + +pub(super) struct ExpandedHistory { + pub ordinary: Vec>, + pub payments: Vec, + pub rich: Vec, + pub imports: Vec, +} + +pub(super) fn reference_digest(reference: &CompactedHistory) -> [u8; 32] { + let bytes = Zeroizing::new( + ( + reference.message_id.as_str(), + reference.timestamp, + reference.identifier, + &*reference.ticket, + reference.endpoint.as_str(), + ) + .encode(), + ); + hash(&bytes) +} + +pub(super) fn has_pending(imports: &[HistoryImport]) -> bool { + imports.iter().any(|entry| entry.pending.is_some()) +} + +pub(super) fn validate_imports(imports: &[HistoryImport]) -> Result<(), Error> { + if imports.len() > MAX_HISTORY_IMPORTS { + return Err(Error::StorageUnavailable); + } + let mut seen = BTreeSet::new(); + for entry in imports { + if !seen.insert((entry.peer, entry.digest)) { + return Err(Error::StorageUnavailable); + } + if let Some(pending) = &entry.pending { + if !pending.endpoint.starts_with("wss://") { + return Err(Error::StorageUnavailable); + } + PendingAck::restore(&pending.encoded).map_err(|_| Error::StorageUnavailable)?; + } + } + Ok(()) +} + +fn claim_error(error: HopError) -> Error { + match error { + HopError::Crypto | HopError::Integrity | HopError::Codec(_) | HopError::InvalidProgress => { + Error::InvalidStatement + } + HopError::Rpc { .. } | HopError::Transport(_) | HopError::NotFound(_) => { + Error::NetworkUnavailable + } + } +} + +impl NativeChatActor { + pub(super) async fn expand_history( + &self, + context: &NativeChatContext, + peer: [u8; 32], + messages: Vec, + ) -> Result { + let (mut seen, existing_count) = self + .store + .read(|state| { + ( + state + .history_imports + .iter() + .filter(|entry| entry.peer == peer) + .map(|entry| entry.digest) + .collect::>(), + state.history_imports.len(), + ) + }) + .await?; + let mut expanded = ExpandedHistory { + ordinary: Vec::new(), + payments: Vec::new(), + rich: Vec::new(), + imports: Vec::new(), + }; + let mut work: Vec<_> = messages + .into_iter() + .rev() + .map(|message| (message, 0usize)) + .collect(); + let mut bytes_seen = 0usize; + while let Some((message, depth)) = work.pop() { + context.require_current()?; + match message { + OpenedDeviceMessage::Ordinary(bytes) => { + let decoded = + wire::decode_message(&bytes).map_err(|_| Error::InvalidStatement)?; + if !super::receive::valid_peer_timestamp(decoded.timestamp, current_unix_secs()) + { + return Err(Error::InvalidStatement); + } + expanded.ordinary.push(bytes); + } + OpenedDeviceMessage::Payment(memo) => { + if !super::receive::valid_peer_timestamp(memo.timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + expanded.payments.push(memo); + } + OpenedDeviceMessage::RichContent(message) => { + if !super::receive::valid_peer_timestamp(message.timestamp, current_unix_secs()) + { + return Err(Error::InvalidStatement); + } + expanded.rich.push(message); + } + OpenedDeviceMessage::PushToken { timestamp, .. } => { + if !super::receive::valid_peer_timestamp(timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + // No mobile push provider is registered by this Host. Only + // replay evidence survives; tokens never enter guest history. + } + OpenedDeviceMessage::DeviceControl(control) => { + // Historical lifecycle data is not fresh device authority. + // Validate it, but never resurrect/remove a live roster member. + if depth == 0 + || !super::receive::valid_peer_timestamp( + control.timestamp, + current_unix_secs(), + ) + { + return Err(Error::InvalidStatement); + } + } + OpenedDeviceMessage::CompactedHistory(reference) => { + if depth >= MAX_HISTORY_DEPTH + || !super::receive::valid_peer_timestamp( + reference.timestamp, + current_unix_secs(), + ) + { + return Err(Error::InvalidStatement); + } + let digest = reference_digest(&reference); + if !seen.insert(digest) { + continue; + } + if existing_count + expanded.imports.len() >= MAX_HISTORY_IMPORTS { + return Err(Error::StorageUnavailable); + } + let rpc = + SessionHopRpc::connect(context, &self.product, &reference.endpoint).await?; + let ticket = FileTicket::from_bytes(&*reference.ticket) + .map_err(|_| Error::InvalidStatement)?; + let result = HopClient::new(&rpc) + .claim_compaction(reference.identifier, &ticket) + .await; + context.require_current()?; + let claimed = result.map_err(claim_error)?; + let mut nested = Vec::new(); + for bytes in claimed.batch.messages() { + bytes_seen = bytes_seen + .checked_add(bytes.len()) + .ok_or(Error::InvalidStatement)?; + if bytes_seen > MAX_EXPANDED_BYTES { + return Err(Error::StorageUnavailable); + } + let mut owned = Zeroizing::new(bytes.to_vec()); + nested.push( + classify_message(&mut owned).map_err(|_| Error::InvalidStatement)?, + ); + } + expanded.imports.push(HistoryImport { + peer, + digest, + pending: claimed.pending_ack.map(|ack| HistoryAck { + endpoint: reference.endpoint, + ticket: Secret32(*reference.ticket), + encoded: ack.encode(), + }), + }); + work.extend(nested.into_iter().rev().map(|message| (message, depth + 1))); + } + } + } + Ok(expanded) + } + + pub(super) async fn acknowledge_history( + &self, + context: &NativeChatContext, + ) -> Result<(), Error> { + let _gate = self.history_ack_gate.lock().await; + let pending = self + .store + .read(|state| { + state + .history_imports + .iter() + .filter(|entry| entry.pending.is_some()) + .cloned() + .collect::>() + }) + .await?; + for imported in pending { + let Some(pending) = imported.pending else { + continue; + }; + let rpc = SessionHopRpc::connect(context, &self.product, &pending.endpoint).await?; + let ticket = + FileTicket::from_bytes(&pending.ticket.0).map_err(|_| Error::StorageUnavailable)?; + let acknowledgment = + PendingAck::restore(&pending.encoded).map_err(|_| Error::StorageUnavailable)?; + HopClient::new(&rpc) + .acknowledge(&acknowledgment, &ticket) + .await + .map_err(|_| Error::NetworkUnavailable)?; + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + if let Some(entry) = state.history_imports.iter_mut().find(|entry| { + entry.peer == imported.peer && entry.digest == imported.digest + }) { + entry.pending = None; + } + Ok(()) + }) + .await?; + } + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs new file mode 100644 index 000000000..8f98b437c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs @@ -0,0 +1,1153 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Authenticate first, persist private claims and roster state, acknowledge last. + +use super::*; +use crate::host_logic::statement_store::{ + decode_verified_statement_data, statement_expiry_elapsed, +}; +use crate::runtime::chat_device::{ + DeviceControl, DeviceLifecycle, OpenedDeviceExchange, OpenedDeviceMessage, + open_identity_exchange, +}; +use schnorrkel::{PublicKey, Signature}; + +impl NativeChatActor { + pub(in crate::runtime::native_chat) async fn receive( + self: &Arc, + context: &NativeChatContext, + registry: &NativeChatRegistry, + statement: SignedStatement, + ) -> Result<(), Error> { + // This gate is never acquired by outgoing payment handoff. No store + // mutation lock is held while awaiting the wallet's claim/settlement gate. + let _incoming = self.receiving.lock().await; + context.require_current()?; + if statement + .data + .as_ref() + .is_none_or(|data| data.len() > 256 * 1024) + || statement.topics.len() > 8 + { + return Err(Error::InvalidStatement); + } + let encoded = + signed_statement_to_scale(statement.clone()).map_err(|_| Error::InvalidStatement)?; + let verified = + decode_verified_statement_data(&encoded, None).map_err(|_| Error::InvalidStatement)?; + let now = current_unix_secs(); + if verified + .expiry + .is_none_or(|expiry| statement_expiry_elapsed(expiry, now)) + { + return Err(Error::InvalidStatement); + } + if statement.topics.contains(&wire::chat_request_full_topic( + &self.public.identity_account_id, + )) { + return self + .receive_invitation(context, statement, verified.signer, verified.data) + .await; + } + let channel = statement.channel.ok_or(Error::InvalidStatement)?; + let peers = self.store.read(|state| state.peers.clone()).await?; + for peer in peers { + let root_shared = chat_shared_secret(&self.root_secret, &peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let root_incoming = chat_identity_session_id( + &root_shared, + &peer.identity, + &self.public.identity_account_id, + ); + if statement.topics.contains(&root_incoming) + && wire::chat_identity_request_topic(&root_incoming).ok() == Some(channel) + { + let plaintext = native_root_open(&root_shared, &verified.data) + .map_err(|_| Error::InvalidStatement)?; + let exchange = + open_identity_exchange(&plaintext).map_err(|_| Error::InvalidStatement)?; + return self + .receive_acceptance(context, registry, &peer, verified.signer, exchange) + .await; + } + if !peer.established { + continue; + } + let Some(sender) = peer + .active_devices() + .into_iter() + .find(|device| device.account_id == verified.signer) + else { + continue; + }; + if statement.topics.contains(&root_incoming) + && wire::chat_identity_response_topic(&root_incoming).ok() == Some(channel) + { + let plaintext = native_root_open(&root_shared, &verified.data) + .map_err(|_| Error::InvalidStatement)?; + return match open_identity_exchange(&plaintext) + .map_err(|_| Error::InvalidStatement)? + { + OpenedDeviceExchange::Response { + request_id, + response_code, + } => { + self.receive_acknowledgment( + context, + registry, + peer.identity, + sender.account_id, + request_id, + response_code, + ) + .await + } + _ => Err(Error::InvalidStatement), + }; + } + let incoming_shared = chat_shared_secret(&self.root_secret, &sender.public_key) + .map_err(|_| Error::InvalidStatement)?; + let incoming_session = chat_identity_session_id( + &incoming_shared, + &sender.account_id, + &self.public.identity_account_id, + ); + let request_route = statement.topics.contains(&incoming_session) + && wire::chat_identity_request_topic(&incoming_session).ok() == Some(channel); + let response_route = statement.topics.contains(&incoming_session) + && wire::chat_identity_response_topic(&incoming_session).ok() == Some(channel); + if !request_route && !response_route { + continue; + } + let plaintext = native_root_open(&incoming_shared, &verified.data) + .map_err(|_| Error::InvalidStatement)?; + let exchange = self + .device + .open_multi_device(&sender, &plaintext) + .map_err(|_| Error::InvalidStatement)?; + return match exchange { + OpenedDeviceExchange::Request { + request_id, + messages, + } if request_route => { + self.receive_messages( + context, registry, peer, sender, request_id, messages, false, + ) + .await + } + OpenedDeviceExchange::Response { + request_id, + response_code, + } if response_route => { + self.receive_acknowledgment( + context, + registry, + peer.identity, + sender.account_id, + request_id, + response_code, + ) + .await + } + _ => Err(Error::InvalidStatement), + }; + } + Err(Error::InvalidStatement) + } + + async fn receive_invitation( + self: &Arc, + context: &NativeChatContext, + statement: SignedStatement, + signer: [u8; 32], + data: Vec, + ) -> Result<(), Error> { + if wire::is_context_bound_chat_request_v2(&data) { + return Err(Error::InvalidStatement); + } + let request = wire::open_chat_request_v2(&self.root_secret, &data) + .map_err(|_| Error::InvalidStatement)?; + let message = &request.message; + let peer_identity = message.content.identity_proof.identity_account_id; + let now = current_unix_secs(); + valid_id(&message.message_id).map_err(|_| Error::InvalidStatement)?; + if peer_identity == self.public.identity_account_id + || peer_identity == [0; 32] + || request.proof.signer.as_slice() != signer + || !fresh(message.timestamp, now) + || message + .content + .welcome_text + .as_ref() + .is_some_and(|text| text.len() > 8192) + { + return Err(Error::InvalidStatement); + } + let day = wire::chat_request_day_from_unix(message.timestamp / 1000) + .ok_or(Error::InvalidStatement)?; + if !statement.topics.contains(&wire::chat_request_day_topic( + &self.public.identity_account_id, + day, + )) { + return Err(Error::InvalidStatement); + } + let payload = + wire::encode_chat_request_v2_proof_payload(message, &self.public.identity_account_id) + .map_err(|_| Error::InvalidStatement)?; + PublicKey::from_bytes(&signer) + .map_err(|_| Error::InvalidStatement)? + .verify_simple( + SR25519_SIGNING_CONTEXT, + &payload, + &Signature::from_bytes(&request.proof.signature) + .map_err(|_| Error::InvalidStatement)?, + ) + .map_err(|_| Error::InvalidStatement)?; + self.device + .identity_shared_secret(&message.content.device_enc_pub_key) + .map_err(|_| Error::InvalidStatement)?; + let resolved = identity::resolve_account(context, peer_identity).await?; + let shared = chat_shared_secret(&self.root_secret, &resolved.chat_public_key) + .map_err(|_| Error::InvalidStatement)?; + if !verify_chat_device_identity_proof( + &shared, + &peer_identity, + &signer, + &message.content.identity_proof.proof, + ) || statement.channel + != Some(chat_request_channel_id( + &shared, + &peer_identity, + &self.public.identity_account_id, + )) + { + return Err(Error::InvalidStatement); + } + context.require_current()?; + let id = hash(&(peer_identity, &message.message_id).encode()); + let digest = hash(&payload); + let invitation = Invitation { + id, + peer: peer_identity, + root_key: resolved.chat_public_key, + username: resolved.username, + device_account: signer, + device_key: message.content.device_enc_pub_key, + message_id: message.message_id.clone(), + timestamp: message.timestamp, + text: message.content.welcome_text.clone().unwrap_or_default(), + }; + let valid = context.session_valid.clone(); + let auto_accept = self + .store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + state.expire_receipts(now); + let replay_id = format!("invite:{}", invitation.message_id); + if let Some(previous) = state + .received + .iter() + .find(|entry| entry.peer == peer_identity && entry.request_id == replay_id) + { + return if previous.digest == digest { + Ok(false) + } else { + Err(Error::InvalidStatement) + }; + } + if state.invitations.len() >= 16 || state.received.len() >= MAX_RECEIPTS { + return Err(Error::StorageUnavailable); + } + let auto_accept = state.peers.iter().any(|peer| { + peer.identity == peer_identity + && peer.established + && peer.root_key == invitation.root_key + }); + state.received.push(Receipt { + peer: peer_identity, + request_id: replay_id, + digest, + timestamp: now, + }); + state.invitations.push(invitation); + Ok(auto_accept) + }) + .await?; + if auto_accept { + self.accept_invitation(context, id).await?; + } + Ok(()) + } + + pub(in crate::runtime::native_chat) async fn accept( + self: &Arc, + context: &NativeChatContext, + invitation_id: [u8; 32], + ) -> Result<(), Error> { + let _incoming = self.receiving.lock().await; + self.accept_invitation(context, invitation_id).await + } + + async fn accept_invitation( + self: &Arc, + context: &NativeChatContext, + invitation_id: [u8; 32], + ) -> Result<(), Error> { + let actor = self.clone(); + let valid = context.session_valid.clone(); + let delivery = self.delivery_gate.lock().await; + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let position = state + .invitations + .iter() + .position(|invite| invite.id == invitation_id) + .ok_or(Error::InvalidRequest)?; + let invitation = state.invitations[position].clone(); + if !fresh(invitation.timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + if let Some(peer) = state + .peers + .iter() + .find(|peer| peer.identity == invitation.peer) + { + if peer.root_key != invitation.root_key { + return Err(Error::InvalidStatement); + } + } else { + if state.peers.len() >= MAX_PEERS { + return Err(Error::StorageUnavailable); + } + state.peers.push(Peer { + identity: invitation.peer, + root_key: invitation.root_key, + username: invitation.username.clone(), + devices: Vec::new(), + invitation: None, + invitation_timestamp: None, + invitation_text: None, + established: false, + revocation_request: None, + revocation_acked: false, + revocation_acks: Vec::new(), + revision: 0, + }); + } + { + let peer = state.peer_mut(&invitation.peer)?; + admit_device( + peer, + PeerDevice { + account_id: invitation.device_account, + public_key: invitation.device_key, + }, + invitation.timestamp, + &invitation.message_id, + )?; + peer.established = true; + if invitation.username.is_some() { + peer.username = invitation.username.clone(); + } + } + let peer = state.peer(&invitation.peer)?.clone(); + let now = current_unix_secs().saturating_mul(1000); + let accepted = wire::encode_multi_chat_accepted_message( + &random_id()?, + now, + &invitation.message_id, + &wire::V2PeerDevice { + statement_account_id: actor.public.account_id, + encryption_public_key: actor.public.chat_public_key, + }, + ) + .map_err(|_| Error::InvalidRequest)?; + let request_id = random_id()?; + let plaintext = Zeroizing::new( + wire::encode_transport_request_plaintext(&request_id, &[accepted]) + .map_err(|_| Error::InvalidRequest)?, + ); + let shared = chat_shared_secret(&actor.root_secret, &peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let topic = chat_identity_session_id( + &shared, + &actor.public.identity_account_id, + &peer.identity, + ); + let channel = wire::chat_identity_request_topic(&topic) + .map_err(|_| Error::InvalidStatement)?; + let encrypted = + native_root_seal(&shared, &plaintext).map_err(|_| Error::InvalidStatement)?; + let statement = actor.sign(state, channel, vec![topic], encrypted)?; + state.queue(Outgoing { + peer: peer.identity, + request_id, + digest: hash(&plaintext), + kind: OutgoingKind::Acceptance, + roster_revision: peer.revision, + statement, + last_attempt: 0, + })?; + actor.queue_revocation(state, &peer.identity)?; + state.invitations.remove(position); + if !invitation.text.is_empty() { + state.record_messages(HostNativeChatMessages { + peer_identity: peer.identity, + incoming: true, + request_id: invitation.message_id.clone(), + messages: vec![ + wire::encode_rich_text_message( + &invitation.message_id, + invitation.timestamp, + Some(&invitation.text), + None, + ) + .map_err(|_| Error::InvalidStatement)?, + ], + }); + } + Ok(()) + }) + .await?; + drop(delivery); + self.start_delivery(context); + self.flush(context).await + } + + async fn receive_acceptance( + self: &Arc, + context: &NativeChatContext, + registry: &NativeChatRegistry, + peer: &Peer, + signer: [u8; 32], + exchange: OpenedDeviceExchange, + ) -> Result<(), Error> { + let OpenedDeviceExchange::Request { + request_id, + messages, + } = exchange + else { + return Err(Error::InvalidStatement); + }; + // Root encryption authenticates the peer identity, not an arbitrary + // statement signer. An unadmitted device must bind itself to our pending + // invitation; neither ContactAdded nor DeviceAdded can authorize it. + let sender = peer + .devices + .iter() + .find(|device| device.active && device.account == signer) + .and_then(|device| { + device.key.map(|public_key| PeerDevice { + account_id: signer, + public_key, + }) + }) + .or_else(|| { + messages.iter().find_map(|message| match message { + OpenedDeviceMessage::DeviceControl(DeviceControl { + content: DeviceLifecycle::MultiAccepted { request_id, device }, + .. + }) if peer.invitation.as_deref() == Some(request_id) + && device.account_id == signer => + { + Some(*device) + } + _ => None, + }) + }) + .ok_or(Error::InvalidStatement)?; + self.receive_messages( + context, + registry, + peer.clone(), + sender, + request_id, + messages, + true, + ) + .await + } + + fn queue_revocation(&self, state: &mut State, identity: &[u8; 32]) -> Result<(), Error> { + let peer = state.peer(identity)?.clone(); + let devices = peer.active_devices(); + if devices.is_empty() { + return Ok(()); + } + let request_id = random_id()?; + let now = current_unix_secs().saturating_mul(1000); + let added = wire::encode_device_added_message( + &random_id()?, + now, + &self.public.account_id, + &self.public.chat_public_key, + ) + .map_err(|_| Error::InvalidRequest)?; + let removed = wire::encode_device_removed_message(&random_id()?, now, &self.legacy_account) + .map_err(|_| Error::InvalidRequest)?; + let messages = vec![added, removed]; + let digest = hash(&messages.encode()); + let statement = self.multi_statement(state, &peer, &devices, &request_id, &messages)?; + state.outbox.retain(|entry| { + !(entry.peer == peer.identity && entry.kind == OutgoingKind::Revocation) + }); + let current = state.peer_mut(identity)?; + current.revocation_request = Some(request_id.clone()); + current.revocation_acked = false; + current.revocation_acks.clear(); + state.queue(Outgoing { + peer: peer.identity, + request_id, + digest, + kind: OutgoingKind::Revocation, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) + } + + fn queue_identity_ack( + &self, + state: &mut State, + peer: &Peer, + request_id: &str, + ) -> Result<(), Error> { + let shared = chat_shared_secret(&self.root_secret, &peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let session = + chat_identity_session_id(&shared, &self.public.identity_account_id, &peer.identity); + let plaintext = Zeroizing::new( + wire::encode_transport_response_plaintext(request_id, 0) + .map_err(|_| Error::InvalidRequest)?, + ); + let encrypted = + native_root_seal(&shared, &plaintext).map_err(|_| Error::InvalidStatement)?; + let channel = + wire::chat_identity_response_topic(&session).map_err(|_| Error::InvalidStatement)?; + let statement = self.sign(state, channel, vec![session], encrypted)?; + state.queue(Outgoing { + peer: peer.identity, + request_id: request_id.to_owned(), + digest: hash(&plaintext), + kind: OutgoingKind::Acknowledgment, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) + } + + fn queue_device_ack( + &self, + state: &mut State, + peer: &Peer, + sender: &PeerDevice, + request_id: &str, + ) -> Result<(), Error> { + let plaintext = Zeroizing::new( + wire::encode_transport_response_plaintext(request_id, 0) + .map_err(|_| Error::InvalidRequest)?, + ); + let inner = Zeroizing::new( + self.device + .seal_multi_device(&[*sender], &plaintext) + .map_err(|_| Error::InvalidStatement)?, + ); + let shared = self + .device + .identity_shared_secret(&peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let session = chat_identity_session_id(&shared, &self.public.account_id, &peer.identity); + let encrypted = native_root_seal(&shared, &inner).map_err(|_| Error::InvalidStatement)?; + let channel = + wire::chat_identity_response_topic(&session).map_err(|_| Error::InvalidStatement)?; + let statement = self.sign(state, channel, vec![session], encrypted)?; + state.queue(Outgoing { + peer: peer.identity, + request_id: request_id.to_owned(), + digest: hash(&plaintext), + kind: OutgoingKind::Acknowledgment, + roster_revision: peer.revision, + statement, + last_attempt: 0, + }) + } + + async fn receive_messages( + self: &Arc, + context: &NativeChatContext, + registry: &NativeChatRegistry, + peer: Peer, + sender: PeerDevice, + request_id: String, + messages: Vec, + identity_route: bool, + ) -> Result<(), Error> { + let digest = exchange_digest(&messages)?; + let previous = self + .store + .read(|state| { + state + .received + .iter() + .find(|entry| entry.peer == peer.identity && entry.request_id == request_id) + .cloned() + }) + .await?; + if previous + .as_ref() + .is_some_and(|previous| previous.digest != digest) + { + return Err(Error::InvalidStatement); + } + let mut controls = Vec::new(); + let mut content = Vec::new(); + if previous.is_none() { + for message in messages { + match message { + OpenedDeviceMessage::DeviceControl(control) => { + if !valid_peer_timestamp(control.timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + controls.push(control); + } + other => content.push(other), + } + } + } + controls.sort_by(|left, right| { + (left.timestamp, &left.message_id).cmp(&(right.timestamp, &right.message_id)) + }); + // Validate the entire control batch before accepting any spendable + // material. The receive gate serializes every roster admission. + let identity = peer.identity; + let previous_revision = peer.revision; + let previous_invitation = peer.invitation.clone(); + let admitted_sender = peer.devices.iter().any(|device| { + device.active + && device.account == sender.account_id + && device.key == Some(sender.public_key) + }); + let last_departure = controls + .iter() + .filter_map(|control| { + matches!(&control.content, DeviceLifecycle::LeftChat).then_some(control.timestamp) + }) + .max(); + let mut prospective = peer; + for control in controls { + apply_control( + &mut prospective, + control, + &sender, + admitted_sender, + last_departure, + )?; + } + let accepted_invitation = previous_invitation + .as_ref() + .filter(|_| prospective.invitation.is_none()) + .cloned(); + if !admitted_sender && accepted_invitation.is_none() { + return Err(Error::InvalidStatement); + } + let history::ExpandedHistory { + ordinary, + payments, + rich, + imports, + } = self.expand_history(context, identity, content).await?; + let rich = self + .prepare_rich(context, identity, &request_id, rich) + .await?; + if !payments.is_empty() { + // The wallet preflights the complete batch before effects. Success + // means every memo and claim plan is durable, not merely queued. + registry + .wallet(context) + .await? + .receive_batch(context, &self.product, identity, &request_id, payments) + .await?; + } + let delivery = self.delivery_gate.lock().await; + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let current = state.peer(&identity)?; + if current.revision != previous_revision + || current.invitation != previous_invitation + || (admitted_sender + && !current.devices.iter().any(|device| { + device.active + && device.account == sender.account_id + && device.key == Some(sender.public_key) + })) + { + return Err(Error::InvalidStatement); + } + let exists = state + .received + .iter() + .find(|entry| entry.peer == identity && entry.request_id == request_id); + if let Some(existing) = exists { + if existing.digest != digest { + return Err(Error::InvalidStatement); + } + } else { + state.expire_receipts(current_unix_secs()); + if state.received.len() >= MAX_RECEIPTS { + return Err(Error::StorageUnavailable); + } + let changed_roster = prospective.revision != previous_revision; + *state.peer_mut(&identity)? = prospective; + if let Some(invitation_id) = accepted_invitation { + state.outbox.retain(|entry| { + !(entry.peer == identity && entry.kind == OutgoingKind::Invitation) + }); + if state.acknowledgments.len() == MAX_HISTORY_BATCHES { + state.acknowledgments.remove(0); + } + state.acknowledgments.push(HostNativeChatAcknowledgment { + peer_identity: identity, + request_id: invitation_id, + response_code: 0, + }); + actor.queue_revocation(state, &identity)?; + } else if changed_roster { + actor.queue_revocation(state, &identity)?; + } + state.record_messages(HostNativeChatMessages { + // The complete expansion is public only after every + // private payment memo and claim plan is durable. + peer_identity: identity, + incoming: true, + request_id: request_id.clone(), + messages: ordinary, + }); + state.history_imports.extend(imports); + files::merge_received(state, rich)?; + state.received.push(Receipt { + peer: identity, + request_id: request_id.clone(), + digest, + timestamp: current_unix_secs(), + }); + } + let current = state.peer(&identity)?.clone(); + if identity_route { + actor.queue_identity_ack(state, ¤t, &request_id) + } else { + actor.queue_device_ack(state, ¤t, &sender, &request_id) + } + }) + .await?; + drop(delivery); + self.start_delivery(context); + self.flush(context).await + } + + async fn receive_acknowledgment( + self: &Arc, + context: &NativeChatContext, + registry: &NativeChatRegistry, + identity: [u8; 32], + sender: [u8; 32], + request_id: String, + response_code: u8, + ) -> Result<(), Error> { + let valid = context.session_valid.clone(); + let actor = self.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + let peer = state.peer(&identity)?; + if !peer + .devices + .iter() + .any(|device| device.active && device.account == sender) + { + return Err(Error::InvalidStatement); + } + if let Some(entry) = state.outbox.iter().find(|entry| { + entry.peer == identity + && entry.request_id == request_id + && matches!(entry.kind, OutgoingKind::Payment(_)) + }) { + // Only a recipient of the committed envelope can acknowledge + // custody. Current roster membership alone is insufficient. + let shared = actor + .device + .identity_shared_secret(&peer.root_key) + .map_err(|_| Error::InvalidStatement)?; + let plaintext = native_root_open( + &shared, + entry + .statement + .data + .as_deref() + .ok_or(Error::StorageUnavailable)?, + ) + .map_err(|_| Error::StorageUnavailable)?; + let wire::V2StatementTransportData::MultiRequest(request) = + wire::decode_transport_plaintext(&plaintext) + .map_err(|_| Error::StorageUnavailable)? + else { + return Err(Error::StorageUnavailable); + }; + if !request + .devices_info + .iter() + .any(|device| device.statement_account_id == sender) + { + return Err(Error::InvalidStatement); + } + } + let peer = state.peer_mut(&identity)?; + if response_code == 0 && peer.revocation_request.as_deref() == Some(&request_id) { + if !peer.revocation_acks.contains(&sender) { + peer.revocation_acks.push(sender); + } + peer.revocation_acked = peer + .devices + .iter() + .filter(|device| device.active) + .all(|device| peer.revocation_acks.contains(&device.account)); + } + let revocation_ready = peer.revocation_acked; + if let Some(entry) = state + .outbox + .iter() + .find(|entry| entry.peer == identity && entry.request_id == request_id) + { + if response_code == 0 { + if let OutgoingKind::Payment(id) = entry.kind { + if !state.payment_acknowledgments.contains(&id) { + if state.payment_acknowledgments.len() >= MAX_RECEIPTS { + return Err(Error::StorageUnavailable); + } + state.payment_acknowledgments.push(id); + } + } + if entry.kind != OutgoingKind::Revocation || revocation_ready { + state.outbox.retain(|entry| { + !(entry.peer == identity && entry.request_id == request_id) + }); + } + } + } + let request_id = state + .sent + .iter() + .find(|receipt| { + receipt.peer == identity && receipt.wire_request_id == request_id + }) + .map(|receipt| receipt.request_id.clone()) + .unwrap_or(request_id); + if !state.acknowledgments.iter().any(|ack| { + ack.peer_identity == identity + && ack.request_id == request_id + && ack.response_code == response_code + }) { + if state.acknowledgments.len() == MAX_HISTORY_BATCHES { + state.acknowledgments.remove(0); + } + state.acknowledgments.push(HostNativeChatAcknowledgment { + peer_identity: identity, + request_id, + response_code, + }); + } + Ok(()) + }) + .await?; + self.replay_payment_acknowledgments(context, registry).await + } + + pub(super) async fn replay_payment_acknowledgments( + &self, + context: &NativeChatContext, + registry: &NativeChatRegistry, + ) -> Result<(), Error> { + let pending = self + .store + .read(|state| state.payment_acknowledgments.clone()) + .await?; + if pending.is_empty() { + return Ok(()); + } + let wallet = registry.wallet(context).await?; + for id in pending { + wallet.note_delivery(context, &self.product, id).await?; + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + state + .payment_acknowledgments + .retain(|pending| *pending != id); + Ok(()) + }) + .await?; + } + Ok(()) + } +} + +// Native OutgoingRequestQueue preserves message timestamps while the channel +// refreshes the signed statement's expiry. Body age is ordering metadata, not a +// second transport TTL: delayed admitted-peer traffic remains valid. Discovery +// invitations keep the separate bounded `fresh` policy. +pub(super) fn valid_peer_timestamp(timestamp_ms: u64, now: u64) -> bool { + let timestamp = timestamp_ms / 1000; + timestamp >= wire::PROTOCOL_EPOCH_SECONDS && timestamp <= now.saturating_add(CLOCK_SKEW) +} + +fn admit_device( + peer: &mut Peer, + device: PeerDevice, + timestamp: u64, + message_id: &str, +) -> Result<(), Error> { + if let Some(record) = peer + .devices + .iter_mut() + .find(|record| record.account == device.account_id) + { + if record.key.is_some_and(|key| key != device.public_key) { + return Err(Error::InvalidStatement); + } + if (timestamp, message_id) <= (record.timestamp, record.message_id.as_str()) { + return Ok(()); + } + let was_active = record.active; + record.key = Some(device.public_key); + record.active = true; + record.timestamp = timestamp; + record.message_id = message_id.to_owned(); + if !was_active { + peer.revision = peer + .revision + .checked_add(1) + .ok_or(Error::StorageUnavailable)?; + } + } else { + if peer.devices.len() >= 64 { + return Err(Error::StorageUnavailable); + } + peer.devices.push(DeviceRecord { + account: device.account_id, + key: Some(device.public_key), + active: true, + timestamp, + message_id: message_id.to_owned(), + }); + peer.revision = peer + .revision + .checked_add(1) + .ok_or(Error::StorageUnavailable)?; + } + if peer.active_devices().len() > 16 { + return Err(Error::StorageUnavailable); + } + Ok(()) +} + +fn apply_control( + peer: &mut Peer, + control: DeviceControl, + sender: &PeerDevice, + admitted_sender: bool, + last_departure: Option, +) -> Result<(), Error> { + match control.content { + DeviceLifecycle::Added(device) => { + admit_device(peer, device, control.timestamp, &control.message_id)? + } + DeviceLifecycle::Removed(account) => { + if let Some(record) = peer + .devices + .iter_mut() + .find(|record| record.account == account) + { + if (control.timestamp, control.message_id.as_str()) + > (record.timestamp, record.message_id.as_str()) + { + let changed = record.active; + record.active = false; + record.timestamp = control.timestamp; + record.message_id = control.message_id; + if changed { + peer.revision = peer + .revision + .checked_add(1) + .ok_or(Error::StorageUnavailable)?; + } + } + } else { + if peer.devices.len() >= 64 { + return Err(Error::StorageUnavailable); + } + peer.devices.push(DeviceRecord { + account, + key: None, + active: false, + timestamp: control.timestamp, + message_id: control.message_id, + }); + } + } + DeviceLifecycle::LeftChat => { + let mut changed = false; + for record in &mut peer.devices { + if (control.timestamp, control.message_id.as_str()) + > (record.timestamp, record.message_id.as_str()) + { + changed |= record.active; + record.active = false; + record.timestamp = control.timestamp; + record.message_id = control.message_id.clone(); + } + } + // Delayed departure must not undo a newer authenticated device + // admission. Per-device tombstones still advance monotonically. + if changed { + if !peer.devices.iter().any(|record| record.active) { + peer.established = false; + } + peer.revocation_acked = false; + peer.revision = peer + .revision + .checked_add(1) + .ok_or(Error::StorageUnavailable)?; + } + } + DeviceLifecycle::ContactAdded => { + // Native compatibility signal for an older outgoing request, not a + // device advertisement. It may only resolve a request using an + // independently admitted signer, never create or revive a binding. + if admitted_sender + && peer.invitation.is_some() + && peer + .invitation_timestamp + .is_some_and(|timestamp| control.timestamp <= timestamp) + && last_departure.is_none_or(|timestamp| timestamp < control.timestamp) + { + peer.invitation = None; + peer.invitation_timestamp = None; + peer.established = true; + } + } + DeviceLifecycle::MultiAccepted { request_id, device } => { + if device != *sender { + return Err(Error::InvalidStatement); + } + if peer.invitation.as_deref() == Some(&request_id) { + admit_device(peer, device, control.timestamp, &control.message_id)?; + if !peer.devices.iter().any(|record| { + record.active + && record.account == device.account_id + && record.key == Some(device.public_key) + }) { + return Err(Error::InvalidStatement); + } + peer.established = true; + peer.invitation = None; + peer.invitation_timestamp = None; + } + } + DeviceLifecycle::Accepted { request_id } => { + // Legacy acceptance carries no key. Correlation alone cannot grant + // roster authority, including in a batch with DeviceAdded. + if !admitted_sender { + return Err(Error::InvalidStatement); + } + if peer.invitation.as_deref() == Some(&request_id) { + peer.established = true; + peer.invitation = None; + peer.invitation_timestamp = None; + } + } + } + Ok(()) +} + +fn exchange_digest(messages: &[OpenedDeviceMessage]) -> Result<[u8; 32], Error> { + let mut hasher = blake2b_simd::Params::new().hash_length(32).to_state(); + hasher.update(&(messages.len() as u32).to_le_bytes()); + for message in messages { + let encoded = match message { + OpenedDeviceMessage::Ordinary(bytes) => { + hasher.update(&[0]); + hasher.update(&(bytes.len() as u32).to_le_bytes()); + hasher.update(bytes); + continue; + } + OpenedDeviceMessage::Payment(memo) => { + hasher.update(&[1]); + let mut bytes = Zeroizing::new( + (memo.message_id.as_str(), memo.timestamp, memo.total_value).encode(), + ); + parity_scale_codec::Compact(memo.coin_keys.len() as u32).encode_to(&mut *bytes); + for key in memo.coin_keys.iter() { + key.encode_to(&mut *bytes); + } + hasher.update(&(bytes.len() as u32).to_le_bytes()); + hasher.update(&*bytes); + continue; + } + OpenedDeviceMessage::CompactedHistory(reference) => { + hasher.update(&[3]); + hasher.update(&history::reference_digest(reference)); + continue; + } + OpenedDeviceMessage::RichContent(message) => { + hasher.update(&[4]); + hasher.update(&message.digest); + continue; + } + OpenedDeviceMessage::PushToken { digest, .. } => { + hasher.update(&[5]); + hasher.update(digest); + continue; + } + OpenedDeviceMessage::DeviceControl(control) => { + let mut bytes = (control.message_id.as_str(), control.timestamp).encode(); + match &control.content { + DeviceLifecycle::Added(device) => { + (0u8, device.account_id, device.public_key).encode_to(&mut bytes) + } + DeviceLifecycle::Removed(account) => (1u8, account).encode_to(&mut bytes), + DeviceLifecycle::Accepted { request_id } => { + (2u8, request_id).encode_to(&mut bytes) + } + DeviceLifecycle::MultiAccepted { request_id, device } => { + (3u8, request_id, device.account_id, device.public_key) + .encode_to(&mut bytes) + } + DeviceLifecycle::ContactAdded => bytes.push(4), + DeviceLifecycle::LeftChat => bytes.push(5), + } + bytes + } + }; + hasher.update(&[2]); + hasher.update(&(encoded.len() as u32).to_le_bytes()); + hasher.update(&encoded); + } + Ok(hasher + .finalize() + .as_bytes() + .try_into() + .expect("32-byte digest")) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs new file mode 100644 index 000000000..5f01f26d4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -0,0 +1,3118 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Native signed/encrypted packets against the real actor and encrypted stores. +#![cfg(not(target_arch = "wasm32"))] + +mod hop_history; + +use super::*; +use crate::{ + host_logic::statement_store::decode_verified_statement_data, + runtime::{authority::AuthoritySession, services::RuntimeServices}, + subscription::Spawner, + test_support::{StubPlatform, core_storage_test_key}, +}; +use futures::{ + executor::block_on, + future::{AbortHandle, Abortable}, +}; +use parking_lot::Mutex; +use truapi_coinage::{MemoEntry, TransferMemo}; +use truapi_platform::CoreStorageKey; + +const PRODUCT: &str = "chat.dot"; + +// Every owned persistence future runs to completion before its awaited call +// returns. Delivery loops belong to this session and are aborted and joined at +// logout/drop, including on assertion failure; no five-second timer leaks into +// another test and no test depends on the delivery thread winning a race. +struct SessionTasks { + live: Arc, + tasks: Arc)>>>, +} + +impl SessionTasks { + fn new() -> Self { + Self { + live: Arc::new(AtomicBool::new(true)), + tasks: Default::default(), + } + } + + fn spawner(&self) -> Spawner { + let tasks = self.tasks.clone(); + Arc::new(move |future| { + let (abort, registration) = AbortHandle::new_pair(); + let thread = std::thread::spawn(move || { + let _ = block_on(Abortable::new(future, registration)); + }); + tasks.lock().push((abort, thread)); + }) + } + + fn stop(&self) { + self.live.store(false, Ordering::Release); + // Aborting a task can race with it spawning its last storage operation. + // Join the current wave and drain again, without holding the list lock. + loop { + let tasks = std::mem::take(&mut *self.tasks.lock()); + if tasks.is_empty() { + break; + } + for (abort, _) in &tasks { + abort.abort(); + } + for (_, thread) in tasks { + thread.join().unwrap(); + } + } + } +} + +impl Drop for SessionTasks { + fn drop(&mut self) { + self.stop(); + } +} + +struct Fixture { + context: NativeChatContext, + platform: Arc, + tasks: SessionTasks, + timestamp: u64, +} + +impl Fixture { + fn new() -> Self { + Self::on_platform(Arc::new(StubPlatform { + chain_connect_error: Some("actor fixture has no RPC"), + ..Default::default() + })) + } + + fn on_platform(platform: Arc) -> Self { + let tasks = SessionTasks::new(); + let live = tasks.live.clone(); + let context = NativeChatContext { + services: RuntimeServices::new( + platform.clone(), + truapi_platform::HostInfo { + name: "Native actor test".into(), + icon: None, + version: None, + platform: HostPlatform::Unknown, + }, + [2; 32], + [3; 32], + [4; 32], + tasks.spawner(), + ), + session: AuthoritySession { + public_key: [1; 32], + identity_account_id: Some([5; 32]), + lite_username: None, + full_username: None, + validation_id: vec![1], + }, + entropy: Zeroizing::new(vec![0x44; 16]), + session_valid: Arc::new(move || live.load(Ordering::Acquire)), + network_suffix: "test".into(), + genesis_hash: [2; 32], + coinage_instance_id: None, + }; + Self { + context, + platform, + tasks, + timestamp: current_unix_secs() * 1000, + } + } + + async fn actor(&self) -> Arc { + NativeChatActor::open(&self.context, PRODUCT).await.unwrap() + } +} + +struct IdentityFixture { + account: [u8; 32], + secret: [u8; 32], +} + +impl IdentityFixture { + fn new() -> Self { + Self { + account: keypair(0x70).public.to_bytes(), + secret: [0x71; 32], + } + } + fn public_key(&self) -> [u8; 32] { + wire::x25519_public_key(&self.secret) + } +} + +struct DeviceFixture { + signer: Keypair, + secret: [u8; 32], +} + +impl DeviceFixture { + fn new(seed: u8) -> Self { + Self { + signer: keypair(seed), + secret: [seed + 64; 32], + } + } + fn account(&self) -> [u8; 32] { + self.signer.public.to_bytes() + } + fn public_key(&self) -> [u8; 32] { + wire::x25519_public_key(&self.secret) + } +} + +fn keypair(seed: u8) -> Keypair { + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .unwrap() + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) +} + +async fn seed_peer( + actor: &NativeChatActor, + identity: &IdentityFixture, + devices: &[&DeviceFixture], +) { + // Initial authenticated discovery and a completed secure-device handshake + // are seeded. Later mutations and ACKs enter through signature/route checks. + let peer = Peer { + identity: identity.account, + root_key: identity.public_key(), + username: Some("peer.dot".into()), + devices: devices + .iter() + .map(|device| DeviceRecord { + account: device.account(), + key: Some(device.public_key()), + active: true, + timestamp: 0, + message_id: "authenticated-fixture".into(), + }) + .collect(), + invitation: None, + invitation_timestamp: None, + invitation_text: None, + established: true, + revocation_request: None, + revocation_acked: true, + revocation_acks: devices.iter().map(|device| device.account()).collect(), + revision: 1, + }; + actor + .store + .update(move |state| { + state.peers.push(peer); + Ok(()) + }) + .await + .unwrap(); +} + +#[test] +fn full_account_refreshes_committed_ciphertext_without_starving_other_peers() { + block_on(async { + let floor = (current_unix_secs() + LIFETIME + 60) << 32; + let platform = Arc::new(StubPlatform { + rpc_method_responses: vec![ + ( + "statement_submit", + serde_json::json!({ + "status":"rejected", "reason":"accountFull", "min_expiry":floor + }) + .to_string(), + ); + 2 + ], + ..Default::default() + }); + let fixture = Fixture::on_platform(platform.clone()); + let actor = fixture.actor().await; + let identities = [ + IdentityFixture::new(), + IdentityFixture { + account: keypair(0x72).public.to_bytes(), + secret: [0x73; 32], + }, + ]; + let device = DeviceFixture::new(1); + for identity in &identities { + seed_peer(&actor, identity, &[&device]).await; + } + let sender = actor.clone(); + let peers = identities.map(|identity| identity.account); + let timestamp = fixture.timestamp; + actor + .store + .update(move |state| { + for (index, identity) in peers.into_iter().enumerate() { + let peer = state.peer(&identity)?.clone(); + let request_id = format!("retained-{index}"); + let messages = + vec![wire::encode_contact_added_message(&request_id, timestamp).unwrap()]; + let statement = sender.multi_statement( + state, + &peer, + &peer.active_devices(), + &request_id, + &messages, + )?; + state.queue(Outgoing { + peer: identity, + request_id, + digest: hash(&messages.encode()), + kind: OutgoingKind::Ordinary, + roster_revision: peer.revision, + statement, + last_attempt: 0, + })?; + } + Ok(()) + }) + .await + .unwrap(); + let original = actor + .store + .read(|state| state.outbox.clone()) + .await + .unwrap(); + assert_eq!( + actor.flush(&fixture.context).await, + Err(Error::NetworkUnavailable) + ); + let submissions: Vec = platform + .sent_rpc + .lock() + .unwrap() + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .filter(|request| request["method"] == "statement_submit") + .map(|request| { + let bytes = hex::decode( + request["params"][0] + .as_str() + .unwrap() + .trim_start_matches("0x"), + ) + .unwrap(); + let verified = decode_verified_statement_data(&bytes, None).unwrap(); + assert_eq!(verified.signer, actor.public.account_id); + decode_signed_statement(&bytes).unwrap() + }) + .collect(); + // A persistent rejection is bounded, but cannot prevent the second + // peer's statement from reaching the transport. + assert_eq!(submissions.len(), 4); + for (pair, before) in submissions.chunks_exact(2).zip(&original) { + assert_eq!(pair[0].expiry, before.statement.expiry); + assert!(pair[1].expiry.unwrap() > floor); + assert_eq!(pair[1].data, before.statement.data); + assert_eq!(pair[1].topics, before.statement.topics); + assert_eq!(pair[1].channel, before.statement.channel); + } + drop(actor); + let reopened = fixture.actor().await; + let retained = reopened + .store + .read(|state| state.outbox.clone()) + .await + .unwrap(); + assert_eq!(retained.len(), 2); + for (entry, submitted) in retained.iter().zip([&submissions[1], &submissions[3]]) { + assert_eq!(entry.statement, *submitted); + } + assert!( + reopened + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .acknowledgments + .is_empty() + ); + }); +} + +async fn seed_outgoing_invitation( + actor: &NativeChatActor, + identity: &IdentityFixture, + devices: &[&DeviceFixture], + timestamp: u64, +) { + seed_peer(actor, identity, devices).await; + let identity = identity.account; + actor + .store + .update(move |state| { + let peer = state.peer_mut(&identity)?; + peer.established = false; + peer.invitation = Some("pending-invitation".into()); + peer.invitation_timestamp = Some(timestamp); + Ok(()) + }) + .await + .unwrap(); +} + +fn signed_packet( + sender: &DeviceFixture, + topic: [u8; 32], + response: bool, + data: Vec, +) -> SignedStatement { + let channel = if response { + wire::chat_identity_response_topic(&topic) + } else { + wire::chat_identity_request_topic(&topic) + } + .unwrap(); + let fields = statement_fields_from_v01(Statement { + proof: None, + decryption_key: None, + expiry: Some((current_unix_secs() + LIFETIME) << 32), + channel: Some(channel), + topics: vec![topic], + data: Some(data), + }) + .unwrap(); + let signed = + sign_statement_fields(sender.signer.secret.to_bytes(), sender.account(), fields).unwrap(); + decode_signed_statement(&signed.encode()).unwrap() +} + +fn resign_with_expiry( + sender: &DeviceFixture, + statement: SignedStatement, + expiry: u64, +) -> SignedStatement { + let fields = statement_fields_from_v01(Statement { + proof: None, + decryption_key: None, + expiry: Some(expiry), + channel: statement.channel, + topics: statement.topics, + data: statement.data, + }) + .unwrap(); + let signed = + sign_statement_fields(sender.signer.secret.to_bytes(), sender.account(), fields).unwrap(); + decode_signed_statement(&signed.encode()).unwrap() +} + +fn route(shared: &[u8; 32], sender: &[u8; 32], recipient: &[u8; 32]) -> [u8; 32] { + wire::chat_identity_session_id(shared, sender, None, recipient, None).unwrap() +} + +// Native IncomingMessageChannel publishes ACKs on sessionId.own, just like +// requests: the sender's outgoing route, not the original requester's route. +// Peer-side ciphertext uses the independent native codec, not actor sealing. +fn native_packet( + actor: &NativeChatActor, + identity: &IdentityFixture, + sender: &DeviceFixture, + plaintext: &[u8], + response: bool, + root_route: bool, +) -> SignedStatement { + let (shared, topic) = if root_route { + let shared = + wire::x25519_shared_secret(&identity.secret, &actor.public.identity_chat_public_key) + .unwrap(); + let topic = route( + &shared, + &identity.account, + &actor.public.identity_account_id, + ); + (shared, topic) + } else { + let shared = + wire::x25519_shared_secret(&sender.secret, &actor.public.identity_chat_public_key) + .unwrap(); + let topic = route( + &shared, + &sender.account(), + &actor.public.identity_account_id, + ); + (shared, topic) + }; + let inner = if root_route { + plaintext.to_vec() + } else { + let one_shot = Zeroizing::new(hash(plaintext)); + let encrypted = + wire::encrypt_multi_device_payload_with_nonce(&one_shot, &plaintext[1..], [0x21; 12]) + .unwrap(); + let devices_info = vec![wire::V2RequestDeviceInfo { + statement_account_id: actor.public.account_id, + encrypted_key: wire::wrap_multi_device_key_with_nonce( + &sender.secret, + &actor.public.chat_public_key, + &one_shot, + [0x22; 12], + ) + .unwrap(), + }]; + if response { + wire::encode_transport_multi_response_plaintext(&wire::V2MultiDeviceResponse { + encrypted_response: encrypted, + devices_info, + }) + .unwrap() + } else { + wire::encode_transport_multi_request_plaintext(&wire::V2MultiDeviceRequest { + encrypted_request: encrypted, + devices_info, + }) + .unwrap() + } + }; + let nonce: [u8; 12] = hash(&inner)[..12].try_into().unwrap(); + let key = Zeroizing::new(wire::hkdf_sha256_32(&shared).unwrap()); + let ciphertext = wire::encrypt_multi_device_payload_with_nonce(&key, &inner, nonce).unwrap(); + signed_packet(sender, topic, response, ciphertext) +} + +fn request( + actor: &NativeChatActor, + identity: &IdentityFixture, + sender: &DeviceFixture, + id: &str, + messages: &[Vec], +) -> SignedStatement { + native_packet( + actor, + identity, + sender, + &wire::encode_transport_request_plaintext(id, messages).unwrap(), + false, + false, + ) +} + +fn acknowledgment( + actor: &NativeChatActor, + identity: &IdentityFixture, + sender: &DeviceFixture, + id: &str, + root: bool, +) -> SignedStatement { + native_packet( + actor, + identity, + sender, + &wire::encode_transport_response_plaintext(id, 0).unwrap(), + true, + root, + ) +} + +fn open_output( + actor: &NativeChatActor, + identity: &IdentityFixture, + statement: &SignedStatement, + response: bool, + root: bool, +) -> wire::V2StatementTransportData { + let verified = decode_verified_statement_data( + &signed_statement_to_scale(statement.clone()).unwrap(), + None, + ) + .unwrap(); + assert_eq!(verified.signer, actor.public.account_id); + let shared = if root { + wire::x25519_shared_secret(&identity.secret, &actor.public.identity_chat_public_key) + .unwrap() + } else { + wire::x25519_shared_secret(&identity.secret, &actor.public.chat_public_key).unwrap() + }; + let topic = if root { + route( + &shared, + &actor.public.identity_account_id, + &identity.account, + ) + } else { + route(&shared, &actor.public.account_id, &identity.account) + }; + assert!(statement.topics.contains(&topic)); + assert_eq!( + statement.channel, + Some( + if response { + wire::chat_identity_response_topic(&topic) + } else { + wire::chat_identity_request_topic(&topic) + } + .unwrap() + ) + ); + wire::decode_transport(&verified.data, &wire::hkdf_sha256_32(&shared).unwrap()).unwrap() +} + +fn open_body( + actor: &NativeChatActor, + recipient: &DeviceFixture, + encrypted: &[u8], + devices: &[wire::V2RequestDeviceInfo], +) -> Zeroizing> { + let own = devices + .iter() + .find(|device| device.statement_account_id == recipient.account()) + .unwrap(); + let key = Zeroizing::new( + wire::unwrap_multi_device_key( + &recipient.secret, + &actor.public.chat_public_key, + &own.encrypted_key, + ) + .unwrap(), + ); + Zeroizing::new(wire::decrypt_multi_device_payload(&key, encrypted).unwrap()) +} + +async fn outgoing(actor: &NativeChatActor, kind: OutgoingKind) -> Outgoing { + actor + .store + .read(move |state| { + state + .outbox + .iter() + .find(|entry| entry.kind == kind) + .cloned() + }) + .await + .unwrap() + .expect("committed packet must remain available offline") +} + +fn memo() -> TransferMemo { + TransferMemo { + entries: vec![ + MemoEntry(keypair(0x31).secret.to_bytes()), + MemoEntry(keypair(0x32).secret.to_bytes()), + ], + total_value: 250, + } +} + +fn payment_intent(identity: &IdentityFixture) -> PaymentIntent { + PaymentIntent { + product_id: PRODUCT.into(), + peer_identity: identity.account, + recipient_username: Some("peer.dot".into()), + request_id: "one-shot-approved-payment".into(), + amount_cents: 25, + } +} + +fn transport( + actor: &Arc, + fixture: &Fixture, + identity: &IdentityFixture, +) -> Arc { + Arc::new(ChatPaymentTransport { + actor: actor.clone(), + context: fixture.context.clone(), + peer_identity: identity.account, + }) +} + +#[test] +fn root_identity_acceptance_ack_removes_only_the_acknowledged_acceptance() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + let invitation = Invitation { + id: [0x11; 32], + peer: identity.account, + root_key: identity.public_key(), + username: Some("peer.dot".into()), + device_account: peer.account(), + device_key: peer.public_key(), + message_id: "native-invitation".into(), + timestamp: fixture.timestamp, + text: "hello from native".into(), + }; + actor + .store + .update(move |state| { + state.invitations.push(invitation); + Ok(()) + }) + .await + .unwrap(); + // No RPC exists, but acceptance must have committed before submission. + assert_eq!( + actor.accept(&fixture.context, [0x11; 32]).await, + Err(Error::NetworkUnavailable) + ); + let accepted = outgoing(&actor, OutgoingKind::Acceptance).await; + let wire::V2StatementTransportData::Request { + request_id, + messages, + } = open_output(&actor, &identity, &accepted.statement, false, true) + else { + panic!("native acceptance must use the root identity request") + }; + assert_eq!(messages.len(), 1); + assert_eq!( + wire::decode_message(&messages[0]).unwrap().content, + wire::V2ChatMessageContent::MultiChatAccepted { + request_id: "native-invitation".into(), + device: wire::V2PeerDevice { + statement_account_id: actor.public.account_id, + encryption_public_key: actor.public.chat_public_key, + }, + } + ); + let registry = NativeChatRegistry::default(); + let ack = acknowledgment(&actor, &identity, &peer, &request_id, true); + actor + .receive(&fixture.context, ®istry, ack.clone()) + .await + .unwrap(); + actor + .receive(&fixture.context, ®istry, ack) + .await + .unwrap(); + assert!( + actor + .store + .read(|state| state + .outbox + .iter() + .all(|entry| entry.kind != OutgoingKind::Acceptance)) + .await + .unwrap() + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id, + response_code: 0, + }] + ); + assert!(view.invitations.is_empty()); + assert!( + !view.peers[0].ready_for_payments, + "acceptance ACK cannot stand in for legacy-device revocation ACK" + ); + let revoked = outgoing(&actor, OutgoingKind::Revocation).await; + assert_ne!(revoked.request_id, accepted.request_id); + }); +} + +#[test] +fn payment_ack_survives_wallet_failure_and_actor_store_reopen() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let card = wallet + .seed_accepted_for_test( + &fixture.context, + payment_intent(&identity), + fixture.timestamp, + &memo(), + ) + .await + .unwrap(); + transport(&actor, &fixture, &identity) + .accept(&card, memo()) + .await + .unwrap(); + assert_eq!(wallet.views(PRODUCT).await.unwrap(), vec![card.clone()]); + let packet = outgoing(&actor, OutgoingKind::Payment(card.operation_id)).await; + + // Reopen wallet custody after restart so authenticated storage is read, + // rather than mutating disk underneath a still-valid in-memory cache. + drop(wallet); + drop(registry); + let registry = NativeChatRegistry::default(); + let slot = core_storage_test_key(CoreStorageKey::MainPurseCoinage { + root_public_key: fixture.context.session.public_key, + genesis_hash: fixture.context.genesis_hash, + }); + let durable_wallet = fixture + .platform + .local_storage + .lock() + .unwrap() + .insert(slot.clone(), vec![0xff]) + .unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &peer, &packet.request_id, false) + ) + .await, + Err(Error::StorageUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id: packet.request_id, + response_code: 0, + }] + ); + assert!( + actor + .store + .read(|state| state + .outbox + .iter() + .all(|entry| !matches!(entry.kind, OutgoingKind::Payment(_)))) + .await + .unwrap() + ); + fixture.tasks.stop(); + drop(registry); + drop(actor); + fixture + .platform + .local_storage + .lock() + .unwrap() + .insert(slot, durable_wallet); + + let restarted = Fixture::on_platform(fixture.platform.clone()); + let actor = restarted.actor().await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&restarted.context).await.unwrap(); + assert_eq!(wallet.views(PRODUCT).await.unwrap(), vec![card.clone()]); + // No packet is re-received. Reconcile must repair delivery before its + // independent finalized-chain observation encounters the offline RPC. + assert_eq!( + actor.reconcile(&restarted.context, ®istry).await, + Err(Error::NetworkUnavailable) + ); + let delivered = HostNativeChatPayment { + state: HostNativeChatPaymentState::Delivered, + ..card + }; + assert_eq!( + actor + .public_view(&restarted.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap() + .payments, + vec![delivered.clone()] + ); + actor + .replay_payment_acknowledgments(&restarted.context, ®istry) + .await + .unwrap(); + assert_eq!(wallet.views(PRODUCT).await.unwrap(), vec![delivered]); + assert!( + restarted + .platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn accepted_payment_rewraps_exact_memo_only_for_new_authenticated_roster() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let old = DeviceFixture::new(1); + let new = DeviceFixture::new(2); + let offline = DeviceFixture::new(3); + seed_peer(&actor, &identity, &[&old]).await; + let registry = NativeChatRegistry::default(); + let added = wire::encode_device_added_message( + "add-offline-device", + fixture.timestamp, + &offline.account(), + &offline.public_key(), + ) + .unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + request( + &actor, + &identity, + &old, + "advertise-offline-device", + &[added] + ), + ) + .await, + Err(Error::NetworkUnavailable) + ); + let update = outgoing(&actor, OutgoingKind::Revocation).await; + assert!( + !actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .peers[0] + .ready_for_payments + ); + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &old, &update.request_id, false), + ) + .await + .unwrap(); + assert!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .peers[0] + .ready_for_payments + ); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let card = wallet + .seed_accepted_for_test( + &fixture.context, + payment_intent(&identity), + fixture.timestamp, + &memo(), + ) + .await + .unwrap(); + let transport = transport(&actor, &fixture, &identity); + transport.accept(&card, memo()).await.unwrap(); + // Chat committed custody, then the process died before either wallet + // acceptance write. Roster repair must work without a new spend review. + wallet + .seed_handoff_ready_for_test(&fixture.context, card.operation_id) + .await + .unwrap(); + let before = outgoing(&actor, OutgoingKind::Payment(card.operation_id)).await; + let wire::V2StatementTransportData::MultiRequest(before_wire) = + open_output(&actor, &identity, &before.statement, false, false) + else { + panic!("payment must be a native multi-device request") + }; + let original = open_body( + &actor, + &old, + &before_wire.encrypted_request, + &before_wire.devices_info, + ); + assert_eq!( + before_wire + .devices_info + .iter() + .map(|device| device.statement_account_id) + .collect::>(), + vec![old.account()], + "an unacknowledged active device must receive no payment key" + ); + for wrap in &before_wire.devices_info { + assert!( + wire::unwrap_multi_device_key( + &offline.secret, + &actor.public.chat_public_key, + &wrap.encrypted_key + ) + .is_err() + ); + } + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &offline, &before.request_id, false), + ) + .await, + Err(Error::InvalidStatement), + "an excluded device cannot falsely acknowledge payment custody" + ); + assert_eq!(wallet.views(PRODUCT).await.unwrap(), vec![card.clone()]); + + let controls = vec![ + wire::encode_device_added_message( + "add-replacement", + fixture.timestamp, + &new.account(), + &new.public_key(), + ) + .unwrap(), + wire::encode_device_removed_message( + "remove-old", + fixture.timestamp + 1, + &old.account(), + ) + .unwrap(), + ]; + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + request(&actor, &identity, &old, "signed-roster-change", &controls) + ) + .await, + Err(Error::NetworkUnavailable) + ); + let public = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + public.peers[0] + .devices + .iter() + .map(|device| device.account_id) + .collect::>(), + [offline.account(), new.account()].into_iter().collect() + ); + assert!(!public.peers[0].ready_for_payments); + let revocation = outgoing(&actor, OutgoingKind::Revocation).await; + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &new, &revocation.request_id, false), + ) + .await + .unwrap(); + assert!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .peers[0] + .ready_for_payments + ); + assert!( + wallet + .pending_handoffs(&fixture.context, PRODUCT, &[]) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + actor.reconcile(&fixture.context, ®istry).await, + Err(Error::NetworkUnavailable) + ); + let after = outgoing(&actor, OutgoingKind::Payment(card.operation_id)).await; + let wire::V2StatementTransportData::MultiRequest(after_wire) = + open_output(&actor, &identity, &after.statement, false, false) + else { + panic!("repaired payment must remain native multi-device transport") + }; + let repaired = open_body( + &actor, + &new, + &after_wire.encrypted_request, + &after_wire.devices_info, + ); + assert_eq!( + repaired.as_slice(), + original.as_slice(), + "rewrapping cannot mint a new message, amount, or spendable memo" + ); + let decoded = wire::decode_message_exchange_request_plaintext(&repaired).unwrap(); + assert_eq!( + decoded.request_id, + format!("pay-{}", hex::encode(card.operation_id)) + ); + assert_eq!(decoded.messages.len(), 1); + let message = wire::decode_message(&decoded.messages[0]).unwrap(); + assert_eq!(message.message_id, card.message_id); + assert_eq!(message.timestamp, card.timestamp); + let wire::V2ChatMessageContent::CoinageSend { + total_value, + coin_keys, + } = message.content + else { + panic!("accepted memo must remain native CoinageSend") + }; + assert_eq!(total_value, "250"); + assert_eq!( + coin_keys, + memo() + .entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect::>() + ); + assert_eq!(wallet.views(PRODUCT).await.unwrap(), vec![card]); + assert_eq!( + after_wire + .devices_info + .iter() + .map(|device| device.statement_account_id) + .collect::>(), + vec![new.account()] + ); + for wrap in &after_wire.devices_info { + assert!( + wire::unwrap_multi_device_key( + &offline.secret, + &actor.public.chat_public_key, + &wrap.encrypted_key + ) + .is_err(), + "rewrapping must not include an active but unacknowledged device" + ); + assert!( + wire::unwrap_multi_device_key( + &old.secret, + &actor.public.chat_public_key, + &wrap.encrypted_key + ) + .is_err(), + "revoked device must not recover the new one-shot key even if it ignores recipient addressing" + ); + } + assert!( + fixture + .platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn revoked_expired_and_future_signed_packets_never_change_roster_or_emit_ack() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let old = DeviceFixture::new(1); + let current = DeviceFixture::new(2); + let intruder = DeviceFixture::new(3); + seed_peer(&actor, &identity, &[&old, ¤t]).await; + let registry = NativeChatRegistry::default(); + let removal = + wire::encode_device_removed_message("remove-old", fixture.timestamp, &old.account()) + .unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + request( + &actor, + &identity, + ¤t, + "authorized-revocation", + &[removal] + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + let before = actor.public_view(&fixture.context, vec![]).await.unwrap(); + let queued = actor + .store + .read(|state| { + state + .outbox + .iter() + .map(|entry| entry.request_id.clone()) + .collect::>() + }) + .await + .unwrap(); + let forged_control = wire::encode_device_added_message( + "intruder", + fixture.timestamp + 1, + &intruder.account(), + &intruder.public_key(), + ) + .unwrap(); + let delayed = wire::encode_rich_text_message( + "delayed", + (current_unix_secs() - LIFETIME - 1) * 1000, + Some("old but authentic plaintext"), + None, + ) + .unwrap(); + let future = wire::encode_rich_text_message( + "future", + (current_unix_secs() + CLOCK_SKEW + 60) * 1000, + Some("invalid future plaintext"), + None, + ) + .unwrap(); + let expired = resign_with_expiry( + ¤t, + request(&actor, &identity, ¤t, "expired-request", &[delayed]), + (current_unix_secs() - 1) << 32, + ); + let mut tampered = request( + &actor, + &identity, + ¤t, + "invalid-signature", + &[future.clone()], + ); + tampered.data.as_mut().unwrap()[0] ^= 1; + let resurrect = wire::encode_multi_chat_accepted_message( + "late-acceptance", + fixture.timestamp + 1, + "old-invitation", + &wire::V2PeerDevice { + statement_account_id: old.account(), + encryption_public_key: old.public_key(), + }, + ) + .unwrap(); + let attacks = [ + request( + &actor, + &identity, + &old, + "revoked-control", + &[forged_control], + ), + expired, + request(&actor, &identity, ¤t, "future-request", &[future]), + tampered, + native_packet( + &actor, + &identity, + &old, + &wire::encode_transport_request_plaintext("revoked-root-acceptance", &[resurrect]) + .unwrap(), + false, + true, + ), + acknowledgment( + &actor, + &identity, + &old, + &outgoing(&actor, OutgoingKind::Revocation).await.request_id, + false, + ), + ]; + for packet in attacks { + assert_eq!( + actor.receive(&fixture.context, ®istry, packet).await, + Err(Error::InvalidStatement) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + before + ); + assert_eq!( + actor + .store + .read(|state| state + .outbox + .iter() + .map(|entry| entry.request_id.clone()) + .collect::>()) + .await + .unwrap(), + queued, + "an unauthenticated, expired, or future exchange must not enqueue an ACK" + ); + } + }); +} + +#[test] +fn ordinary_native_delivery_and_ack_work_but_guest_cannot_send_payment_or_control() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + let before = actor.public_view(&fixture.context, vec![]).await.unwrap(); + let keys: Vec<_> = memo() + .entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect(); + let blocked = [ + wire::encode_coinage_send_message("guest-payment", fixture.timestamp, "250", &keys) + .unwrap(), + wire::encode_device_removed_message( + "guest-revocation", + fixture.timestamp, + &peer.account(), + ) + .unwrap(), + wire::encode_device_added_message( + "guest-admission", + fixture.timestamp, + &peer.account(), + &peer.public_key(), + ) + .unwrap(), + ]; + let ordinary = wire::encode_rich_text_message( + "ordinary-message", + fixture.timestamp, + Some("native hello"), + None, + ) + .unwrap(); + for (index, forbidden) in blocked.into_iter().enumerate() { + assert_eq!( + actor + .send( + &fixture.context, + identity.account, + format!("guest-injection-{index}"), + vec![ordinary.clone(), forbidden] + ) + .await, + Err(Error::InvalidRequest) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + before + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + } + assert_eq!( + actor + .send( + &fixture.context, + identity.account, + "ordinary-send".into(), + vec![ordinary.clone()] + ) + .await, + Err(Error::NetworkUnavailable) + ); + let outgoing = outgoing(&actor, OutgoingKind::Ordinary).await; + let wire::V2StatementTransportData::MultiRequest(native) = + open_output(&actor, &identity, &outgoing.statement, false, false) + else { + panic!("ordinary guest send must use native multi-device request") + }; + let body = open_body( + &actor, + &peer, + &native.encrypted_request, + &native.devices_info, + ); + let native = wire::decode_message_exchange_request_plaintext(&body).unwrap(); + assert_eq!(native.request_id, outgoing.request_id); + assert_ne!( + native.request_id, "ordinary-send", + "guest correlation IDs cannot select a Host transport operation" + ); + assert_eq!(native.messages, vec![ordinary.clone()]); + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &peer, &native.request_id, false), + ) + .await + .unwrap(); + assert_eq!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id: "ordinary-send".into(), + response_code: 0 + }] + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + + let incoming = request( + &actor, + &identity, + &peer, + "native-incoming", + &[ordinary.clone()], + ); + assert_eq!( + actor + .receive(&fixture.context, ®istry, incoming.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "native-incoming".into(), + messages: vec![ordinary], + }] + ); + let ack = actor + .store + .read(|state| { + state + .outbox + .iter() + .find(|entry| entry.kind == OutgoingKind::Acknowledgment) + .unwrap() + .statement + .clone() + }) + .await + .unwrap(); + let wire::V2StatementTransportData::MultiResponse(native) = + open_output(&actor, &identity, &ack, true, false) + else { + panic!("native incoming message must get a native multi-device ACK") + }; + let body = open_body( + &actor, + &peer, + &native.encrypted_response, + &native.devices_info, + ); + assert_eq!( + wire::decode_message_exchange_response_plaintext(&body).unwrap(), + wire::V2MessageExchangeResponse { + request_id: "native-incoming".into(), + response_code: 0, + } + ); + // An offline ACK persisted by the old Host used the requester's route. + // Replaying the authenticated request must repair that queued response. + let old_shared = + wire::x25519_shared_secret(&peer.secret, &actor.public.identity_chat_public_key) + .unwrap(); + let old_topic = route( + &old_shared, + &peer.account(), + &actor.public.identity_account_id, + ); + let old_data = wire::encrypt_multi_device_payload_with_nonce( + &wire::hkdf_sha256_32(&old_shared).unwrap(), + &wire::encode_transport_multi_response_plaintext(&native).unwrap(), + [0x27; 12], + ) + .unwrap(); + let signing_actor = actor.clone(); + actor + .store + .update(move |state| { + let statement = signing_actor.sign( + state, + wire::chat_identity_response_topic(&old_topic).unwrap(), + vec![old_topic], + old_data, + )?; + state + .outbox + .iter_mut() + .find(|entry| entry.kind == OutgoingKind::Acknowledgment) + .unwrap() + .statement = statement; + Ok(()) + }) + .await + .unwrap(); + assert_eq!( + actor.receive(&fixture.context, ®istry, incoming).await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + view, + "retrying authenticated native delivery cannot duplicate the conversation" + ); + let repaired = self::outgoing(&actor, OutgoingKind::Acknowledgment).await; + let wire::V2StatementTransportData::MultiResponse(native) = + open_output(&actor, &identity, &repaired.statement, true, false) + else { + panic!("replayed request must produce a native-decodable response") + }; + let body = open_body( + &actor, + &peer, + &native.encrypted_response, + &native.devices_info, + ); + assert_eq!( + wire::decode_message_exchange_response_plaintext(&body).unwrap(), + wire::V2MessageExchangeResponse { + request_id: "native-incoming".into(), + response_code: 0, + } + ); + assert!( + fixture + .platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn refreshed_statement_delivers_old_admitted_peer_messages_without_rewriting_them() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let admitted_at = fixture.timestamp; + actor + .store + .update(move |state| { + state.peers[0].devices[0].timestamp = admitted_at; + Ok(()) + }) + .await + .unwrap(); + let registry = NativeChatRegistry::default(); + let message = wire::encode_rich_text_message( + "queued-offline", + fixture.timestamp - (LIFETIME + 86_400) * 1000, + Some("delayed native message"), + None, + ) + .unwrap(); + let old_departure = wire::encode_left_chat_message( + "earlier-departure", + fixture.timestamp - (LIFETIME + 86_400) * 1000, + ) + .unwrap(); + let packet = request( + &actor, + &identity, + &peer, + "offline-request", + &[old_departure, message.clone()], + ); + let expired = resign_with_expiry(&peer, packet, (current_unix_secs() - 1) << 32); + assert_eq!( + actor + .receive(&fixture.context, ®istry, expired.clone()) + .await, + Err(Error::InvalidStatement) + ); + assert!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .messages + .is_empty() + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + let refreshed = resign_with_expiry( + &peer, + expired.clone(), + (current_unix_secs() + LIFETIME) << 32, + ); + assert_eq!( + refreshed.data, expired.data, + "refresh changes only the signed expiry, not old message content" + ); + assert_eq!( + actor + .receive(&fixture.context, ®istry, refreshed.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "offline-request".into(), + messages: vec![message] + }] + ); + assert_eq!( + view.peers[0].devices, + vec![HostNativeChatPeerDevice { + account_id: peer.account(), + chat_public_key: peer.public_key(), + }], + "an old departure cannot roll back a later authenticated admission" + ); + assert_eq!( + outgoing(&actor, OutgoingKind::Acknowledgment) + .await + .request_id, + "offline-request" + ); + assert_eq!( + actor.receive(&fixture.context, ®istry, refreshed).await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + view + ); + }); +} + +#[test] +fn native_acceptance_with_push_tokens_keeps_metadata_private_and_replay_bound() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_outgoing_invitation(&actor, &identity, &[], fixture.timestamp).await; + let registry = NativeChatRegistry::default(); + let accepted = wire::encode_multi_chat_accepted_message( + "accepted", + fixture.timestamp, + "pending-invitation", + &wire::V2PeerDevice { + statement_account_id: peer.account(), + encryption_public_key: peer.public_key(), + }, + ) + .unwrap(); + let ordinary = + wire::encode_rich_text_message("reply", fixture.timestamp, Some("native reply"), None) + .unwrap(); + let token = wire::encode_token_message( + "ios-token", + fixture.timestamp, + &[0xa1; 32], + wire::V2PushPlatform::Ios, + ) + .unwrap(); + let voip = wire::encode_token_message( + "voip-token", + fixture.timestamp, + &[0xa2; 32], + wire::V2PushPlatform::IosVoip, + ) + .unwrap(); + let mut messages = vec![accepted, token, voip, ordinary.clone()]; + let packet = |messages: &[Vec]| { + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext("native-acceptance", messages).unwrap(), + false, + true, + ) + }; + let before = actor.public_view(&fixture.context, vec![]).await.unwrap(); + messages[1].push(0); + assert_eq!( + actor + .receive(&fixture.context, ®istry, packet(&messages)) + .await, + Err(Error::InvalidStatement) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + before + ); + messages[1].pop(); + let valid_packet = packet(&messages); + assert_eq!( + actor + .receive(&fixture.context, ®istry, valid_packet.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.peers[0].devices, + vec![HostNativeChatPeerDevice { + account_id: peer.account(), + chat_public_key: peer.public_key(), + }] + ); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "native-acceptance".into(), + messages: vec![ordinary], + }] + ); + assert_eq!( + actor + .receive(&fixture.context, ®istry, valid_packet) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + view + ); + messages[1] = wire::encode_token_message( + "ios-token", + fixture.timestamp, + &[0xa3; 32], + wire::V2PushPlatform::Ios, + ) + .unwrap(); + assert_eq!( + actor + .receive(&fixture.context, ®istry, packet(&messages)) + .await, + Err(Error::InvalidStatement) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + view + ); + }); +} + +#[test] +fn acceptance_batch_authenticates_every_control_before_wallet_or_roster_effects() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + let intruder = DeviceFixture::new(2); + seed_outgoing_invitation(&actor, &identity, &[], fixture.timestamp).await; + let registry = NativeChatRegistry::default(); + let accepted = wire::encode_multi_chat_accepted_message( + "accepted", + fixture.timestamp, + "pending-invitation", + &wire::V2PeerDevice { + statement_account_id: peer.account(), + encryption_public_key: peer.public_key(), + }, + ) + .unwrap(); + let ordinary = wire::encode_rich_text_message( + "hello", + fixture.timestamp, + Some("not visible on rejection"), + None, + ) + .unwrap(); + let keys = memo() + .entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect::>(); + let payment = + wire::encode_coinage_send_message("private-payment", fixture.timestamp, "250", &keys) + .unwrap(); + let rebound = wire::encode_device_added_message( + "rebound-key", + fixture.timestamp + 1, + &peer.account(), + &intruder.public_key(), + ) + .unwrap(); + let before = actor.public_view(&fixture.context, vec![]).await.unwrap(); + let attacks = [ + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext( + "late-invalid-control", + &[payment.clone(), ordinary.clone(), accepted.clone(), rebound], + ) + .unwrap(), + false, + true, + ), + native_packet( + &actor, + &identity, + &intruder, + &wire::encode_transport_request_plaintext( + "wrong-acceptance-signer", + &[accepted, payment, ordinary], + ) + .unwrap(), + false, + true, + ), + ]; + for packet in attacks { + assert_eq!( + actor.receive(&fixture.context, ®istry, packet).await, + Err(Error::InvalidStatement) + ); + assert_eq!( + actor.public_view(&fixture.context, vec![]).await.unwrap(), + before + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + assert!( + fixture.platform.chain_connects.lock().unwrap().is_empty(), + "invalid controls must fail before Coinage effects" + ); + } + }); +} + +#[test] +fn contact_added_resolves_only_an_admitted_peers_pending_invitation() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + let intruder = DeviceFixture::new(2); + seed_outgoing_invitation(&actor, &identity, &[&peer], fixture.timestamp).await; + let registry = NativeChatRegistry::default(); + let contact = + wire::encode_contact_added_message("contact-added", fixture.timestamp - 1000).unwrap(); + let forged = wire::encode_device_added_message( + "self-admission", + fixture.timestamp, + &intruder.account(), + &intruder.public_key(), + ) + .unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + native_packet( + &actor, + &identity, + &intruder, + &wire::encode_transport_request_plaintext( + "unbound-contact", + &[contact.clone(), forged] + ) + .unwrap(), + false, + true + ) + ) + .await, + Err(Error::InvalidStatement) + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + let too_new = + wire::encode_contact_added_message("later-contact", fixture.timestamp + 1000).unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext( + "uncorrelated-contact", + &[too_new] + ) + .unwrap(), + false, + true + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor + .store + .read(|state| state.peers[0].invitation.clone()) + .await + .unwrap() + .as_deref(), + Some("pending-invitation") + ); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext("correlated-contact", &[contact]) + .unwrap(), + false, + true + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert!( + view.messages.is_empty(), + "lifecycle notifications are not guest messages" + ); + assert_eq!( + view.peers[0].devices, + vec![HostNativeChatPeerDevice { + account_id: peer.account(), + chat_public_key: peer.public_key() + }] + ); + assert_eq!( + view.acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id: "pending-invitation".into(), + response_code: 0, + }] + ); + assert!( + actor + .store + .read(|state| state.peers[0].invitation.is_none() + && state.peers[0].invitation_timestamp.is_none()) + .await + .unwrap() + ); + }); +} + +#[test] +fn a_later_departure_blocks_contact_added_acceptance_in_the_same_batch() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_outgoing_invitation(&actor, &identity, &[&peer], fixture.timestamp).await; + let registry = NativeChatRegistry::default(); + let contact = + wire::encode_contact_added_message("contact-added", fixture.timestamp - 1000).unwrap(); + let left = wire::encode_left_chat_message("left-chat", fixture.timestamp).unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext( + "contact-and-departure", + &[left, contact] + ) + .unwrap(), + false, + true + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert!(view.messages.is_empty()); + assert!( + view.acknowledgments.is_empty(), + "transport ACK must not imply invitation acceptance" + ); + assert!(view.peers[0].devices.is_empty()); + assert_eq!( + actor + .store + .read(|state| state.peers[0].invitation.clone()) + .await + .unwrap() + .as_deref(), + Some("pending-invitation") + ); + }); +} + +#[test] +fn mixed_acceptance_batch_waits_for_every_claim_plan_and_replays_after_reopen() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + let second_device = DeviceFixture::new(2); + seed_outgoing_invitation(&actor, &identity, &[], fixture.timestamp).await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let first_memo = TransferMemo { + entries: vec![MemoEntry(keypair(0x31).secret.to_bytes())], + total_value: 160, + }; + let second_memo = TransferMemo { + entries: vec![MemoEntry(keypair(0x32).secret.to_bytes())], + total_value: 80, + }; + let first = wallet + .seed_incoming_for_test( + &fixture.context, + PRODUCT, + identity.account, + "mixed-native-request", + "first-payment", + fixture.timestamp, + &first_memo, + true, + ) + .await + .unwrap(); + let second = wallet + .seed_incoming_for_test( + &fixture.context, + PRODUCT, + identity.account, + "mixed-native-request", + "second-payment", + fixture.timestamp, + &second_memo, + false, + ) + .await + .unwrap(); + let accepted = wire::encode_multi_chat_accepted_message( + "accepted", + fixture.timestamp, + "pending-invitation", + &wire::V2PeerDevice { + statement_account_id: peer.account(), + encryption_public_key: peer.public_key(), + }, + ) + .unwrap(); + let historical = wire::encode_multi_chat_accepted_message( + "previous-acceptance", + fixture.timestamp - 1000, + "previous-invitation", + &wire::V2PeerDevice { + statement_account_id: peer.account(), + encryption_public_key: peer.public_key(), + }, + ) + .unwrap(); + let added = wire::encode_device_added_message( + "second-device", + fixture.timestamp + 1, + &second_device.account(), + &second_device.public_key(), + ) + .unwrap(); + let ordinary = wire::encode_rich_text_message( + "welcome", + fixture.timestamp, + Some("native batched greeting"), + None, + ) + .unwrap(); + let first_wire = wire::encode_coinage_send_message( + "first-payment", + fixture.timestamp, + "160", + &[first_memo.entries[0].0.to_vec()], + ) + .unwrap(); + let second_wire = wire::encode_coinage_send_message( + "second-payment", + fixture.timestamp, + "80", + &[second_memo.entries[0].0.to_vec()], + ) + .unwrap(); + let mut messages = vec![ + ordinary.clone(), + first_wire, + added, + historical, + accepted, + second_wire, + ]; + let packet = native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext("mixed-native-request", &messages).unwrap(), + false, + true, + ); + let before = actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(); + assert_eq!( + actor + .receive(&fixture.context, ®istry, packet.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(), + before, + "durable first claim cannot admit a roster, expose text, or acknowledge an unplanned second claim" + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + + wallet + .persist_incoming_plan_for_test(&second_memo) + .await + .unwrap(); + assert_eq!( + actor + .receive(&fixture.context, ®istry, packet.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + let cards = wallet.views(PRODUCT).await.unwrap(); + assert_eq!(cards.len(), 2); + assert!( + cards.contains(&first) && cards.contains(&second), + "same native request carries two independent payment identities" + ); + let view = actor.public_view(&fixture.context, cards).await.unwrap(); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "mixed-native-request".into(), + messages: vec![ordinary] + }] + ); + assert_eq!( + view.peers[0].devices, + vec![ + HostNativeChatPeerDevice { + account_id: peer.account(), + chat_public_key: peer.public_key() + }, + HostNativeChatPeerDevice { + account_id: second_device.account(), + chat_public_key: second_device.public_key() + }, + ] + ); + assert_eq!( + view.acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id: "pending-invitation".into(), + response_code: 0, + }] + ); + let ack = outgoing(&actor, OutgoingKind::Acknowledgment).await; + assert_eq!( + open_output(&actor, &identity, &ack.statement, true, true), + wire::V2StatementTransportData::Response { + request_id: "mixed-native-request".into(), + response_code: 0 + } + ); + messages[0] = wire::encode_rich_text_message( + "welcome", + fixture.timestamp, + Some("mutated retry"), + None, + ) + .unwrap(); + assert_eq!( + actor + .receive( + &fixture.context, + ®istry, + native_packet( + &actor, + &identity, + &peer, + &wire::encode_transport_request_plaintext( + "mixed-native-request", + &messages + ) + .unwrap(), + false, + true + ) + ) + .await, + Err(Error::InvalidStatement) + ); + assert_eq!( + actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(), + view + ); + fixture.tasks.stop(); + drop(wallet); + drop(registry); + drop(actor); + + let restarted = Fixture::on_platform(fixture.platform.clone()); + let actor = restarted.actor().await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&restarted.context).await.unwrap(); + assert_eq!( + actor + .public_view(&restarted.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(), + view + ); + assert_eq!( + actor.receive(&restarted.context, ®istry, packet).await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + actor + .public_view(&restarted.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(), + view, + "restored batch retries cannot duplicate payments, acceptance, or visible conversation" + ); + }); +} + +#[test] +fn an_ordinary_ack_cannot_acknowledge_a_payment_with_a_colliding_guest_request_id() { + block_on(async { + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let card = wallet + .seed_accepted_for_test( + &fixture.context, + payment_intent(&identity), + fixture.timestamp, + &memo(), + ) + .await + .unwrap(); + transport(&actor, &fixture, &identity) + .accept(&card, memo()) + .await + .unwrap(); + let payment = outgoing(&actor, OutgoingKind::Payment(card.operation_id)).await; + let ordinary_message = wire::encode_rich_text_message( + "ordinary-collision", + fixture.timestamp, + Some("not a payment"), + None, + ) + .unwrap(); + assert_eq!( + actor + .send( + &fixture.context, + identity.account, + payment.request_id.clone(), + vec![ordinary_message] + ) + .await, + Err(Error::NetworkUnavailable) + ); + let ordinary = outgoing(&actor, OutgoingKind::Ordinary).await; + assert_ne!(ordinary.request_id, payment.request_id); + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &peer, &ordinary.request_id, false), + ) + .await + .unwrap(); + assert_eq!( + wallet.views(PRODUCT).await.unwrap(), + vec![card.clone()], + "acknowledging ordinary text cannot mark the colliding payment delivered" + ); + assert_eq!( + outgoing(&actor, OutgoingKind::Payment(card.operation_id)) + .await + .request_id, + payment.request_id + ); + assert!( + actor + .store + .read(|state| state + .outbox + .iter() + .all(|entry| entry.kind != OutgoingKind::Ordinary)) + .await + .unwrap() + ); + assert_eq!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .acknowledgments, + vec![HostNativeChatAcknowledgment { + peer_identity: identity.account, + request_id: payment.request_id.clone(), + response_code: 0 + }] + ); + actor + .receive( + &fixture.context, + ®istry, + acknowledgment(&actor, &identity, &peer, &payment.request_id, false), + ) + .await + .unwrap(); + assert_eq!( + wallet.views(PRODUCT).await.unwrap(), + vec![HostNativeChatPayment { + state: HostNativeChatPaymentState::Delivered, + ..card + }] + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap() + ); + }); +} + +async fn set_background_grants( + platform: &StubPlatform, + submit: truapi_platform::PermissionAuthorizationStatus, +) { + set_product_background_grants(platform, PRODUCT, submit).await; +} + +async fn set_product_background_grants( + platform: &StubPlatform, + product: &str, + submit: truapi_platform::PermissionAuthorizationStatus, +) { + use crate::host_logic::permissions::PermissionsService; + use truapi_platform::{PermissionAuthorizationRequest, PermissionAuthorizationStatus}; + let permissions = PermissionsService::new(platform, platform, product); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); + permissions + .set_authorization_status( + &PermissionAuthorizationRequest::Remote(RemotePermissionRequest { + permission: RemotePermission::StatementSubmit, + }), + submit, + ) + .await + .unwrap(); +} + +fn notification(statement: SignedStatement) -> serde_json::Value { + serde_json::json!({ + "event": "newStatements", + "data": { + "statements": [format!("0x{}", hex::encode(signed_statement_to_scale(statement).unwrap()))], + "remaining": 0, + } + }) +} + +#[test] +fn background_notification_keeps_claim_before_ack_and_rechecks_grants() { + block_on(async { + use super::super::background::receive_notification; + use truapi_platform::PermissionAuthorizationStatus; + let fixture = Fixture::new(); + set_background_grants(&fixture.platform, PermissionAuthorizationStatus::Authorized).await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + // Two source keys correspond to the 160 + 80 native denominations. + let mut memo = memo(); + memo.total_value = 240; + let card = wallet + .seed_incoming_for_test( + &fixture.context, + PRODUCT, + identity.account, + "background-request", + "background-payment", + fixture.timestamp, + &memo, + false, + ) + .await + .unwrap(); + let payment = wire::encode_coinage_send_message( + "background-payment", + fixture.timestamp, + &memo.total_value.to_string(), + &memo + .entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect::>(), + ) + .unwrap(); + let ordinary = wire::encode_rich_text_message( + "background-text", + fixture.timestamp, + Some("received without a guest"), + None, + ) + .unwrap(); + let packet = notification(request( + &actor, + &identity, + &peer, + "background-request", + &[payment, ordinary.clone()], + )); + receive_notification(&fixture.context, PRODUCT, &actor, ®istry, packet.clone()) + .await + .unwrap(); + assert!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .messages + .is_empty() + ); + assert!( + actor + .store + .read(|state| state.outbox.is_empty()) + .await + .unwrap(), + "an unplanned private claim must not emit an ACK" + ); + wallet.persist_incoming_plan_for_test(&memo).await.unwrap(); + receive_notification(&fixture.context, PRODUCT, &actor, ®istry, packet.clone()) + .await + .unwrap(); + let view = actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(); + assert_eq!(view.payments, vec![card]); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "background-request".into(), + messages: vec![ordinary], + }] + ); + let ack = outgoing(&actor, OutgoingKind::Acknowledgment).await; + assert_eq!(ack.request_id, "background-request"); + receive_notification(&fixture.context, PRODUCT, &actor, ®istry, packet.clone()) + .await + .unwrap(); + assert_eq!( + actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(), + view, + "replayed subscription pages must use durable receipts" + ); + set_background_grants(&fixture.platform, PermissionAuthorizationStatus::Denied).await; + assert_eq!( + receive_notification(&fixture.context, PRODUCT, &actor, ®istry, packet.clone()) + .await, + Err(Error::AccessNotGranted) + ); + fixture.tasks.live.store(false, Ordering::Release); + assert_eq!( + receive_notification(&fixture.context, PRODUCT, &actor, ®istry, packet).await, + Err(Error::NotConnected) + ); + assert!( + fixture + .platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + }); +} + +async fn await_background(future: impl Future) -> T { + use futures::future::{Either, select}; + let deadline = futures_timer::Delay::new(std::time::Duration::from_secs(5)); + futures::pin_mut!(future, deadline); + match select(future, deadline).await { + Either::Left((result, _)) => result, + Either::Right(_) => panic!("owned background task did not reach the expected state"), + } +} + +#[test] +fn owned_subscription_survives_guest_return_and_stops_on_revocation_and_replacement() { + block_on(async { + use truapi_platform::PermissionAuthorizationStatus; + let seed = Fixture::new(); + let actor = seed.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let ordinary = wire::encode_rich_text_message( + "owned-text", + seed.timestamp, + Some("arrived after Initialize returned"), + None, + ) + .unwrap(); + let statement = request( + &actor, + &identity, + &peer, + "owned-request", + &[ordinary.clone()], + ); + let platform = Arc::new(StubPlatform { + local_storage: seed.platform.local_storage.clone(), + rpc_responses: vec![ + crate::test_support::subscribe_ack_frame("truapi:1", "owned-chat"), + crate::test_support::new_statements_frame( + "owned-chat", + vec![signed_statement_to_scale(statement).unwrap()], + ), + ], + ..Default::default() + }); + drop(actor); + seed.tasks.stop(); + let fixture = Fixture::on_platform(platform.clone()); + set_background_grants(&platform, PermissionAuthorizationStatus::Authorized).await; + let registry = NativeChatRegistry::default(); + let first = registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + let actor = registry.chat(&fixture.context, PRODUCT).await.unwrap(); + // No product connection or Receive call exists for the incoming packet. + let view = await_background(async { + loop { + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + if !view.messages.is_empty() { + break view; + } + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert_eq!(view.device, first.device); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "owned-request".into(), + messages: vec![ordinary], + }] + ); + registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + set_background_grants(&platform, PermissionAuthorizationStatus::Denied).await; + await_background(async { + while fixture.context.services.worker_ledger.count(PRODUCT) != 0 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + let subscriptions = || { + platform + .sent_rpc + .lock() + .unwrap() + .iter() + .filter(|request| { + serde_json::from_str::(request).unwrap()["method"] + == "statement_subscribeStatement" + }) + .count() + }; + assert_eq!( + subscriptions(), + 1, + "repeated Initialize must not duplicate subscriptions" + ); + assert!( + platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + set_background_grants(&platform, PermissionAuthorizationStatus::Authorized).await; + let mut replacement = fixture.context.clone(); + replacement.session.validation_id = vec![2]; + let live = Arc::new(AtomicBool::new(true)); + let valid = live.clone(); + replacement.session_valid = Arc::new(move || valid.load(Ordering::Acquire)); + fixture.tasks.live.store(false, Ordering::Release); + registry.stop_receiving(); + registry.resume_receiving(replacement.clone()); + await_background(async { + while subscriptions() != 2 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert_eq!( + actor + .public_view(&replacement, vec![]) + .await + .unwrap() + .messages, + view.messages + ); + live.store(false, Ordering::Release); + registry.stop_receiving(); + await_background(async { + while replacement.services.worker_ledger.count(PRODUCT) != 0 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + }); +} + +#[test] +fn cold_restart_restores_consented_receive_without_product_launch_and_honors_forget() { + block_on(async { + use truapi_platform::PermissionAuthorizationStatus; + let seed = Fixture::new(); + set_background_grants(&seed.platform, PermissionAuthorizationStatus::Authorized).await; + let first_registry = NativeChatRegistry::default(); + let first = first_registry + .execute( + seed.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + let actor = first_registry.chat(&seed.context, PRODUCT).await.unwrap(); + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let ordinary = wire::encode_rich_text_message( + "restart-text", + seed.timestamp, + Some("arrived before any product was reopened"), + None, + ) + .unwrap(); + let statement = request( + &actor, + &identity, + &peer, + "restart-request", + &[ordinary.clone()], + ); + let platform = Arc::new(StubPlatform { + local_storage: seed.platform.local_storage.clone(), + rpc_responses: vec![ + crate::test_support::subscribe_ack_frame("truapi:1", "restarted-chat"), + crate::test_support::new_statements_frame( + "restarted-chat", + vec![signed_statement_to_scale(statement).unwrap()], + ), + ], + ..Default::default() + }); + first_registry.stop_receiving(); + seed.tasks.stop(); + drop(actor); + drop(first_registry); + + let fixture = Fixture::on_platform(platform.clone()); + let restored = NativeChatRegistry::default(); + // An actual new registry: no guest execution or Initialize after unlock. + restored.restore_receiving(&fixture.context).await.unwrap(); + let actor = restored.chat(&fixture.context, PRODUCT).await.unwrap(); + let view = await_background(async { + loop { + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + if !view.messages.is_empty() { + break view; + } + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert_eq!(view.device, first.device); + assert_eq!( + view.messages, + vec![HostNativeChatMessages { + peer_identity: identity.account, + incoming: true, + request_id: "restart-request".into(), + messages: vec![ordinary], + }] + ); + assert!( + platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + + restored + .forget_product(&fixture.context, PRODUCT) + .await + .unwrap(); + await_background(async { + while fixture.context.services.worker_ledger.count(PRODUCT) != 0 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + let forgotten = NativeChatRegistry::default(); + forgotten.restore_receiving(&fixture.context).await.unwrap(); + assert_eq!(fixture.context.services.worker_ledger.count(PRODUCT), 0); + assert_eq!( + actor + .public_view(&fixture.context, vec![]) + .await + .unwrap() + .messages, + view.messages, + "forgetting reception must not erase durable history or custody" + ); + }); +} + +#[test] +fn cold_restart_does_not_restore_revoked_or_missing_chat_devices() { + block_on(async { + use truapi_platform::PermissionAuthorizationStatus; + let fixture = Fixture::new(); + let registry = NativeChatRegistry::default(); + registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + set_background_grants(&fixture.platform, PermissionAuthorizationStatus::Denied).await; + let restored = NativeChatRegistry::default(); + restored.restore_receiving(&fixture.context).await.unwrap(); + assert_eq!(fixture.context.services.worker_ledger.count(PRODUCT), 0); + assert!( + fixture + .platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + + set_background_grants(&fixture.platform, PermissionAuthorizationStatus::Authorized).await; + truapi_platform::CoreStorage::clear_core_storage( + fixture.platform.as_ref(), + CoreStorageKey::NativeChatDevice { + root_public_key: fixture.context.session.public_key, + genesis_hash: fixture.context.genesis_hash, + product_id: PRODUCT.into(), + }, + ) + .await + .unwrap(); + assert!(matches!( + restored.restore_receiving(&fixture.context).await, + Err(Error::StorageUnavailable) + )); + assert_eq!(fixture.context.services.worker_ledger.count(PRODUCT), 0); + }); +} + +#[test] +fn retryable_open_and_session_release_preserve_identity_without_retaining_owners() { + block_on(async { + let fixture = Fixture::new(); + let registry = NativeChatRegistry::default(); + let key = core_storage_test_key(CoreStorageKey::NativeChatDevice { + root_public_key: fixture.context.session.public_key, + genesis_hash: fixture.context.genesis_hash, + product_id: PRODUCT.into(), + }); + fixture.platform.core_read_failures.lock().insert(key); + assert!(matches!( + registry.chat(&fixture.context, PRODUCT).await, + Err(Error::StorageUnavailable) + )); + fixture.platform.core_read_failures.lock().clear(); + let actor = registry.chat(&fixture.context, PRODUCT).await.unwrap(); + let device = actor.public.clone(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let actor_lifetime = Arc::downgrade(&actor); + let wallet_lifetime = Arc::downgrade(&wallet); + fixture.tasks.live.store(false, Ordering::Release); + registry.release(); + assert!(matches!( + registry.chat(&fixture.context, PRODUCT).await, + Err(Error::NotConnected) + )); + let restarted = Fixture::on_platform(fixture.platform.clone()); + // An old operation retaining either owner must exclude a new allocator. + assert!(matches!( + registry.chat(&restarted.context, PRODUCT).await, + Err(Error::StorageUnavailable) + )); + assert!(matches!( + registry.wallet(&restarted.context).await, + Err(Error::StorageUnavailable) + )); + drop(actor); + drop(wallet); + assert!(actor_lifetime.upgrade().is_none()); + assert!(wallet_lifetime.upgrade().is_none()); + assert_eq!( + registry + .chat(&restarted.context, PRODUCT) + .await + .unwrap() + .public, + device + ); + registry.wallet(&restarted.context).await.unwrap(); + }); +} + +#[test] +fn cold_restore_reports_bad_devices_but_starts_every_valid_product() { + block_on(async { + use truapi_platform::PermissionAuthorizationStatus; + let fixture = Fixture::new(); + let registry = NativeChatRegistry::default(); + for product in ["aaa.dot", "bbb.dot", PRODUCT] { + set_product_background_grants( + &fixture.platform, + product, + PermissionAuthorizationStatus::Authorized, + ) + .await; + registry.chat(&fixture.context, product).await.unwrap(); + registry + .remember_product(&fixture.context, product) + .await + .unwrap(); + } + let device = registry + .chat(&fixture.context, PRODUCT) + .await + .unwrap() + .public + .clone(); + registry.release(); + let key = |product: &str| { + core_storage_test_key(CoreStorageKey::NativeChatDevice { + root_public_key: fixture.context.session.public_key, + genesis_hash: fixture.context.genesis_hash, + product_id: product.into(), + }) + }; + fixture + .platform + .local_storage + .lock() + .unwrap() + .remove(&key("aaa.dot")); + fixture + .platform + .local_storage + .lock() + .unwrap() + .insert(key("bbb.dot"), vec![0xff]); + assert_eq!( + registry.restore_receiving(&fixture.context).await, + Err(Error::StorageUnavailable) + ); + await_background(async { + while fixture.context.services.worker_ledger.count(PRODUCT) != 1 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert_eq!( + registry + .chat(&fixture.context, PRODUCT) + .await + .unwrap() + .public, + device + ); + assert_eq!(fixture.context.services.worker_ledger.count("aaa.dot"), 0); + assert_eq!(fixture.context.services.worker_ledger.count("bbb.dot"), 0); + let stored = fixture.platform.local_storage.lock().unwrap(); + assert!(!stored.contains_key(&key("aaa.dot"))); + assert_eq!(stored.get(&key("bbb.dot")), Some(&vec![0xff])); + }); +} + +#[test] +fn authorization_storage_outage_pauses_and_recovers_without_reopening_then_denial_stops() { + block_on(async { + use super::super::background::{require_authorized, require_upload_authorized}; + use truapi_platform::PermissionAuthorizationStatus; + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + ..Default::default() + }); + let fixture = Fixture::on_platform(platform.clone()); + set_background_grants(&platform, PermissionAuthorizationStatus::Authorized).await; + let registry = NativeChatRegistry::default(); + registry + .execute( + fixture.context.clone(), + PRODUCT.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + await_background(async { + while platform.chain_connects.lock().unwrap().len() != 1 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + platform + .core_read_failures + .lock() + .insert(core_storage_test_key( + CoreStorageKey::remote_permission_authorization( + PRODUCT, + &RemotePermissionRequest { + permission: RemotePermission::StatementSubmit, + }, + ), + )); + assert_eq!( + require_authorized(&fixture.context, PRODUCT).await, + Err(Error::StorageUnavailable) + ); + assert_eq!( + require_upload_authorized(&fixture.context, PRODUCT).await, + Err(Error::StorageUnavailable) + ); + await_background(async { + while !platform.pending_connect_dropped.load(Ordering::Acquire) { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert_eq!(fixture.context.services.worker_ledger.count(PRODUCT), 1); + platform.core_read_failures.lock().clear(); + await_background(async { + while platform.chain_connects.lock().unwrap().len() != 2 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + platform + .core_read_failures + .lock() + .insert(core_storage_test_key( + CoreStorageKey::remote_permission_authorization( + PRODUCT, + &RemotePermissionRequest { + permission: RemotePermission::PreimageSubmit, + }, + ), + )); + assert_eq!(require_authorized(&fixture.context, PRODUCT).await, Ok(())); + assert_eq!( + require_upload_authorized(&fixture.context, PRODUCT).await, + Err(Error::StorageUnavailable) + ); + platform.core_read_failures.lock().clear(); + set_background_grants(&platform, PermissionAuthorizationStatus::Denied).await; + assert_eq!( + require_authorized(&fixture.context, PRODUCT).await, + Err(Error::AccessNotGranted) + ); + await_background(async { + while fixture.context.services.worker_ledger.count(PRODUCT) != 0 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert!( + platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn failed_product_forget_rebinds_unrelated_receivers_without_readable_or_writable_index() { + block_on(async { + use truapi_platform::PermissionAuthorizationStatus; + for fail_read in [true, false] { + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + ..Default::default() + }); + let fixture = Fixture::on_platform(platform.clone()); + let registry = NativeChatRegistry::default(); + for product in ["aaa.dot", PRODUCT] { + set_product_background_grants( + &platform, + product, + PermissionAuthorizationStatus::Authorized, + ) + .await; + registry + .execute( + fixture.context.clone(), + product.into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + } + await_background(async { + while platform.chain_connects.lock().unwrap().len() != 2 { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + set_product_background_grants( + &platform, + "aaa.dot", + PermissionAuthorizationStatus::Denied, + ) + .await; + let mut replacement = fixture.context.clone(); + replacement.session.validation_id = vec![2]; + replacement.session_valid = Arc::new(|| true); + fixture.tasks.live.store(false, Ordering::Release); + let index = core_storage_test_key(NativeChatRegistry::products_key(&replacement)); + if fail_read { + platform.core_read_failures.lock().insert(index); + } else { + platform.core_write_failures.lock().insert(index); + } + assert_eq!( + registry.forget_product(&replacement, "aaa.dot").await, + Err(Error::StorageUnavailable) + ); + await_background(async { + while platform.chain_connects.lock().unwrap().len() < 3 + || replacement.services.worker_ledger.count(PRODUCT) != 1 + || replacement.services.worker_ledger.count("aaa.dot") != 0 + { + futures_timer::Delay::new(std::time::Duration::from_millis(1)).await; + } + }) + .await; + assert!( + platform + .remote_permission_requests + .lock() + .unwrap() + .is_empty() + ); + } + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history.rs new file mode 100644 index 000000000..4b2d66897 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history.rs @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: AGPL-3.0-only +use super::*; +mod attachments; +use crate::runtime::native_chat::hop::{ + self, FileTicket, HopClient, HopError, HopRpc, MultiSignature, MultiSigner, PreparedUpload, + SenderProof, SenderProofProviding, +}; +use futures::{StreamExt, channel::mpsc, stream::BoxStream}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, sync::atomic::AtomicUsize}; +use truapi::latest::GenericError; +use truapi_platform::{HopProvider, JsonRpcConnection, PermissionAuthorizationStatus}; + +const ENDPOINT: &str = "wss://history.fixture.invalid"; + +#[derive(Default)] +struct PoolState { + entries: Mutex>, + claims: AtomicUsize, + acknowledgments: AtomicUsize, + submissions: Mutex>, + reject_next_submit: AtomicBool, + expected_sender: Mutex>, +} +#[derive(Clone, Default)] +struct Pool(Arc); + +#[async_trait::async_trait] +impl HopRpc for Pool { + async fn call(&self, method: &str, params: Value) -> Result { + assert_eq!(method, "hop_submit"); + let data = params["data"].as_str().unwrap().to_owned(); + let encrypted = hex::decode(data.strip_prefix("0x").unwrap()).unwrap(); + let hash = format!("0x{}", hex::encode(hop::blake2b_256(&encrypted))); + self.0.entries.lock().insert(hash, data); + Ok( + json!({"poolStatus":{"entryCount":self.0.entries.lock().len(),"totalBytes":encrypted.len(),"maxBytes":4000000}}), + ) + } +} + +struct Sender; +#[async_trait::async_trait] +impl SenderProofProviding for Sender { + async fn proof(&self, hash: &[u8; 32]) -> Result { + let signer = keypair(0x61); + let submit_timestamp = 1770000000000; + Ok(SenderProof { + sender: MultiSigner::Sr25519(signer.public.to_bytes()), + signature: MultiSignature::Sr25519( + signer + .sign_simple( + b"substrate", + &hop::sender_proof_payload(hash, submit_timestamp), + ) + .to_bytes(), + ), + submit_timestamp, + }) + } +} + +impl Pool { + fn submit(&self, params: &Value) -> Value { + let data = params["data"].as_str().unwrap().to_owned(); + let encrypted = hex::decode(data.trim_start_matches("0x")).unwrap(); + let hash = hop::blake2b_256(&encrypted); + let signer = + hex::decode(params["signer"].as_str().unwrap().trim_start_matches("0x")).unwrap(); + let signature = hex::decode( + params["signature"] + .as_str() + .unwrap() + .trim_start_matches("0x"), + ) + .unwrap(); + assert_eq!(signer[0], 1); + assert_eq!(signature[0], 1); + let public = schnorrkel::PublicKey::from_bytes(&signer[1..]).unwrap(); + if let Some(expected) = *self.0.expected_sender.lock() { + assert_eq!(public.to_bytes(), expected); + } + public + .verify_simple( + b"substrate", + &hop::sender_proof_payload(&hash, params["submit_timestamp"].as_u64().unwrap()), + &schnorrkel::Signature::from_bytes(&signature[1..]).unwrap(), + ) + .unwrap(); + let hash = format!("0x{}", hex::encode(hash)); + self.0.submissions.lock().push(hash.clone()); + self.0.entries.lock().insert(hash, data); + json!({"poolStatus":{"entryCount":self.0.entries.lock().len(),"totalBytes":encrypted.len(),"maxBytes":16000000}}) + } + async fn compact( + &self, + id: &str, + timestamp: u64, + ticket: &FileTicket, + messages: &[Vec], + ) -> Vec { + let prepared = PreparedUpload::compaction(messages, ticket).unwrap(); + let submitted = HopClient::new(self) + .submit(&prepared, &Sender) + .await + .unwrap(); + wire::encode_compacted_messages_message( + id, + timestamp, + &submitted.hash, + ticket.as_bytes(), + &wire::V2NodeEndpoint::WssUrl(ENDPOINT.into()), + ) + .unwrap() + } +} + +#[async_trait::async_trait] +impl HopProvider for Pool { + async fn allowed_hop_endpoints(&self, genesis: [u8; 32]) -> Result, GenericError> { + assert_eq!(genesis, [3; 32]); + Ok(vec![ENDPOINT.into()]) + } + async fn connect_hop( + &self, + genesis: [u8; 32], + endpoint: String, + ) -> Result, GenericError> { + assert_eq!(genesis, [3; 32]); + assert_eq!(endpoint, ENDPOINT); + let (sender, receiver) = mpsc::unbounded(); + Ok(Box::new(Connection { + pool: self.clone(), + sender: Mutex::new(Some(sender)), + receiver: Mutex::new(Some(receiver)), + })) + } +} + +struct Connection { + pool: Pool, + sender: Mutex>>, + receiver: Mutex>>, +} +impl JsonRpcConnection for Connection { + fn send(&self, request: String) { + let request: Value = serde_json::from_str(&request).unwrap(); + // HOP uses named parameters, unlike the bitswap array parameters. + let hash = request["params"]["raw_hash"].as_str().unwrap_or(""); + let result = match request["method"].as_str().unwrap() { + "hop_submit" => { + let result = self.pool.submit(&request["params"]); + if self.pool.0.reject_next_submit.swap(false, Ordering::SeqCst) { + let response = json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32001,"message":"accepted but response lost"}}); + self.sender + .lock() + .as_ref() + .unwrap() + .unbounded_send(response.to_string()) + .unwrap(); + return; + } + Some(result) + } + "hop_claim" => { + self.pool.0.claims.fetch_add(1, Ordering::SeqCst); + self.pool + .0 + .entries + .lock() + .get(hash) + .cloned() + .map(Value::String) + } + "hop_ack" => { + self.pool.0.acknowledgments.fetch_add(1, Ordering::SeqCst); + self.pool.0.entries.lock().remove(hash); + Some(Value::Null) + } + other => panic!("unexpected HOP fixture method {other}"), + }; + let response = match result { + Some(result) => json!({"jsonrpc":"2.0","id":request["id"],"result":result}), + None => { + json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":1004,"message":"absent"}}) + } + }; + if let Some(sender) = self.sender.lock().as_ref() { + sender.unbounded_send(response.to_string()).unwrap(); + } + } + fn responses(&self) -> BoxStream<'static, String> { + self.receiver.lock().take().unwrap().boxed() + } + fn close(&self) { + self.sender.lock().take(); + } +} + +fn encoded_payment(id: &str, timestamp: u64, memo: &TransferMemo) -> Vec { + wire::encode_coinage_send_message( + id, + timestamp, + &memo.total_value.to_string(), + &memo + .entries + .iter() + .map(|entry| entry.0.to_vec()) + .collect::>(), + ) + .unwrap() +} + +#[test] +fn nested_history_keeps_all_claim_plans_before_ack_and_survives_pool_deletion() { + block_on(async { + let pool = Pool::default(); + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("history fixture has no Coinage RPC"), + hop_provider: Some(Arc::new(pool.clone())), + ..Default::default() + }); + let fixture = Fixture::on_platform(platform.clone()); + set_background_grants(&platform, PermissionAuthorizationStatus::Authorized).await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + let wallet = registry.wallet(&fixture.context).await.unwrap(); + let mut first = memo(); + first.total_value = 240; + let second = TransferMemo { + entries: vec![MemoEntry(keypair(0x33).secret.to_bytes())], + total_value: 80, + }; + wallet + .seed_incoming_for_test( + &fixture.context, + PRODUCT, + identity.account, + "original-first", + "first-payment", + fixture.timestamp, + &first, + true, + ) + .await + .unwrap(); + wallet + .seed_incoming_for_test( + &fixture.context, + PRODUCT, + identity.account, + "original-second", + "second-payment", + fixture.timestamp, + &second, + false, + ) + .await + .unwrap(); + let before = + wire::encode_text_message("before", fixture.timestamp, "Before history").unwrap(); + let middle = + wire::encode_text_message("middle", fixture.timestamp, "Nested history").unwrap(); + let after = wire::encode_text_message("after", fixture.timestamp, "After history").unwrap(); + let child_ticket = FileTicket::from_bytes(&[0xac; 32]).unwrap(); + let child = pool + .compact( + "child", + fixture.timestamp, + &child_ticket, + &[ + middle.clone(), + encoded_payment("second-payment", fixture.timestamp, &second), + ], + ) + .await; + let unauthorized_device = DeviceFixture::new(9); + let historical_control = wire::encode_device_added_message( + "old-device", + fixture.timestamp, + &unauthorized_device.account(), + &unauthorized_device.public_key(), + ) + .unwrap(); + let root_ticket = FileTicket::from_bytes(&[0xab; 32]).unwrap(); + let compacted = pool + .compact( + "root", + fixture.timestamp, + &root_ticket, + &[ + before.clone(), + child, + encoded_payment("first-payment", fixture.timestamp, &first), + historical_control, + after.clone(), + ], + ) + .await; + let packet = request( + &actor, + &identity, + &peer, + "history-request", + &[compacted.clone()], + ); + assert_eq!( + actor + .receive(&fixture.context, ®istry, packet.clone()) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!(pool.0.acknowledgments.load(Ordering::SeqCst), 0); + assert!( + actor + .store + .read(|state| state.outbox.is_empty() && state.messages.is_empty()) + .await + .unwrap() + ); + + wallet + .persist_incoming_plan_for_test(&second) + .await + .unwrap(); + // Statement delivery remains offline, but HOP custody now commits. + assert_eq!( + actor.receive(&fixture.context, ®istry, packet).await, + Err(Error::NetworkUnavailable) + ); + actor.acknowledge_history(&fixture.context).await.unwrap(); + assert_eq!(pool.0.acknowledgments.load(Ordering::SeqCst), 2); + assert!(pool.0.entries.lock().is_empty()); + let view = actor + .public_view(&fixture.context, wallet.views(PRODUCT).await.unwrap()) + .await + .unwrap(); + assert_eq!(view.messages[0].messages, vec![before, middle, after]); + assert_eq!(view.payments.len(), 2); + let public_bytes = view.encode(); + assert!( + !public_bytes + .windows(32) + .any(|part| part == root_ticket.as_bytes() || part == child_ticket.as_bytes()) + ); + for memo in [&first, &second] { + for source in &memo.entries { + assert!(!public_bytes.windows(64).any(|part| part == source.0)); + } + } + assert_eq!( + actor + .store + .read(|state| state + .peer(&identity.account) + .unwrap() + .active_devices() + .len()) + .await + .unwrap(), + 1 + ); + let claims = pool.0.claims.load(Ordering::SeqCst); + fixture.tasks.stop(); + drop(wallet); + drop(actor); + drop(registry); + let restarted = Fixture::on_platform(platform); + let actor = restarted.actor().await; + let registry = NativeChatRegistry::default(); + // A refreshed outer request must not fetch or re-ACK a deleted HOP entry. + let replay = request( + &actor, + &identity, + &peer, + "refreshed-history-request", + &[compacted], + ); + assert_eq!( + actor.receive(&restarted.context, ®istry, replay).await, + Err(Error::NetworkUnavailable) + ); + actor.acknowledge_history(&restarted.context).await.unwrap(); + assert_eq!(pool.0.claims.load(Ordering::SeqCst), claims); + assert_eq!(pool.0.acknowledgments.load(Ordering::SeqCst), 2); + assert_eq!( + actor + .public_view(&restarted.context, vec![]) + .await + .unwrap() + .messages, + view.messages + ); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history/attachments.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history/attachments.rs new file mode 100644 index 000000000..8d9128ca5 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests/hop_history/attachments.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: AGPL-3.0-only +use super::*; +use truapi_platform::{ + NativeChatFileExportRequest, NativeChatFilePickRequest, NativeChatFilesHost, + NativeChatPickedFile, PermissionAuthorizationRequest, +}; + +struct Files { + bytes: Vec, + picks: AtomicUsize, + released: AtomicBool, + output: Mutex>, + completed: AtomicBool, +} +impl Files { + fn new(bytes: Vec) -> Self { + Self { + bytes, + picks: AtomicUsize::new(0), + released: AtomicBool::new(false), + output: Mutex::new(Vec::new()), + completed: AtomicBool::new(false), + } + } +} +#[async_trait::async_trait] +impl NativeChatFilesHost for Files { + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, GenericError> { + assert_eq!(request.product_id, PRODUCT); + self.picks.fetch_add(1, Ordering::SeqCst); + Ok(vec![NativeChatPickedFile { + source_id: "immutable-fixture".into(), + metadata: HostNativeChatAttachmentMetadata { + mime_type: "image/png".into(), + size_bytes: self.bytes.len() as u32, + kind: HostNativeChatAttachmentKind::Image { + width: 320, + height: 240, + thumbnail: Some(b"LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_vec()), + }, + }, + }]) + } + async fn read_chat_file( + &self, + source: String, + offset: u64, + length: u32, + ) -> Result, GenericError> { + assert_eq!(source, "immutable-fixture"); + assert!(!self.released.load(Ordering::SeqCst)); + assert!(length as usize <= hop::HOP_CHUNK_BYTES); + Ok(self.bytes[offset as usize..offset as usize + length as usize].to_vec()) + } + async fn release_chat_file(&self, source: String) -> Result<(), GenericError> { + assert_eq!(source, "immutable-fixture"); + self.released.store(true, Ordering::SeqCst); + Ok(()) + } + async fn begin_chat_file_export( + &self, + _: NativeChatFileExportRequest, + ) -> Result, GenericError> { + self.output.lock().clear(); + self.completed.store(false, Ordering::SeqCst); + Ok(Some("trusted-output".into())) + } + async fn write_chat_file_export( + &self, + id: String, + offset: u64, + bytes: Vec, + ) -> Result<(), GenericError> { + assert_eq!(id, "trusted-output"); + let mut output = self.output.lock(); + assert_eq!(offset, output.len() as u64); + assert!(bytes.len() <= hop::HOP_CHUNK_BYTES); + output.extend(bytes); + Ok(()) + } + async fn finish_chat_file_export(&self, _: String) -> Result<(), GenericError> { + self.completed.store(true, Ordering::SeqCst); + Ok(()) + } + async fn cancel_chat_file_export(&self, _: String) -> Result<(), GenericError> { + self.output.lock().clear(); + Ok(()) + } +} + +async fn authorize_upload(platform: &StubPlatform) { + set_background_grants(platform, PermissionAuthorizationStatus::Authorized).await; + crate::host_logic::permissions::PermissionsService::new(platform, platform, PRODUCT) + .set_authorization_status( + &PermissionAuthorizationRequest::Remote(RemotePermissionRequest { + permission: RemotePermission::PreimageSubmit, + }), + PermissionAuthorizationStatus::Authorized, + ) + .await + .unwrap(); +} + +#[test] +fn attachment_acceptance_loss_reuses_ciphertext_and_download_ack_survives_restart() { + block_on(async { + let bytes: Vec<_> = (0..hop::HOP_CHUNK_BYTES + 173) + .map(|i| (i % 251) as u8) + .collect(); + let files = Arc::new(Files::new(bytes)); + let pool = Pool::default(); + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("attachment fixture has no chain RPC"), + native_chat_files: Some(files.clone()), + hop_provider: Some(Arc::new(pool.clone())), + ..Default::default() + }); + authorize_upload(&platform).await; + let fixture = Fixture::on_platform(platform.clone()); + let actor = fixture.actor().await; + actor.delivering.store(true, Ordering::SeqCst); + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let allowance = derive_sr25519_hard_path( + &fixture.context.entropy, + &["allowance", "bulletin", PRODUCT], + ) + .unwrap(); + assert_ne!(allowance.public.to_bytes(), actor.public.account_id); + *pool.0.expected_sender.lock() = Some(allowance.public.to_bytes()); + actor + .send_attachments( + &fixture.context, + identity.account, + "stable-file".into(), + Some("native image".into()), + ) + .await + .unwrap(); + actor.drive_files(&fixture.context).await.unwrap(); // cache + prepared ciphertext + assert!(pool.0.submissions.lock().is_empty()); + pool.0.reject_next_submit.store(true, Ordering::SeqCst); + actor.drive_files(&fixture.context).await.unwrap(); // accepted, response lost + assert_eq!(pool.0.submissions.lock().len(), 1); + assert!(!files.released.load(Ordering::SeqCst)); + fixture.tasks.stop(); + drop(actor); + let resumed = Fixture::on_platform(platform.clone()); + let actor = resumed.actor().await; + actor.delivering.store(true, Ordering::SeqCst); + actor + .send_attachments( + &resumed.context, + identity.account, + "stable-file".into(), + Some("native image".into()), + ) + .await + .unwrap(); + assert_eq!(files.picks.load(Ordering::SeqCst), 1); + assert_eq!( + actor + .send_attachments( + &resumed.context, + identity.account, + "stable-file".into(), + Some("changed".into()) + ) + .await, + Err(Error::OperationConflict) + ); + for _ in 0..8 { + actor.drive_files(&resumed.context).await.unwrap(); + } + let submissions = pool.0.submissions.lock().clone(); + assert_eq!(submissions.len(), 4); // retried first chunk, second chunk, root + assert_eq!(submissions[0], submissions[1]); + assert!(files.released.load(Ordering::SeqCst)); + let view = actor.public_view(&resumed.context, vec![]).await.unwrap(); + let attachment = &view.rich_messages[0].attachments[0]; + assert_eq!(attachment.state, HostNativeChatAttachmentState::Ready); + actor + .open_attachment(&resumed.context, attachment.attachment_id) + .await + .unwrap(); + assert!(files.completed.load(Ordering::SeqCst)); + assert_eq!(*files.output.lock(), files.bytes); + let outgoing = actor + .store + .read(|state| { + state + .outbox + .iter() + .find(|entry| matches!(entry.kind, OutgoingKind::Rich(_))) + .cloned() + .unwrap() + }) + .await + .unwrap(); + let wire::V2StatementTransportData::MultiRequest(multi) = + open_output(&actor, &identity, &outgoing.statement, false, false) + else { + panic!("not a native multi request") + }; + let body = open_body(&actor, &peer, &multi.encrypted_request, &multi.devices_info); + let exchange = wire::decode_message_exchange_request_plaintext(&body).unwrap(); + let decoded = wire::decode_message(&exchange.messages[0]).unwrap(); + let wire::V2ChatMessageContent::RichText { + attachments: Some(references), + .. + } = &decoded.content + else { + panic!("missing native file reference") + }; + let wire::V2FileVariant::P2pMixnet(reference) = &references[0]; + for secret in [&reference.claim_ticket, &reference.identifier] { + assert!( + !view + .encode() + .windows(secret.len()) + .any(|window| window == secret) + ); + } + assert!( + actor + .send( + &resumed.context, + identity.account, + "raw-capability".into(), + exchange.messages.clone() + ) + .await + .is_err() + ); + + // An independent native peer forwards the authenticated capability. All + // incoming cache bytes must survive ACK and reopening without pool data. + let output = Arc::new(Files::new(Vec::new())); + let receiver_platform = Arc::new(StubPlatform { + chain_connect_error: Some("receiver fixture has no chain RPC"), + native_chat_files: Some(output.clone()), + hop_provider: Some(Arc::new(pool.clone())), + ..Default::default() + }); + set_background_grants( + &receiver_platform, + PermissionAuthorizationStatus::Authorized, + ) + .await; + let receiver = Fixture::on_platform(receiver_platform.clone()); + let receiving = receiver.actor().await; + receiving.delivering.store(true, Ordering::SeqCst); + seed_peer(&receiving, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + assert_eq!( + receiving + .receive( + &receiver.context, + ®istry, + request( + &receiving, + &identity, + &peer, + "native-forward", + &exchange.messages + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!(pool.0.claims.load(Ordering::SeqCst), 0); + receiving.drive_files(&receiver.context).await.unwrap(); // root custody, no ACK yet + assert_eq!(pool.0.acknowledgments.load(Ordering::SeqCst), 0); + receiver.tasks.stop(); + drop(receiving); + let receiver = Fixture::on_platform(receiver_platform.clone()); + let receiving = receiver.actor().await; + receiving.delivering.store(true, Ordering::SeqCst); + for _ in 0..6 { + receiving.drive_files(&receiver.context).await.unwrap(); + } + assert!(pool.0.entries.lock().is_empty()); + assert_eq!(pool.0.acknowledgments.load(Ordering::SeqCst), 3); + let view = receiving + .public_view(&receiver.context, vec![]) + .await + .unwrap(); + let attachment = &view.rich_messages[0].attachments[0]; + assert_eq!(attachment.state, HostNativeChatAttachmentState::Ready); + receiving + .open_attachment(&receiver.context, attachment.attachment_id) + .await + .unwrap(); + assert!(output.completed.load(Ordering::SeqCst)); + assert_eq!(*output.output.lock(), files.bytes); + let claims = pool.0.claims.load(Ordering::SeqCst); + let forwarded = wire::encode_rich_text_message( + "same-file-another-message", + receiver.timestamp, + Some("again"), + Some(references), + ) + .unwrap(); + assert_eq!( + receiving + .receive( + &receiver.context, + ®istry, + request( + &receiving, + &identity, + &peer, + "another-forward", + &[forwarded] + ) + ) + .await, + Err(Error::NetworkUnavailable) + ); + receiving.drive_files(&receiver.context).await.unwrap(); + assert_eq!(pool.0.claims.load(Ordering::SeqCst), claims); + receiver.tasks.stop(); + assert_eq!( + receiving + .open_attachment(&receiver.context, attachment.attachment_id) + .await, + Err(Error::NotConnected) + ); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/background.rs b/rust/crates/truapi-server/src/runtime/native_chat/background.rs new file mode 100644 index 000000000..d76b6b7d8 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/background.rs @@ -0,0 +1,395 @@ +//! In-process receive ownership, independent of a product connection. This is +//! not an OS background scheduler: suspended or terminated hosts cannot poll. + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use futures::{ + FutureExt, StreamExt, + channel::mpsc, + future::{AbortHandle, Abortable}, + stream::{BoxStream, SelectAll, select_all}, +}; +use futures_timer::Delay; +use serde_json::Value; +use truapi::latest::{RemotePermission, RemotePermissionRequest}; +use truapi_platform::{PermissionAuthorizationRequest, PermissionAuthorizationStatus}; + +use super::{ChatError, NativeChatActor, NativeChatContext, NativeChatRegistry}; +use crate::{ + host_logic::{ + permissions::PermissionsService, + statement_store::{ + MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, + parse_new_statements_result, + }, + }, + runtime::statement_store_rpc, +}; + +const AUTHORIZATION_INTERVAL: Duration = Duration::from_secs(1); +const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub(super) const MAX_RETRY: Duration = Duration::from_secs(30); + +pub(super) struct Receiver { + session: Vec, + active: Arc, + abort: AbortHandle, + wake: mpsc::Sender<()>, +} + +impl Drop for Receiver { + fn drop(&mut self) { + // Fence effects synchronously, even if the executor has not polled the + // abort yet. Durable writes already handed off remain independently owned. + self.active.store(false, Ordering::Release); + self.abort.abort(); + } +} + +struct Running { + context: NativeChatContext, + product: String, + active: Arc, +} + +impl Drop for Running { + fn drop(&mut self) { + self.active.store(false, Ordering::Release); + self.context.services.worker_ledger.release(&self.product); + } +} + +impl NativeChatRegistry { + /// Product clearing advances the local activation. Rebind only existing + /// unrelated receivers from memory, independent of products-index storage. + pub(super) async fn rebind_receiving(&self, context: &NativeChatContext, forgotten: &str) { + let wallet = (context.session.public_key, context.genesis_hash); + let keys: Vec<_> = self + .state + .receivers + .lock() + .keys() + .filter(|(owner, product)| *owner == wallet && product != forgotten) + .cloned() + .collect(); + let cache = self.state.cache.lock().clone(); + let actors: Vec<_> = { + let chats = cache.chats.lock().await; + keys.into_iter() + .filter_map(|key| chats.get(&key).cloned().map(|actor| (key.1, actor))) + .collect() + }; + for (product, actor) in actors { + self.ensure_receiving(context, &product, actor).await; + } + } + + pub(super) async fn ensure_receiving( + &self, + context: &NativeChatContext, + product: &str, + actor: Arc, + ) { + let authorization = require_authorized(context, product).await; + let key = ( + (context.session.public_key, context.genesis_hash), + product.to_owned(), + ); + let mut receivers = self.state.receivers.lock(); + // A late result belonging to a replaced session must not remove its + // successor's receiver, or insert a task after logout drained the map. + if context.require_current().is_err() { + return; + } + if matches!( + authorization, + Err(ChatError::NotConnected | ChatError::AccessNotGranted) + ) { + receivers.remove(&key); + return; + } + if let Some(receiver) = receivers.get_mut(&key) { + if receiver.session == context.session.validation_id + && receiver.active.load(Ordering::Acquire) + { + let _ = receiver.wake.try_send(()); + return; + } + } + receivers.remove(&key); + let (abort, registration) = AbortHandle::new_pair(); + let (wake, mut changes) = mpsc::channel(1); + let active = Arc::new(AtomicBool::new(true)); + let mut context = context.clone(); + let session_valid = context.session_valid.clone(); + let receiving = active.clone(); + context.session_valid = + Arc::new(move || receiving.load(Ordering::Acquire) && session_valid()); + receivers.insert( + key, + Receiver { + session: context.session.validation_id.clone(), + active: active.clone(), + abort, + wake, + }, + ); + drop(receivers); + let registry = self.clone(); + let product = product.to_owned(); + let spawner = context.services.spawner.clone(); + spawner(Box::pin(async move { + let _ = Abortable::new( + async move { + // Storage unavailability is not revocation. Retain ownership + // while paused; authorize again before every new effect. + context.services.worker_ledger.acquire(&product); + let running = Running { + context, + product, + active, + }; + let mut retry = Duration::from_secs(1); + loop { + let result = { + // Cancel even hung network work on authorization + // failure, dropping its streams before waiting. + let revoked = async { + loop { + Delay::new(AUTHORIZATION_INTERVAL).await; + if let Err(error) = + require_authorized(&running.context, &running.product).await + { + return Err::<(), ChatError>(error); + } + } + } + .fuse(); + let receive = run( + &running.context, + &running.product, + &actor, + ®istry, + &mut changes, + ) + .fuse(); + futures::pin_mut!(revoked, receive); + futures::select! { + result = revoked => result, + result = receive => result, + } + }; + match result { + Err(ChatError::StorageUnavailable) => { + Delay::new(retry).await; + retry = (retry * 2).min(MAX_RETRY); + } + _ => break, + } + } + }, + registration, + ) + .await; + })); + } +} + +/// Read only: Initialize/PaymentStatus must never prompt for submit permission. +pub(super) async fn require_authorized( + context: &NativeChatContext, + product: &str, +) -> Result<(), ChatError> { + context.require_current()?; + let platform = context.services.platform.as_ref(); + let permissions = PermissionsService::new(platform, platform, product); + for request in [ + PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationRequest::Remote(RemotePermissionRequest { + permission: RemotePermission::StatementSubmit, + }), + ] { + if permissions + .authorization_status(&request) + .await + .map_err(|_| ChatError::StorageUnavailable)? + != PermissionAuthorizationStatus::Authorized + { + return Err(ChatError::AccessNotGranted); + } + } + context.require_current() +} + +/// Uploads additionally consume the product's separately authorized storage resource. +pub(super) async fn require_upload_authorized( + context: &NativeChatContext, + product: &str, +) -> Result<(), ChatError> { + require_authorized(context, product).await?; + let platform = context.services.platform.as_ref(); + let permissions = PermissionsService::new(platform, platform, product); + if permissions + .authorization_status(&PermissionAuthorizationRequest::Remote( + RemotePermissionRequest { + permission: RemotePermission::PreimageSubmit, + }, + )) + .await + .map_err(|_| ChatError::StorageUnavailable)? + != PermissionAuthorizationStatus::Authorized + { + return Err(ChatError::AccessNotGranted); + } + context.require_current() +} + +async fn connect( + context: &NativeChatContext, + product: &str, + actor: &NativeChatActor, +) -> Result< + ( + SelectAll>>, + Vec<[u8; 32]>, + ), + ChatError, +> { + require_authorized(context, product).await?; + let topics = actor.incoming_topics().await?; + let rpc = context + .services + .statement_store + .client("native_chat.receive") + .await + .map_err(|_| ChatError::NetworkUnavailable)?; + let mut subscriptions = Vec::new(); + // MatchAny is necessary: root requests, responses, and device requests are + // distinct routes, not topics required together on one statement. + for chunk in topics.chunks(MAX_MATCH_ANY_TOPICS) { + require_authorized(context, product).await?; + let subscription = statement_store_rpc::subscribe(&rpc, TopicFilterKind::MatchAny, chunk) + .await + .map_err(|_| ChatError::NetworkUnavailable)?; + // SelectAll normally hides an individual stream ending. Treat that as + // disconnection, or one lost chunk would silently lose incoming routes. + subscriptions.push( + subscription + .map(|item| item.map_err(|_| ())) + .chain(futures::stream::once(async { Err(()) })) + .boxed(), + ); + } + Ok((select_all(subscriptions), topics)) +} + +pub(super) async fn receive_notification( + context: &NativeChatContext, + product: &str, + actor: &Arc, + registry: &NativeChatRegistry, + notification: Value, +) -> Result { + let Ok(page) = parse_new_statements_result(String::new(), ¬ification) else { + return Ok(false); + }; + let mut replay_needed = false; + for bytes in page.statements { + require_authorized(context, product).await?; + let Ok(statement) = decode_signed_statement(&bytes) else { + continue; + }; + // Authentication, device admission, replay receipts, private claim + // persistence and claim-before-ACK ordering all remain in the actor. + match actor.receive(context, registry, statement).await { + Err(ChatError::NotConnected | ChatError::AccessNotGranted) => { + return Err(ChatError::NotConnected); + } + Err( + ChatError::StorageUnavailable + | ChatError::NetworkUnavailable + | ChatError::AllowanceRequired, + ) => { + // Reopen after reconciliation to replay the store's backlog: + // a failed claim has no receipt yet and must not be forgotten. + replay_needed = true; + } + _ => {} + } + } + Ok(replay_needed) +} + +async fn run( + context: &NativeChatContext, + product: &str, + actor: &Arc, + registry: &NativeChatRegistry, + changes: &mut mpsc::Receiver<()>, +) -> Result<(), ChatError> { + let mut retry = Duration::from_secs(1); + loop { + require_authorized(context, product).await?; + let connection = connect(context, product, actor).fuse(); + let timeout = Delay::new(CONNECT_TIMEOUT).fuse(); + futures::pin_mut!(connection, timeout); + let connected = futures::select! { + result = connection => result, + _ = timeout => Err(ChatError::NetworkUnavailable), + }; + if let Ok((mut subscriptions, topics)) = connected { + let mut reconcile = Delay::new(RECONCILE_INTERVAL).fuse(); + let mut replay_needed = false; + let mut refresh = false; + loop { + futures::select! { + notification = subscriptions.next().fuse() => { + let Some(Ok(notification)) = notification else { break }; + match receive_notification(context, product, actor, registry, notification).await { + Ok(replay) => replay_needed |= replay, + Err(error) => return Err(error), + } + retry = Duration::from_secs(1); + }, + change = changes.next().fuse() => { + if change.is_none() { return Ok(()); } + }, + _ = reconcile => { + require_authorized(context, product).await?; + let _ = actor.reconcile(context, registry).await; + reconcile = Delay::new(RECONCILE_INTERVAL).fuse(); + if replay_needed { + refresh = true; + break; + } + }, + } + context.require_current()?; + if actor + .incoming_topics() + .await + .as_ref() + .is_ok_and(|current| *current != topics) + { + refresh = true; + break; + } + } + // Dropping the streams unsubscribes before installing the new exact + // topic set. Replay on reconnect uses the existing durable receipts. + drop(subscriptions); + if refresh { + continue; + } + } + Delay::new(retry).await; + retry = (retry * 2).min(MAX_RETRY); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/hop.rs b/rust/crates/truapi-server/src/runtime/native_chat/hop.rs new file mode 100644 index 000000000..d09f26b78 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/hop.rs @@ -0,0 +1,1071 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-private native HOP protocol. Adapted from brevity-chat/src/hop.rs, +//! brevity-dozer/core d504259b60b88ca42f70a8378186a714887ef19f. +//! +//! Wire indices, ticket KDFs, proof domains, CryptoKit combined ciphertexts and +//! RPC bodies match the native HandoffService. Connections MUST be supplied by +//! the Host's live Bulletin WSS allowlist and session/permission fence. This +//! module accepts no endpoint and opens no connections. +//! +//! Every operation transfers at most one bounded entry. There is deliberately +//! no download-all loop, storage callback, or automatic acknowledgment. Persist +//! the ticket, routing identity, prepared ciphertext, root/progress and pending +//! acknowledgments in Host-private custody between effects. For compaction, +//! durable custody includes every embedded message and its complete claim plan. + +use std::ops::Range; + +use async_trait::async_trait; +use chacha20poly1305::aead::{AeadInPlace, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag}; +use parity_scale_codec::{Compact, Decode, Encode}; +use schnorrkel::{ExpansionMode, Keypair, MiniSecretKey, signing_context}; +use serde_json::{Value, json}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +pub const HOP_NOT_FOUND: i64 = 1_004; +pub const BITSWAP_NOT_FOUND: i64 = -32_810; +pub const BITSWAP_INVALID_CID: i64 = -32_602; +pub const HOP_CHUNK_BYTES: usize = 2_000_000; +pub const HOP_INLINE_MAX_BYTES: usize = HOP_CHUNK_BYTES - 64; +/// Native rich-attachment metadata uses an un-compacted u32 file size. +pub const HOP_MAX_FILE_BYTES: u64 = u32::MAX as u64; +const CRYPTO_OVERHEAD: usize = 12 + 16; +const MAX_ENCRYPTED_BYTES: usize = HOP_CHUNK_BYTES + CRYPTO_OVERHEAD; +// A root must itself fit a single entry. Each native Vec hash costs 33 +// bytes; reserve the version/payload, u64 total, and maximum compact count. +const MAX_ROOT_CHUNKS: usize = (HOP_CHUNK_BYTES - 15) / 33; +const MAX_UPLOAD_CHUNKS: usize = (HOP_MAX_FILE_BYTES as usize / HOP_CHUNK_BYTES) + 1; +const SUBMIT_CONTEXT: &[u8] = b"hop-submit-v1:"; +const CLAIM_CONTEXT: &[u8] = b"hop-claim-v1:"; +const ACK_CONTEXT: &[u8] = b"hop-ack-v1:"; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum HopError { + /// Preserve this code at the JSON-RPC boundary: only 1004 permits fallback. + #[error("HOP RPC {code}: {message}")] + Rpc { code: i64, message: String }, + #[error("HOP transport: {0}")] + Transport(String), + #[error("invalid HOP encoding: {0}")] + Codec(String), + #[error("HOP cryptographic operation failed")] + Crypto, + #[error("HOP entry integrity check failed")] + Integrity, + #[error("HOP entry is absent from pool and bitswap")] + NotFound([u8; 32]), + #[error("invalid HOP transfer progress")] + InvalidProgress, +} + +/// Implemented by the actor's fenced connection, never by a guest URL dialer. +#[async_trait] +pub trait HopRpc: Send + Sync { + async fn call(&self, method: &str, params: Value) -> Result; +} + +/// Native Host identities use Sr25519; preserve its HOP wire discriminant. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub enum MultiSigner { + #[codec(index = 1)] + Sr25519([u8; 32]), +} + +/// Only the native Sr25519 signing scheme is accepted by this client. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub enum MultiSignature { + #[codec(index = 1)] + Sr25519([u8; 64]), +} + +pub struct SenderProof { + pub sender: MultiSigner, + pub signature: MultiSignature, + /// Unix milliseconds, included as little-endian u64 in the signed payload. + pub submit_timestamp: u64, +} + +/// The Host signs sender_proof_payload(hash, timestamp) through its native +/// session-fenced authority. No mnemonic or wallet key enters this module. +#[async_trait] +pub trait SenderProofProviding: Send + Sync { + async fn proof(&self, data_hash: &[u8; 32]) -> Result; +} + +pub fn blake2b_256(data: &[u8]) -> [u8; 32] { + let hash = blake2b_simd::Params::new().hash_length(32).hash(data); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output +} + +pub fn sender_proof_payload(hash: &[u8; 32], timestamp: u64) -> [u8; 32] { + proof_payload(SUBMIT_CONTEXT, hash, ×tamp.to_le_bytes()) +} + +fn proof_payload(context: &[u8], hash: &[u8; 32], suffix: &[u8]) -> [u8; 32] { + let hash = blake2b_simd::Params::new() + .hash_length(32) + .to_state() + .update(context) + .update(hash) + .update(suffix) + .finalize(); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output +} + +/// A secret capability, deliberately neither Debug nor Copy nor serializable +/// through a guest-facing derive. as_bytes is for Host-private durable custody. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct FileTicket([u8; 32]); + +impl FileTicket { + pub fn generate() -> Result { + let mut ticket = Self([0; 32]); + getrandom::getrandom(&mut ticket.0).map_err(|_| HopError::Crypto)?; + Ok(ticket) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(codec("ticket must be 32 bytes")); + } + let mut ticket = Self([0; 32]); + ticket.0.copy_from_slice(bytes); + Ok(ticket) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + fn derive_key(&self, context: &[u8]) -> Zeroizing<[u8; 32]> { + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(&self.0) + .hash(context); + let mut key = Zeroizing::new([0; 32]); + key.copy_from_slice(hash.as_bytes()); + key + } + + fn signing_keypair(&self) -> Result { + let seed = self.derive_key(b"signer"); + // schnorrkel's mini-secret and keypair both zeroize on drop. + let mini = MiniSecretKey::from_bytes(&*seed).map_err(|_| HopError::Crypto)?; + Ok(mini.expand_to_keypair(ExpansionMode::Ed25519)) + } + + pub fn recipient(&self) -> Result { + Ok(MultiSigner::Sr25519( + self.signing_keypair()?.public.to_bytes(), + )) + } + + fn recipient_proof( + &self, + hash: &[u8; 32], + context: &[u8], + ) -> Result<([u8; 32], MultiSignature), HopError> { + let keypair = self.signing_keypair()?; + let payload = proof_payload(context, hash, &[]); + let signature = keypair.sign(signing_context(b"substrate").bytes(&payload)); + Ok(( + keypair.public.to_bytes(), + MultiSignature::Sr25519(signature.to_bytes()), + )) + } + + fn encrypt(&self, plaintext: &[u8]) -> Result, HopError> { + if plaintext.len() > HOP_CHUNK_BYTES { + return Err(codec("plaintext exceeds native entry bound")); + } + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|_| HopError::Crypto)?; + self.encrypt_with_nonce(plaintext, nonce) + } + + fn encrypt_with_nonce(&self, plaintext: &[u8], nonce: [u8; 12]) -> Result, HopError> { + let key = self.derive_key(b"encryption"); + let mut combined = Zeroizing::new(Vec::with_capacity(plaintext.len() + CRYPTO_OVERHEAD)); + combined.extend_from_slice(&nonce); + combined.extend_from_slice(plaintext); + let tag = ChaCha20Poly1305::new((&*key).into()) + .encrypt_in_place_detached(Nonce::from_slice(&nonce), &[], &mut combined[12..]) + .map_err(|_| HopError::Crypto)?; + combined.extend_from_slice(&tag); + Ok(std::mem::take(&mut *combined)) + } + + fn decrypt(&self, encrypted: &[u8]) -> Result>, HopError> { + if !(CRYPTO_OVERHEAD..=MAX_ENCRYPTED_BYTES).contains(&encrypted.len()) { + return Err(codec("invalid encrypted entry length")); + } + let key = self.derive_key(b"encryption"); + let tag_start = encrypted.len() - 16; + // In-place decryption may partially modify its buffer on failure; it + // must already be zeroizing before authentication is attempted. + let mut plaintext = Zeroizing::new(encrypted[12..tag_start].to_vec()); + ChaCha20Poly1305::new((&*key).into()) + .decrypt_in_place_detached( + Nonce::from_slice(&encrypted[..12]), + &[], + &mut plaintext, + Tag::from_slice(&encrypted[tag_start..]), + ) + .map_err(|_| HopError::Crypto)?; + Ok(plaintext) + } +} + +/// Exact ciphertext is persistable BEFORE submission. Retrying this object +/// reuses its nonce/hash rather than creating unreachable duplicate entries. +#[derive(Encode)] +pub struct PreparedUpload { + hash: [u8; 32], + recipient: [u8; 32], + encrypted: Vec, +} + +impl PreparedUpload { + fn new(plaintext: &[u8], ticket: &FileTicket) -> Result { + let recipient = ticket.signing_keypair()?.public.to_bytes(); + let encrypted = ticket.encrypt(plaintext)?; + Ok(Self { + hash: blake2b_256(&encrypted), + recipient, + encrypted, + }) + } + + pub fn inline(data: &[u8], ticket: &FileTicket) -> Result { + Self::new(&encode_inline(data)?, ticket) + } + + /// Chunks are encrypted raw bytes, NOT nested root envelopes. + pub fn chunk(data: &[u8], ticket: &FileTicket) -> Result { + if data.is_empty() || data.len() > HOP_CHUNK_BYTES { + return Err(codec("invalid native chunk length")); + } + Self::new(data, ticket) + } + + pub fn compaction>( + messages: &[M], + ticket: &FileTicket, + ) -> Result { + Self::new(&encode_compaction_entry(messages)?, ticket) + } + + pub fn hash(&self) -> [u8; 32] { + self.hash + } + + /// Strict bounded inverse of Encode for Host-private restart records. + pub fn restore(bytes: &[u8]) -> Result { + let mut input = bytes; + let hash = fixed::<32>(&mut input)?; + let recipient = fixed::<32>(&mut input)?; + let encrypted = byte_vector(&mut input, MAX_ENCRYPTED_BYTES)?; + finish(input)?; + if encrypted.len() < CRYPTO_OVERHEAD || blake2b_256(encrypted) != hash { + return Err(HopError::Integrity); + } + Ok(Self { + hash, + recipient, + encrypted: encrypted.to_vec(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PoolStatus { + pub entry_count: u64, + pub total_bytes: u64, + pub max_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubmittedData { + pub hash: [u8; 32], + pub pool_status: PoolStatus, +} + +/// Only successful authenticated POOL claims create these. Bitswap claims +/// return None, so promoted entries cannot accidentally acquire an ack token. +/// Persist with the routing identity and ticket before acknowledging. Replaying +/// an ack after a crash is safe: native 1004 is success. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct PendingAck { + hash: [u8; 32], + recipient: [u8; 32], +} + +impl PendingAck { + pub fn hash(&self) -> [u8; 32] { + self.hash + } + + pub fn restore(bytes: &[u8]) -> Result { + let mut input = bytes; + let value = Self { + hash: fixed(&mut input)?, + recipient: fixed(&mut input)?, + }; + finish(input)?; + Ok(value) + } +} + +/// Native chunk metadata has Vec> hashes, not Vec<[u8; 32]>. +/// The private in-memory representation avoids an allocation for each hash. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct ChunkedFile { + total_size: u64, + chunks: Vec<[u8; 32]>, +} + +impl ChunkedFile { + pub fn total_size(&self) -> u64 { + self.total_size + } + + pub fn chunks(&self) -> &[[u8; 32]] { + &self.chunks + } + + fn validate(&self) -> Result<(), HopError> { + validate_chunk_layout(self.total_size, self.chunks.len()) + } +} + +/// Persist this descriptor separately from clear inline file bytes. Encode is +/// the Host-private resume codec, NOT the native pool envelope codec. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub enum RootDescriptor { + #[codec(index = 0)] + Inline { entry_hash: [u8; 32], byte_len: u32 }, + #[codec(index = 1)] + Chunked { + entry_hash: [u8; 32], + metadata: ChunkedFile, + }, +} + +impl RootDescriptor { + pub fn entry_hash(&self) -> [u8; 32] { + match self { + Self::Inline { entry_hash, .. } | Self::Chunked { entry_hash, .. } => *entry_hash, + } + } + + pub fn total_size(&self) -> u64 { + match self { + Self::Inline { byte_len, .. } => u64::from(*byte_len), + Self::Chunked { metadata, .. } => metadata.total_size, + } + } + + pub fn restore(bytes: &[u8]) -> Result { + let mut input = bytes; + let kind = fixed::<1>(&mut input)?[0]; + let entry_hash = fixed(&mut input)?; + let result = match kind { + 0 => { + let byte_len = u32::from_le_bytes(fixed(&mut input)?); + if byte_len as usize > HOP_INLINE_MAX_BYTES { + return Err(codec("inline descriptor exceeds native bound")); + } + Self::Inline { + entry_hash, + byte_len, + } + } + 1 => { + let total_size = u64::from_le_bytes(fixed(&mut input)?); + let count = compact(&mut input)? as usize; + validate_chunk_layout(total_size, count)?; + if input.len() != count * 32 { + return Err(codec("invalid persisted chunk hash vector")); + } + let mut chunks = Vec::with_capacity(count); + for _ in 0..count { + chunks.push(fixed(&mut input)?); + } + Self::Chunked { + entry_hash, + metadata: ChunkedFile { total_size, chunks }, + } + } + _ => return Err(codec("unknown root descriptor kind")), + }; + finish(input)?; + Ok(result) + } +} + +pub struct ClaimedRoot { + pub descriptor: RootDescriptor, + /// Some only for inline roots. The caller must durably save these bytes. + pub inline: Option>>, + pub pending_ack: Option, +} + +/// Sequential resumable chunk download. It never represents inline files. +/// Native peers may choose smaller chunks: offsets are observed byte counts, +/// not index * HOP_CHUNK_BYTES. The final chunk must exactly match total_size. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode)] +pub struct DownloadProgress { + pub next_chunk: u32, + pub downloaded_bytes: u64, +} + +impl DownloadProgress { + pub fn restore(bytes: &[u8], root: &RootDescriptor) -> Result { + let mut input = bytes; + let value = Self { + next_chunk: u32::from_le_bytes(fixed(&mut input)?), + downloaded_bytes: u64::from_le_bytes(fixed(&mut input)?), + }; + finish(input)?; + value.validate(root)?; + Ok(value) + } + + pub fn validate(&self, root: &RootDescriptor) -> Result<(), HopError> { + let RootDescriptor::Chunked { metadata, .. } = root else { + return Err(HopError::InvalidProgress); + }; + metadata.validate()?; + let index = self.next_chunk as usize; + if index > metadata.chunks.len() || self.downloaded_bytes > metadata.total_size { + return Err(HopError::InvalidProgress); + } + let remaining_chunks = (metadata.chunks.len() - index) as u64; + let remaining_bytes = metadata.total_size - self.downloaded_bytes; + let completed = u64::from(self.next_chunk); + if self.downloaded_bytes < completed + || self.downloaded_bytes > completed * HOP_CHUNK_BYTES as u64 + || remaining_bytes < remaining_chunks + || remaining_bytes > remaining_chunks * HOP_CHUNK_BYTES as u64 + { + return Err(HopError::InvalidProgress); + } + Ok(()) + } + + pub fn is_complete(&self, root: &RootDescriptor) -> Result { + self.validate(root)?; + Ok(self.downloaded_bytes == root.total_size()) + } +} + +pub struct ClaimedChunk { + pub index: u32, + pub offset: u64, + pub data: Zeroizing>, + /// Persist together with data before separately acknowledging pending_ack. + pub next_progress: DownloadProgress, + pub pending_ack: Option, +} + +/// Upload progress only for files over the native inline threshold. Each +/// successful submit records one full 2MB chunk, except the final remainder. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub struct UploadProgress { + total_size: u32, + uploaded_hashes: Vec<[u8; 32]>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChunkRequest { + pub index: u32, + pub offset: u64, + pub byte_len: usize, +} + +impl UploadProgress { + pub fn new(total_size: u32) -> Result { + if total_size as usize <= HOP_INLINE_MAX_BYTES { + return Err(HopError::InvalidProgress); + } + Ok(Self { + total_size, + uploaded_hashes: Vec::new(), + }) + } + + pub fn total_size(&self) -> u32 { + self.total_size + } + + pub fn uploaded_size(&self) -> u64 { + (self.uploaded_hashes.len() as u64 * HOP_CHUNK_BYTES as u64).min(u64::from(self.total_size)) + } + + pub fn restore(bytes: &[u8]) -> Result { + let mut input = bytes; + let total_size = u32::from_le_bytes(fixed(&mut input)?); + let mut value = Self::new(total_size)?; + let count = compact(&mut input)? as usize; + let expected = u64::from(total_size).div_ceil(HOP_CHUNK_BYTES as u64) as usize; + if count > expected || count > MAX_UPLOAD_CHUNKS || input.len() != count * 32 { + return Err(HopError::InvalidProgress); + } + value.uploaded_hashes.reserve(count); + for _ in 0..count { + value.uploaded_hashes.push(fixed(&mut input)?); + } + finish(input)?; + Ok(value) + } + + pub fn next_chunk(&self) -> Option { + let offset = self.uploaded_hashes.len() as u64 * HOP_CHUNK_BYTES as u64; + let remaining = u64::from(self.total_size).checked_sub(offset)?; + if remaining == 0 { + return None; + } + Some(ChunkRequest { + index: self.uploaded_hashes.len() as u32, + offset, + byte_len: remaining.min(HOP_CHUNK_BYTES as u64) as usize, + }) + } + + /// Call only after submit succeeds, and persist before requesting the next + /// chunk. Keep the PreparedUpload until this record is durable. + pub fn record_chunk(&mut self, hash: [u8; 32], byte_len: usize) -> Result<(), HopError> { + let expected = self.next_chunk().ok_or(HopError::InvalidProgress)?; + if byte_len != expected.byte_len { + return Err(HopError::InvalidProgress); + } + self.uploaded_hashes.push(hash); + Ok(()) + } + + pub fn prepare_root(&self, ticket: &FileTicket) -> Result { + if self.next_chunk().is_some() { + return Err(HopError::InvalidProgress); + } + PreparedUpload::new( + &encode_chunked_envelope(u64::from(self.total_size), &self.uploaded_hashes)?, + ticket, + ) + } +} + +pub struct ClaimedCompaction { + pub batch: CompactionBatch, + pub pending_ack: Option, +} + +/// One zeroizing allocation owns the clear entry. Messages borrow its bytes; +/// no Vec per message, even for the maximum valid number of empty messages. +/// Intentionally not Debug: compacted messages are Host-private cleartext. +pub struct CompactionBatch { + entry: Zeroizing>, + messages_start: usize, + message_count: u32, +} + +impl CompactionBatch { + pub fn message_count(&self) -> usize { + self.message_count as usize + } + + pub fn messages(&self) -> impl Iterator { + let mut input = &self.entry[self.messages_start..]; + (0..self.message_count).map(move |_| { + // Validated in full before CompactionBatch can be constructed; + // callers have no mutable access to its backing allocation. + byte_vector(&mut input, HOP_INLINE_MAX_BYTES) + .expect("validated immutable compaction batch") + }) + } +} + +/// Return ordered ranges into the caller's messages, never duplicate payloads. +/// The exact SCALE outer count prefix is included at every size boundary. +pub fn pack_compaction_batches>( + messages: &[M], +) -> Result>, HopError> { + let mut ranges = Vec::new(); + let mut start = 0; + let mut payload_size = 0; + for (index, message) in messages.iter().enumerate() { + let size = encoded_message_size(message.as_ref())?; + if 1 + size > HOP_INLINE_MAX_BYTES { + return Err(codec("message cannot fit an inline compaction batch")); + } + let count = index - start + 1; + if compact_size(count as u32) + payload_size + size > HOP_INLINE_MAX_BYTES { + ranges.push(start..index); + start = index; + payload_size = 0; + } + payload_size += size; + } + if start < messages.len() { + ranges.push(start..messages.len()); + } + Ok(ranges) +} + +pub fn encode_compaction_entry>( + messages: &[M], +) -> Result>, HopError> { + if messages.len() > HOP_INLINE_MAX_BYTES { + return Err(codec("compaction count exceeds inline bound")); + } + let count = messages.len() as u32; + let mut size = compact_size(count); + for message in messages { + size += encoded_message_size(message.as_ref())?; + if size > HOP_INLINE_MAX_BYTES { + return Err(codec("compaction batch exceeds inline bound")); + } + } + let mut encoded = Zeroizing::new(Vec::with_capacity(2 + compact_size(size as u32) + size)); + encoded.extend_from_slice(&[0, 0]); + Compact(size as u32).encode_to(&mut *encoded); + Compact(count).encode_to(&mut *encoded); + for message in messages { + let bytes = message.as_ref(); + Compact(bytes.len() as u32).encode_to(&mut *encoded); + encoded.extend_from_slice(bytes); + } + Ok(encoded) +} + +/// Reject chunked entries, unknown versions, impossible/noncanonical lengths +/// and trailing bytes before exposing any inner message or allocating a vector. +pub fn decode_compaction_entry(entry: Zeroizing>) -> Result { + let mut input = entry.as_slice(); + if fixed::<2>(&mut input)? != [0, 0] { + return Err(codec("compaction requires a version-0 inline envelope")); + } + let mut batch = byte_vector(&mut input, HOP_INLINE_MAX_BYTES)?; + finish(input)?; + let message_count = compact(&mut batch)?; + if message_count as usize > batch.len() { + return Err(codec("compaction count exceeds remaining bytes")); + } + let messages_start = entry.len() - batch.len(); + for _ in 0..message_count { + byte_vector(&mut batch, HOP_INLINE_MAX_BYTES)?; + } + finish(batch)?; + Ok(CompactionBatch { + entry, + messages_start, + message_count, + }) +} + +pub struct HopClient<'a> { + rpc: &'a dyn HopRpc, +} + +impl<'a> HopClient<'a> { + pub fn new(rpc: &'a dyn HopRpc) -> Self { + Self { rpc } + } + + pub async fn submit( + &self, + prepared: &PreparedUpload, + sender: &dyn SenderProofProviding, + ) -> Result { + let proof = sender.proof(&prepared.hash).await?; + let result = self.rpc.call("hop_submit", json!({ + "data": prefixed_hex(&prepared.encrypted), + "recipients": [prefixed_hex(&MultiSigner::Sr25519(prepared.recipient).encode())], + "signature": prefixed_hex(&proof.signature.encode()), + "signer": prefixed_hex(&proof.sender.encode()), + "submit_timestamp": proof.submit_timestamp, + })).await?; + let status = result + .get("poolStatus") + .ok_or_else(|| codec("missing poolStatus"))?; + let field = |name: &str| { + status + .get(name) + .and_then(Value::as_u64) + .ok_or_else(|| codec("invalid poolStatus field")) + }; + Ok(SubmittedData { + hash: prepared.hash, + pool_status: PoolStatus { + entry_count: field("entryCount")?, + total_bytes: field("totalBytes")?, + max_bytes: field("maxBytes")?, + }, + }) + } + + pub async fn claim_root( + &self, + entry_hash: [u8; 32], + ticket: &FileTicket, + expected_size: Option, + ) -> Result { + let (mut plaintext, pending_ack) = self.claim(&entry_hash, ticket).await?; + let (descriptor, inline_len) = match decode_root(&plaintext)? { + DecodedRoot::Inline(data) => ( + RootDescriptor::Inline { + entry_hash, + byte_len: data.len() as u32, + }, + Some(data.len()), + ), + DecodedRoot::Chunked(metadata) => ( + RootDescriptor::Chunked { + entry_hash, + metadata, + }, + None, + ), + }; + if expected_size.is_some_and(|size| u64::from(size) != descriptor.total_size()) { + return Err(codec("attachment size does not match root")); + } + let inline = inline_len.map(|len| { + // Strip the envelope without allocating a second clear file. + let start = plaintext.len() - len; + plaintext.copy_within(start.., 0); + plaintext[len..].zeroize(); + plaintext.truncate(len); + plaintext + }); + Ok(ClaimedRoot { + descriptor, + inline, + pending_ack, + }) + } + + /// A completed progress record yields None without making a network call. + /// Otherwise this claims exactly one chunk and returns a proposed next + /// record; no in-memory or durable progress is advanced on the caller's behalf. + pub async fn claim_chunk( + &self, + root: &RootDescriptor, + progress: DownloadProgress, + ticket: &FileTicket, + ) -> Result, HopError> { + progress.validate(root)?; + let RootDescriptor::Chunked { metadata, .. } = root else { + return Err(HopError::InvalidProgress); + }; + let Some(hash) = metadata.chunks.get(progress.next_chunk as usize) else { + return Ok(None); + }; + let (data, pending_ack) = self.claim(hash, ticket).await?; + let next_progress = DownloadProgress { + next_chunk: progress.next_chunk + 1, + downloaded_bytes: progress.downloaded_bytes + data.len() as u64, + }; + if data.is_empty() { + return Err(codec("empty native file chunk")); + } + next_progress.validate(root)?; + Ok(Some(ClaimedChunk { + index: progress.next_chunk, + offset: progress.downloaded_bytes, + data, + next_progress, + pending_ack, + })) + } + + pub async fn claim_compaction( + &self, + entry_hash: [u8; 32], + ticket: &FileTicket, + ) -> Result { + let (plaintext, pending_ack) = self.claim(&entry_hash, ticket).await?; + let batch = decode_compaction_entry(plaintext)?; + Ok(ClaimedCompaction { batch, pending_ack }) + } + + /// Only the actor calls this, after durable custody / complete claim plans. + /// It is never invoked by claim, decode, progress, drop, or bitswap fallback. + pub async fn acknowledge( + &self, + pending: &PendingAck, + ticket: &FileTicket, + ) -> Result<(), HopError> { + let (recipient, signature) = ticket.recipient_proof(&pending.hash, ACK_CONTEXT)?; + if recipient != pending.recipient { + return Err(HopError::Crypto); + } + match self + .rpc + .call( + "hop_ack", + json!({ + "raw_hash": prefixed_hex(&pending.hash), + "signature": prefixed_hex(&signature.encode()), + }), + ) + .await + { + Ok(_) => Ok(()), + Err(HopError::Rpc { + code: HOP_NOT_FOUND, + .. + }) => Ok(()), + Err(error) => Err(error), + } + } + + async fn claim( + &self, + hash: &[u8; 32], + ticket: &FileTicket, + ) -> Result<(Zeroizing>, Option), HopError> { + let (recipient, signature) = ticket.recipient_proof(hash, CLAIM_CONTEXT)?; + let (result, pool) = match self + .rpc + .call( + "hop_claim", + json!({ + "raw_hash": prefixed_hex(hash), + "signature": prefixed_hex(&signature.encode()), + }), + ) + .await + { + Ok(result) => (result, true), + Err(HopError::Rpc { + code: HOP_NOT_FOUND, + .. + }) => { + let result = match self + .rpc + .call("bitswap_v1_get", json!([raw_cid(hash)])) + .await + { + Ok(result) => result, + Err(HopError::Rpc { + code: BITSWAP_NOT_FOUND, + .. + }) => return Err(HopError::NotFound(*hash)), + // Invalid CID, permissions, timeouts and every other error + // propagate intact. No secondary source or blind retry. + Err(error) => return Err(error), + }; + (result, false) + } + Err(error) => return Err(error), + }; + let encrypted = decode_rpc_bytes(&result)?; + if blake2b_256(&encrypted) != *hash { + return Err(HopError::Integrity); + } + let plaintext = ticket.decrypt(&encrypted)?; + let pending = pool.then_some(PendingAck { + hash: *hash, + recipient, + }); + Ok((plaintext, pending)) + } +} + +fn codec(message: &str) -> HopError { + HopError::Codec(message.into()) +} + +fn prefixed_hex(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(2 + bytes.len() * 2); + encoded.push_str("0x"); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 15) as usize] as char); + } + encoded +} + +fn decode_rpc_bytes(value: &Value) -> Result, HopError> { + let value = value + .as_str() + .ok_or_else(|| codec("entry result must be hex text"))?; + let hex = value.strip_prefix("0x").unwrap_or(value); + if hex.len() > MAX_ENCRYPTED_BYTES * 2 || hex.len() < CRYPTO_OVERHEAD * 2 { + return Err(codec("entry result exceeds native size bounds")); + } + // hex::decode handles malformed Unicode without byte-indexing panics. + hex::decode(hex).map_err(|_| codec("invalid hex entry")) +} + +fn fixed(input: &mut &[u8]) -> Result<[u8; N], HopError> { + if input.len() < N { + return Err(codec("truncated fixed field")); + } + let mut value = [0; N]; + value.copy_from_slice(&input[..N]); + *input = &input[N..]; + Ok(value) +} + +fn compact(input: &mut &[u8]) -> Result { + let before = input.len(); + let value = Compact::::decode(input) + .map_err(|_| codec("invalid SCALE compact length"))? + .0; + if before - input.len() != compact_size(value) { + return Err(codec("noncanonical SCALE compact length")); + } + Ok(value) +} + +fn compact_size(value: u32) -> usize { + match value { + 0..=63 => 1, + 64..=16_383 => 2, + 16_384..=1_073_741_823 => 4, + _ => 5, + } +} + +fn byte_vector<'a>(input: &mut &'a [u8], maximum: usize) -> Result<&'a [u8], HopError> { + let size = compact(input)? as usize; + if size > maximum || size > input.len() { + return Err(codec("SCALE vector exceeds bounded input")); + } + let (bytes, remaining) = input.split_at(size); + *input = remaining; + Ok(bytes) +} + +fn finish(input: &[u8]) -> Result<(), HopError> { + if input.is_empty() { + Ok(()) + } else { + Err(codec("trailing bytes")) + } +} + +fn encoded_message_size(message: &[u8]) -> Result { + if message.len() > HOP_INLINE_MAX_BYTES { + return Err(codec("message exceeds inline bound")); + } + Ok(compact_size(message.len() as u32) + message.len()) +} + +fn encode_inline(data: &[u8]) -> Result>, HopError> { + if data.len() > HOP_INLINE_MAX_BYTES { + return Err(codec("inline file exceeds native bound")); + } + let mut encoded = Zeroizing::new(Vec::with_capacity( + 2 + compact_size(data.len() as u32) + data.len(), + )); + encoded.extend_from_slice(&[0, 0]); + Compact(data.len() as u32).encode_to(&mut *encoded); + encoded.extend_from_slice(data); + Ok(encoded) +} + +fn encode_chunked_envelope( + total_size: u64, + chunks: &[[u8; 32]], +) -> Result>, HopError> { + validate_chunk_layout(total_size, chunks.len())?; + let mut encoded = Zeroizing::new(Vec::with_capacity(15 + chunks.len() * 33)); + encoded.extend_from_slice(&[0, 1]); + total_size.encode_to(&mut *encoded); + Compact(chunks.len() as u32).encode_to(&mut *encoded); + for hash in chunks { + Compact(32u32).encode_to(&mut *encoded); + encoded.extend_from_slice(hash); + } + Ok(encoded) +} + +fn validate_chunk_layout(total_size: u64, count: usize) -> Result<(), HopError> { + if total_size == 0 + || total_size > HOP_MAX_FILE_BYTES + || count == 0 + || count > MAX_ROOT_CHUNKS + || total_size < count as u64 + || total_size > count as u64 * HOP_CHUNK_BYTES as u64 + { + return Err(codec("invalid native chunked layout")); + } + Ok(()) +} + +enum DecodedRoot<'a> { + Inline(&'a [u8]), + Chunked(ChunkedFile), +} + +fn decode_root(entry: &[u8]) -> Result, HopError> { + if entry.len() > HOP_CHUNK_BYTES { + return Err(codec("root exceeds native entry bound")); + } + let mut input = entry; + let header = fixed::<2>(&mut input)?; + if header[0] != 0 { + return Err(codec("unsupported root envelope version")); + } + let result = match header[1] { + 0 => DecodedRoot::Inline(byte_vector(&mut input, HOP_INLINE_MAX_BYTES)?), + 1 => { + let total_size = u64::from_le_bytes(fixed(&mut input)?); + let count = compact(&mut input)? as usize; + validate_chunk_layout(total_size, count)?; + // Every canonical 32-byte Vec has a one-byte 0x80 prefix. + // Preflight the complete encoded size before allocating any hashes. + if input.len() != count * 33 { + return Err(codec("invalid native chunk hash vector size")); + } + let mut chunks = Vec::with_capacity(count); + for _ in 0..count { + let hash = byte_vector(&mut input, 32)?; + chunks.push( + hash.try_into() + .map_err(|_| codec("chunk hash must be 32 bytes"))?, + ); + } + DecodedRoot::Chunked(ChunkedFile { total_size, chunks }) + } + _ => return Err(codec("unsupported root payload kind")), + }; + finish(input)?; + Ok(result) +} + +/// CIDv1 / raw / BLAKE2b-256, lowercase unpadded base32. The six-byte prefix +/// is varint(1), varint(0x55), varint(0xb220), varint(32). +fn raw_cid(hash: &[u8; 32]) -> String { + const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut bytes = [0; 38]; + bytes[..6].copy_from_slice(&[0x01, 0x55, 0xa0, 0xe4, 0x02, 0x20]); + bytes[6..].copy_from_slice(hash); + let mut cid = String::with_capacity(62); + cid.push('b'); + let mut buffer = 0u32; + let mut bits = 0; + for byte in bytes { + buffer = (buffer << 8) | u32::from(byte); + bits += 8; + while bits >= 5 { + bits -= 5; + cid.push(ALPHABET[((buffer >> bits) & 31) as usize] as char); + } + } + if bits > 0 { + cid.push(ALPHABET[((buffer << (5 - bits)) & 31) as usize] as char); + } + cid +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/truapi-server/src/runtime/native_chat/hop/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/hop/tests.rs new file mode 100644 index 000000000..fceaf5024 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/hop/tests.rs @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: AGPL-3.0-only +use parking_lot::Mutex; +use std::collections::VecDeque; + +use super::*; + +fn ticket(byte: u8) -> FileTicket { + FileTicket::from_bytes(&[byte; 32]).unwrap() +} + +fn rpc_error(code: i64) -> HopError { + HopError::Rpc { + code, + message: "scripted remote error".into(), + } +} + +struct ScriptedRpc { + responses: Mutex)>>, + calls: Mutex>, +} + +impl ScriptedRpc { + fn new(responses: Vec<(&'static str, Result)>) -> Self { + Self { + responses: Mutex::new(responses.into()), + calls: Mutex::new(Vec::new()), + } + } + + fn methods(&self) -> Vec { + self.calls + .lock() + .iter() + .map(|(method, _)| method.clone()) + .collect() + } +} + +#[async_trait] +impl HopRpc for ScriptedRpc { + async fn call(&self, method: &str, params: Value) -> Result { + self.calls.lock().push((method.into(), params)); + let (expected, result) = self.responses.lock().pop_front().expect("unexpected RPC"); + assert_eq!(method, expected); + result + } +} + +#[test] +fn frozen_native_envelopes_compaction_and_cid_are_preserved() { + // iOS VersionedUploadedFileTests and Android CompactionBatchStore vectors. + assert_eq!( + &*encode_inline(&[0xde, 0xad]).unwrap(), + &[0, 0, 8, 0xde, 0xad] + ); + let encoded = encode_chunked_envelope(300, &[[0xab; 32]]).unwrap(); + let mut expected = vec![0, 1, 0x2c, 1, 0, 0, 0, 0, 0, 0, 4, 0x80]; + expected.extend_from_slice(&[0xab; 32]); + assert_eq!(&*encoded, &expected); + let DecodedRoot::Chunked(metadata) = decode_root(&encoded).unwrap() else { + panic!("chunked") + }; + assert_eq!(metadata.total_size(), 300); + assert_eq!(metadata.chunks(), &[[0xab; 32]]); + let mut trailing_root = encoded.to_vec(); + trailing_root.push(0); + assert!(decode_root(&trailing_root).is_err()); + let mut wrong_hash_length = encoded.to_vec(); + wrong_hash_length[11] = 0x7c; // Declares 31 bytes instead of 32. + assert!(decode_root(&wrong_hash_length).is_err()); + assert!(decode_root(&[1, 0, 0]).is_err()); + + let messages = [vec![0xaa, 0xbb, 0xcc], vec![1, 2]]; + let encoded = encode_compaction_entry(&messages).unwrap(); + assert_eq!(hex::encode(&*encoded), "000020080caabbcc080102"); + let batch = decode_compaction_entry(encoded).unwrap(); + assert_eq!( + batch.messages().collect::>(), + vec![&messages[0][..], &messages[1][..]] + ); + + // Frozen Swift BitswapRemoteStoreTests vector (digest bytes 1...32). + let mut digest = [0; 32]; + for (index, byte) in digest.iter_mut().enumerate() { + *byte = index as u8 + 1; + } + assert_eq!( + raw_cid(&digest), + "bafk2bzaceaaqeayeaudaocajbifqydiob4ibceqtcqkrmfyydenbwha5dypsa" + ); +} + +#[test] +fn compaction_preflights_counts_trailing_data_and_exact_inline_boundary() { + let malformed = [ + vec![1, 0, 0], // Unknown envelope version. + vec![0, 2, 0], // Unknown payload index. + vec![0, 0, 4, 0, 0], // Trailing envelope byte. + vec![0, 0, 8, 0, 0], // Trailing inner batch byte. + vec![0, 0, 4, 4], // One message, no length/data. + vec![0, 0, 20, 3, 255, 255, 255, 255], // Huge count in tiny input. + vec![0, 0, 5, 0, 0], // Noncanonical compact length. + ]; + for bytes in malformed { + assert!(decode_compaction_entry(Zeroizing::new(bytes)).is_err()); + } + let chunked = encode_chunked_envelope(1, &[[0; 32]]).unwrap(); + assert!(decode_compaction_entry(chunked).is_err()); + + let exact = vec![0x7b; HOP_INLINE_MAX_BYTES - 5]; + let messages = [exact, vec![9]]; + let batches = pack_compaction_batches(&messages).unwrap(); + assert_eq!(batches, vec![0..1, 1..2]); + let encoded = encode_compaction_entry(&messages[batches[0].clone()]).unwrap(); + let decoded = decode_compaction_entry(encoded).unwrap(); + assert_eq!(decoded.messages().next().unwrap(), messages[0]); + assert!(encode_compaction_entry(&messages).is_err()); + assert!(pack_compaction_batches(&[vec![0; HOP_INLINE_MAX_BYTES - 4]]).is_err()); + + // Crossing the outer compact-count boundary also consumes a byte. + let empty_messages: Vec> = vec![vec![]; 64]; + let encoded = encode_compaction_entry(&empty_messages).unwrap(); + let decoded = decode_compaction_entry(encoded).unwrap(); + assert_eq!(decoded.message_count(), 64); + assert!(decoded.messages().all(|message| message.is_empty())); +} + +#[test] +fn ticket_authentication_binds_nonce_ciphertext_tag_and_proof_domain() { + let ticket = ticket(7); + let clear = b"native attachment bytes"; + let encrypted = ticket.encrypt_with_nonce(clear, [3; 12]).unwrap(); + assert_eq!(&encrypted[..12], &[3; 12]); + assert_eq!(&*ticket.decrypt(&encrypted).unwrap(), clear); + assert!(super::tests::ticket(8).decrypt(&encrypted).is_err()); + for index in [0, 12, encrypted.len() - 1] { + let mut modified = encrypted.clone(); + modified[index] ^= 1; + assert!(ticket.decrypt(&modified).is_err()); + } + assert!(ticket.decrypt(&encrypted[..27]).is_err()); + + let hash = blake2b_256(&encrypted); + let (public, claim) = ticket.recipient_proof(&hash, CLAIM_CONTEXT).unwrap(); + let (_, ack) = ticket.recipient_proof(&hash, ACK_CONTEXT).unwrap(); + let MultiSignature::Sr25519(claim) = claim; + let MultiSignature::Sr25519(ack) = ack; + let public = schnorrkel::PublicKey::from_bytes(&public).unwrap(); + let mut native_claim_payload = b"hop-claim-v1:".to_vec(); + native_claim_payload.extend_from_slice(&hash); + let payload = blake2b_256(&native_claim_payload); + public + .verify( + signing_context(b"substrate").bytes(&payload), + &schnorrkel::Signature::from_bytes(&claim).unwrap(), + ) + .unwrap(); + assert!( + public + .verify( + signing_context(b"substrate").bytes(&payload), + &schnorrkel::Signature::from_bytes(&ack).unwrap(), + ) + .is_err() + ); +} + +#[test] +fn pool_claim_leaves_restartable_ack_pending_until_explicit_custody() { + futures::executor::block_on(async { + let ticket = ticket(7); + let prepared = PreparedUpload::inline(b"durable first", &ticket).unwrap(); + let rpc = ScriptedRpc::new(vec![ + ("hop_claim", Ok(json!(prefixed_hex(&prepared.encrypted)))), + ("hop_ack", Err(rpc_error(HOP_NOT_FOUND))), + ]); + let client = HopClient::new(&rpc); + let claimed = client + .claim_root(prepared.hash(), &ticket, Some(13)) + .await + .unwrap(); + assert_eq!(&**claimed.inline.as_ref().unwrap(), b"durable first"); + assert_eq!(rpc.methods(), ["hop_claim"]); + let persisted = claimed.pending_ack.unwrap().encode(); + let pending = PendingAck::restore(&persisted).unwrap(); + assert_eq!(pending.hash(), prepared.hash()); + // A different ticket must not acknowledge the restored token. + assert!( + client + .acknowledge(&pending, &super::tests::ticket(8)) + .await + .is_err() + ); + assert_eq!(rpc.methods(), ["hop_claim"]); + client.acknowledge(&pending, &ticket).await.unwrap(); + assert_eq!(rpc.methods(), ["hop_claim", "hop_ack"]); + let mut trailing = persisted; + trailing.push(0); + assert!(PendingAck::restore(&trailing).is_err()); + }); +} + +#[test] +fn only_1004_falls_back_and_promoted_authenticated_entries_never_gain_acks() { + futures::executor::block_on(async { + let ticket = ticket(7); + let prepared = PreparedUpload::compaction(&[b"secret memo"], &ticket).unwrap(); + let rpc = ScriptedRpc::new(vec![ + ("hop_claim", Err(rpc_error(HOP_NOT_FOUND))), + ( + "bitswap_v1_get", + Ok(json!(prefixed_hex(&prepared.encrypted))), + ), + ]); + let claimed = HopClient::new(&rpc) + .claim_compaction(prepared.hash(), &ticket) + .await + .unwrap(); + assert!(claimed.pending_ack.is_none()); + assert_eq!(claimed.batch.messages().next().unwrap(), b"secret memo"); + assert_eq!(rpc.methods(), ["hop_claim", "bitswap_v1_get"]); + assert_eq!(rpc.calls.lock()[1].1, json!([raw_cid(&prepared.hash())])); + + let denied = ScriptedRpc::new(vec![("hop_claim", Err(rpc_error(1003)))]); + assert!(matches!( + HopClient::new(&denied) + .claim_compaction(prepared.hash(), &ticket) + .await, + Err(HopError::Rpc { code: 1003, .. }) + )); + assert_eq!(denied.methods(), ["hop_claim"]); + + let invalid_cid = ScriptedRpc::new(vec![ + ("hop_claim", Err(rpc_error(HOP_NOT_FOUND))), + ("bitswap_v1_get", Err(rpc_error(BITSWAP_INVALID_CID))), + ]); + assert!(matches!( + HopClient::new(&invalid_cid) + .claim_compaction(prepared.hash(), &ticket) + .await, + Err(HopError::Rpc { + code: BITSWAP_INVALID_CID, + .. + }) + )); + }); +} + +#[test] +fn forged_pool_and_bitswap_data_and_wrong_tickets_fail_without_ack() { + futures::executor::block_on(async { + let ticket = ticket(7); + let prepared = PreparedUpload::inline(b"authenticated", &ticket).unwrap(); + let altered = PreparedUpload::inline(b"different bytes", &ticket).unwrap(); + for promoted in [false, true] { + let mut responses = Vec::new(); + if promoted { + responses.push(("hop_claim", Err(rpc_error(HOP_NOT_FOUND)))); + } + responses.push(( + if promoted { + "bitswap_v1_get" + } else { + "hop_claim" + }, + Ok(json!(prefixed_hex(&altered.encrypted))), + )); + let rpc = ScriptedRpc::new(responses); + assert!(matches!( + HopClient::new(&rpc) + .claim_root(prepared.hash(), &ticket, None) + .await, + Err(HopError::Integrity) + )); + assert!(!rpc.methods().iter().any(|method| method == "hop_ack")); + } + let rpc = ScriptedRpc::new(vec![( + "hop_claim", + Ok(json!(prefixed_hex(&prepared.encrypted))), + )]); + assert!(matches!( + HopClient::new(&rpc) + .claim_root(prepared.hash(), &super::tests::ticket(8), None) + .await, + Err(HopError::Crypto) + )); + }); +} + +#[test] +fn chunk_download_resumes_from_durable_offset_and_rejects_wrong_final_size() { + futures::executor::block_on(async { + let ticket = ticket(7); + let first = PreparedUpload::chunk(b"abc", &ticket).unwrap(); + let second = PreparedUpload::chunk(b"de", &ticket).unwrap(); + let envelope = encode_chunked_envelope(5, &[first.hash(), second.hash()]).unwrap(); + let root = PreparedUpload::new(&envelope, &ticket).unwrap(); + let rpc = ScriptedRpc::new(vec![ + ("hop_claim", Ok(json!(prefixed_hex(&root.encrypted)))), + ("hop_claim", Ok(json!(prefixed_hex(&first.encrypted)))), + ("hop_claim", Ok(json!(prefixed_hex(&second.encrypted)))), + ]); + let client = HopClient::new(&rpc); + let claimed = client + .claim_root(root.hash(), &ticket, Some(5)) + .await + .unwrap(); + assert!(claimed.pending_ack.is_some()); + assert!(claimed.inline.is_none()); + let descriptor = RootDescriptor::restore(&claimed.descriptor.encode()).unwrap(); + let first_chunk = client + .claim_chunk(&descriptor, DownloadProgress::default(), &ticket) + .await + .unwrap() + .unwrap(); + assert_eq!(&*first_chunk.data, b"abc"); + assert!(first_chunk.pending_ack.is_some()); + let progress = + DownloadProgress::restore(&first_chunk.next_progress.encode(), &descriptor).unwrap(); + let second_chunk = client + .claim_chunk(&descriptor, progress, &ticket) + .await + .unwrap() + .unwrap(); + assert_eq!(second_chunk.offset, 3); + assert_eq!(&*second_chunk.data, b"de"); + assert!(second_chunk.next_progress.is_complete(&descriptor).unwrap()); + assert!( + client + .claim_chunk(&descriptor, second_chunk.next_progress, &ticket) + .await + .unwrap() + .is_none() + ); + assert_eq!(rpc.methods(), ["hop_claim", "hop_claim", "hop_claim"]); + + let wrong_total = RootDescriptor::Chunked { + entry_hash: root.hash(), + metadata: ChunkedFile { + total_size: 6, + chunks: vec![first.hash(), second.hash()], + }, + }; + let rpc = ScriptedRpc::new(vec![( + "hop_claim", + Ok(json!(prefixed_hex(&second.encrypted))), + )]); + assert!(matches!( + HopClient::new(&rpc) + .claim_chunk(&wrong_total, progress, &ticket) + .await, + Err(HopError::InvalidProgress) + )); + }); +} + +#[test] +fn resumable_upload_keeps_ciphertext_hash_and_native_u32_size_bound() { + let ticket = ticket(7); + let first = PreparedUpload::chunk(b"persist ciphertext before RPC", &ticket).unwrap(); + let restored = PreparedUpload::restore(&first.encode()).unwrap(); + assert_eq!(restored.hash(), first.hash()); + assert_eq!(restored.encrypted, first.encrypted); + let mut corrupted = first.encode(); + *corrupted.last_mut().unwrap() ^= 1; + assert!(matches!( + PreparedUpload::restore(&corrupted), + Err(HopError::Integrity) + )); + + let mut progress = UploadProgress::new((HOP_CHUNK_BYTES + 3) as u32).unwrap(); + assert!( + progress + .record_chunk(first.hash(), HOP_CHUNK_BYTES - 1) + .is_err() + ); + assert!(progress.prepare_root(&ticket).is_err()); + progress + .record_chunk(first.hash(), HOP_CHUNK_BYTES) + .unwrap(); + let mut resumed = UploadProgress::restore(&progress.encode()).unwrap(); + assert_eq!( + resumed.next_chunk().unwrap(), + ChunkRequest { + index: 1, + offset: HOP_CHUNK_BYTES as u64, + byte_len: 3 + } + ); + resumed.record_chunk([2; 32], 3).unwrap(); + assert!(resumed.next_chunk().is_none()); + let root = resumed.prepare_root(&ticket).unwrap(); + let plaintext = ticket.decrypt(&root.encrypted).unwrap(); + let DecodedRoot::Chunked(metadata) = decode_root(&plaintext).unwrap() else { + panic!("chunked root") + }; + assert_eq!(metadata.chunks(), &[first.hash(), [2; 32]]); + assert_eq!(metadata.total_size(), (HOP_CHUNK_BYTES + 3) as u64); + + let count = HOP_MAX_FILE_BYTES.div_ceil(HOP_CHUNK_BYTES as u64) as usize; + let largest = encode_chunked_envelope(HOP_MAX_FILE_BYTES, &vec![[0; 32]; count]).unwrap(); + let DecodedRoot::Chunked(largest) = decode_root(&largest).unwrap() else { + panic!("maximum native file") + }; + assert_eq!(largest.total_size(), u64::from(u32::MAX)); + assert!(encode_chunked_envelope(HOP_MAX_FILE_BYTES + 1, &vec![[0; 32]; count]).is_err()); +} + +struct NativeSender { + keypair: Keypair, +} + +#[async_trait] +impl SenderProofProviding for NativeSender { + async fn proof(&self, hash: &[u8; 32]) -> Result { + let timestamp = 1_700_000_000_000; + let payload = sender_proof_payload(hash, timestamp); + Ok(SenderProof { + sender: MultiSigner::Sr25519(self.keypair.public.to_bytes()), + signature: MultiSignature::Sr25519( + self.keypair + .sign(signing_context(b"substrate").bytes(&payload)) + .to_bytes(), + ), + submit_timestamp: timestamp, + }) + } +} + +#[test] +fn native_submit_proof_verifies_over_exact_encrypted_entry_and_timestamp() { + futures::executor::block_on(async { + let ticket = ticket(7); + let prepared = PreparedUpload::inline(b"native upload", &ticket).unwrap(); + let sender = NativeSender { + keypair: super::tests::ticket(8).signing_keypair().unwrap(), + }; + let rpc = ScriptedRpc::new(vec![( + "hop_submit", + Ok(json!({ + "poolStatus": { "entryCount": 1, "totalBytes": 100, "maxBytes": 1_000 } + })), + )]); + let submitted = HopClient::new(&rpc) + .submit(&prepared, &sender) + .await + .unwrap(); + let calls = rpc.calls.lock(); + let params = &calls[0].1; + let data = + hex::decode(params["data"].as_str().unwrap().strip_prefix("0x").unwrap()).unwrap(); + assert_eq!(submitted.hash, blake2b_256(&data)); + assert_eq!( + &*ticket.decrypt(&data).unwrap(), + &*encode_inline(b"native upload").unwrap() + ); + let encoded_signature = hex::decode( + params["signature"] + .as_str() + .unwrap() + .strip_prefix("0x") + .unwrap(), + ) + .unwrap(); + assert_eq!(encoded_signature[0], 1); + let signature = schnorrkel::Signature::from_bytes(&encoded_signature[1..]).unwrap(); + let mut native_payload = b"hop-submit-v1:".to_vec(); + native_payload.extend_from_slice(&blake2b_256(&data)); + native_payload + .extend_from_slice(¶ms["submit_timestamp"].as_u64().unwrap().to_le_bytes()); + sender + .keypair + .public + .verify( + signing_context(b"substrate").bytes(&blake2b_256(&native_payload)), + &signature, + ) + .unwrap(); + assert_eq!( + params["recipients"], + json!([prefixed_hex(&ticket.recipient().unwrap().encode())]) + ); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/hop_access.rs b/rust/crates/truapi-server/src/runtime/native_chat/hop_access.rs new file mode 100644 index 000000000..fc79455ed --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/hop_access.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-private HOP connections: trusted endpoints, current session and grants. + +use std::{future::Future, sync::Arc, time::Duration}; + +use futures::{FutureExt, future::Either}; +use futures_timer::Delay; +use serde_json::Value; +use subxt_rpcs::client::RpcClientT; + +use super::{ + ChatError, NativeChatContext, + background::{require_authorized, require_upload_authorized}, + hop::{HopError, HopRpc}, +}; +use crate::host_rpc_client::HostRpcClient; + +const RPC_TIMEOUT: Duration = Duration::from_secs(30); + +pub(super) struct SessionHopRpc { + context: NativeChatContext, + product: String, + rpc: HostRpcClient, +} + +async fn bounded(future: impl Future) -> Result { + let future = future.fuse(); + let timeout = Delay::new(RPC_TIMEOUT).fuse(); + futures::pin_mut!(future, timeout); + match futures::future::select(future, timeout).await { + Either::Left((result, _)) => Ok(result), + Either::Right(_) => Err(ChatError::NetworkUnavailable), + } +} + +impl SessionHopRpc { + pub(super) async fn connect( + context: &NativeChatContext, + product: &str, + endpoint: &str, + ) -> Result { + require_authorized(context, product).await?; + let platform = context.services.platform.as_ref(); + let genesis = context.services.bulletin.genesis_hash(); + let allowed = bounded(platform.allowed_hop_endpoints(genesis)) + .await? + .map_err(|_| ChatError::NetworkUnavailable)?; + truapi_platform::ensure_allowed_hop_endpoint(endpoint, &allowed) + .map_err(|_| ChatError::InvalidStatement)?; + require_authorized(context, product).await?; + let connection = bounded(platform.connect_hop(genesis, endpoint.to_owned())) + .await? + .map_err(|_| ChatError::NetworkUnavailable)?; + // Establish response-pump ownership before the next await, so failed + // authorization drops and closes a successfully opened connection. + let rpc = HostRpcClient::new(Arc::from(connection), context.services.spawner.clone()); + require_authorized(context, product).await?; + Ok(Self { + context: context.clone(), + product: product.to_owned(), + rpc, + }) + } +} + +#[async_trait::async_trait] +impl HopRpc for SessionHopRpc { + async fn call(&self, method: &str, params: Value) -> Result { + let authorize = || async { + if method == "hop_submit" { + require_upload_authorized(&self.context, &self.product).await + } else { + require_authorized(&self.context, &self.product).await + } + }; + authorize() + .await + .map_err(|_| HopError::Transport("Chat authorization ended".into()))?; + if !params.is_array() && !params.is_object() { + return Err(HopError::Codec( + "RPC parameters must be an array or object".into(), + )); + } + let encoded = serde_json::value::to_raw_value(¶ms) + .map_err(|_| HopError::Codec("invalid RPC parameters".into()))?; + let result = bounded(self.rpc.request_raw(method, Some(encoded))) + .await + .map_err(|_| HopError::Transport("HOP request timed out".into()))?; + authorize() + .await + .map_err(|_| HopError::Transport("Chat authorization ended".into()))?; + let raw = result.map_err(|error| match error { + // Upstream text is not trusted and may echo request material. + subxt_rpcs::Error::User(error) => HopError::Rpc { + code: i64::from(error.code), + message: "remote HOP request failed".into(), + }, + _ => HopError::Transport("HOP connection failed".into()), + })?; + serde_json::from_str(raw.get()).map_err(|_| HopError::Codec("invalid RPC result".into())) + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity.rs new file mode 100644 index 000000000..981e493ad --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from polkavm-app-kit polkavm-chat-v2/src/recipient.rs and Brevity's +// brevity-ffi/src/handle.rs peer resolution. Copyright their contributors. + +//! Host-authenticated public identity. People owns encryption keys; Asset Hub +//! dotNS owns names. Neither a product nor a peer supplies either association. + +mod dotns; +mod rpc; +mod schema; + +use super::NativeChatContext; +use truapi::latest::HostProductDeviceChatError as Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedPeer { + pub(crate) identity_account_id: [u8; 32], + pub(crate) chat_public_key: [u8; 32], + pub(crate) username: Option, +} + +pub(crate) async fn resolve_username( + context: &NativeChatContext, + username: &str, +) -> Result { + ensure_session(context)?; + let username = normalize_username(username)?; + let mut directory = dotns::Directory::open(context) + .await + .map_err(directory_error)?; + let owner = directory + .owner(&username) + .await + .map_err(directory_error)? + .ok_or(Error::RecipientNotFound)?; + if let Some(account) = owner.account { + match people_key(context, account).await { + Ok(chat_public_key) => { + return Ok(ResolvedPeer { + identity_account_id: account, + chat_public_key, + username: Some(username), + }); + } + Err(Error::RecipientNotFound) if owner.is_unmapped_hint() => {} + Err(error) => return Err(error), + } + } + let account = username_candidate(context, &username, &owner.address).await?; + let chat_public_key = people_key(context, account).await?; + ensure_session(context)?; + Ok(ResolvedPeer { + identity_account_id: account, + chat_public_key, + username: Some(username), + }) +} + +pub(crate) async fn resolve_account( + context: &NativeChatContext, + account: [u8; 32], +) -> Result { + let chat_public_key = people_key(context, account).await?; + // A directory outage must not turn a chain-authenticated incoming identity + // into an arbitrary-key fallback or make its independent People key unusable. + let username = match dotns::verified_label(context, &account).await { + Ok(username) => username, + Err(reason) => { + tracing::debug!(%reason, "native Chat peer name unavailable"); + None + } + }; + ensure_session(context)?; + Ok(ResolvedPeer { + identity_account_id: account, + chat_public_key, + username, + }) +} + +async fn username_candidate( + context: &NativeChatContext, + username: &str, + owner: &[u8; 20], +) -> Result<[u8; 32], Error> { + ensure_session(context)?; + let snapshot = rpc::Snapshot::open(context, context.genesis_hash) + .await + .map_err(network_error)?; + let metadata = snapshot.metadata().await.map_err(network_error)?; + if let Some(schema) = schema::UsernameOwnerSchema::new(&metadata).map_err(directory_error)? { + let key = schema.key(username).map_err(directory_error)?; + if let Some(encoded) = snapshot.storage_value(&key).await.map_err(network_error)? { + let candidate: [u8; 32] = encoded + .as_slice() + .try_into() + .map_err(|_| directory_error("legacy username owner is not AccountId32".into()))?; + if let Some(account) = + dotns::verified_candidate(&[candidate], owner).map_err(directory_error)? + { + return Ok(account); + } + } + } + ensure_session(context)?; + let backend = context + .services + .identity_backend_host() + .ok_or(Error::NetworkUnavailable)?; + let candidates = backend + .identity_username_candidates(username.to_owned(), context.genesis_hash) + .await + .map_err(|_| Error::NetworkUnavailable)?; + ensure_session(context)?; + // The registry already proves this name exists. An unavailable, stale or + // incomplete index is not evidence that its recipient is absent. + dotns::verified_candidate(&candidates, owner) + .map_err(directory_error)? + .ok_or(Error::NetworkUnavailable) +} + +async fn people_key(context: &NativeChatContext, account: [u8; 32]) -> Result<[u8; 32], Error> { + ensure_session(context)?; + if account == [0; 32] { + return Err(Error::InvalidRequest); + } + if context.genesis_hash != context.services.people_chain_genesis_hash { + return Err(Error::NetworkUnavailable); + } + let snapshot = rpc::Snapshot::open(context, context.genesis_hash) + .await + .map_err(network_error)?; + let metadata = snapshot.metadata().await.map_err(network_error)?; + let schema = schema::ConsumerSchema::new(&metadata).map_err(directory_error)?; + let key = schema.key(&account); + let value = snapshot + .storage_value(&key) + .await + .map_err(network_error)? + .ok_or(Error::RecipientNotFound)?; + let public_key = schema.identifier(&value).map_err(directory_error)?; + ensure_session(context)?; + Ok(public_key) +} + +fn ensure_session(context: &NativeChatContext) -> Result<(), Error> { + if (context.session_valid)() { + Ok(()) + } else { + Err(Error::NotConnected) + } +} + +fn network_error(reason: String) -> Error { + tracing::debug!(%reason, "native Chat configured chain unavailable"); + Error::NetworkUnavailable +} + +fn directory_error(reason: String) -> Error { + tracing::debug!(%reason, "native Chat configured identity directory unavailable"); + Error::NetworkUnavailable +} + +fn normalize_username(username: &str) -> Result { + // Exact-key semantics of the deployed guest and host identity encoding: + // ASCII case folding only; never rewrite suffix digits or resolve a prefix. + let username = username.trim(); + if username.is_empty() || username.len() > 32 { + return Err(Error::InvalidRequest); + } + let username = username.to_ascii_lowercase(); + if username + .bytes() + .any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-')) + || username.starts_with('.') + || username.ends_with('.') + || username.contains("..") + { + return Err(Error::InvalidRequest); + } + Ok(username) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_username_normalization_never_rewrites_numeric_suffixes() { + assert_eq!(normalize_username(" ALICE.01 ").unwrap(), "alice.01"); + assert_eq!(normalize_username("alice.1").unwrap(), "alice.1"); + assert_ne!( + normalize_username("alice.01"), + normalize_username("alice.1") + ); + assert_eq!( + normalize_username(" ALICE-JANE.0123 ").unwrap(), + "alice-jane.0123" + ); + for name in [ + "", + "álice", + ".alice", + "alice.", + "alice..01", + "alice\0", + "alice/01", + ] { + assert!(normalize_username(name).is_err()); + } + assert!(normalize_username(&"a".repeat(33)).is_err()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity/dotns.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity/dotns.rs new file mode 100644 index 000000000..886813903 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity/dotns.rs @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Adapted from Brevity's dotns_identity.rs and Host dotns_gateway.rs. + +//! Resolve the configured Asset Hub's on-chain PoP directory, never a guessed +//! backend URL. Protocol components and TLD are discovered from the gateway at +//! one finalized pin. The registry, not the append-only LabelStore, owns names. + +use super::{NativeChatContext, normalize_username, rpc::Snapshot, schema}; +#[cfg(test)] +use crate::host_logic::dotns_gateway as gateway; +use crate::host_logic::dotns_gateway::{ + DotnsTransport, account_to_h160, call_bytes32, call_no_args, decode_address, decode_bool, + discover_pop_controller, is_dns_label, is_pop_issued, namehash_under, network_tld, + protocol_component, resolve_labels, tld_node, +}; + +pub(super) struct Directory { + snapshot: Snapshot, + controller: [u8; 20], + registry: [u8; 20], + tld: [u8; 32], + schema: schema::GatewaySchema, +} + +pub(super) struct Owner { + pub(super) account: Option<[u8; 32]>, + pub(super) address: [u8; 20], +} + +impl Owner { + pub(super) fn is_unmapped_hint(&self) -> bool { + self.account + .is_none_or(|account| account[20..] == [0xee; 12]) + } +} + +impl Directory { + pub(super) async fn open(context: &NativeChatContext) -> Result { + let genesis = context + .services + .asset_hub_chain_genesis_hash() + .ok_or("current Host network has no Asset Hub directory")?; + let mut snapshot = Snapshot::open(context, genesis).await?; + let schema = schema::validate_gateway(&snapshot.metadata().await?)?; + let controller = discover_pop_controller(&mut snapshot) + .await? + .filter(|address| *address != [0; 20]) + .ok_or("current Host network has no dotNS PoP controller")?; + let output = snapshot + .view(&controller, call_no_args("protocolRegistry()")) + .await?; + let protocol = address(&output)?; + if protocol == [0; 20] { + return Err("dotNS protocol registry is unconfigured".into()); + } + let tld = network_tld(&mut snapshot, &protocol).await?; + // The Host's configured suffix is a consistency check, never permission + // to try another chain, contract or identity backend when lookup fails. + let suffix = tld + .strip_prefix('.') + .ok_or("dotNS TLD has no leading dot")?; + if suffix != context.network_suffix || !is_dns_label(suffix) { + return Err("dotNS TLD differs from current Host network".into()); + } + let registry = protocol_component(&mut snapshot, &protocol, "registry").await?; + if registry == [0; 20] { + return Err("dotNS name registry is unconfigured".into()); + } + Ok(Self { + snapshot, + controller, + registry, + tld: tld_node(&tld), + schema, + }) + } + + pub(super) async fn owner(&mut self, username: &str) -> Result, String> { + let owner = forward_owner( + &mut self.snapshot, + &self.controller, + &self.registry, + &self.tld, + username, + ) + .await?; + let Some(owner) = owner else { + return Ok(None); + }; + // Gateway-issued lite names retain the original AccountId32 even before + // the person maps a Revive address. This is a metadata-directed exact + // lookup, and still must agree with the live forward registry owner. + if is_lite_username(username) { + if let Some(key) = self.schema.lite_owner_key(username)? { + if let Some(encoded) = self.snapshot.storage_value(&key).await? { + return Ok(Some(Owner { + account: Some(account_for_owner(&encoded, &owner)?), + address: owner, + })); + } + } + } + // ReviveApi_account_id is the canonical mapped H160 -> AccountId32 + // conversion used by dotns-cli's get_substrate_address. Never pad a + // contract owner or borrow the caller's claimed account as a substitute. + let account = match self + .snapshot + .runtime_call("ReviveApi_account_id", &owner) + .await + { + Ok(encoded) => Some(account_for_owner(&encoded, &owner)?), + Err(reason) => { + // Some deployments cannot invert an unmapped H160. A native + // index may supply a candidate, never replace the owner above. + tracing::debug!(%reason, "dotNS inverse account lookup unavailable"); + None + } + }; + Ok(Some(Owner { + account, + address: owner, + })) + } + + pub(super) async fn labels(&mut self, account: &[u8; 32]) -> Result, String> { + let labels = resolve_labels(&mut self.snapshot, &self.controller, account).await?; + Ok(labels + .into_iter() + .filter(|label| { + normalize_username(label) + .as_ref() + .is_ok_and(|normalized| normalized == label) + && (is_dns_label(label) || is_lite_username(label)) + }) + .collect()) + } +} + +pub(super) async fn verified_label( + context: &NativeChatContext, + account: &[u8; 32], +) -> Result, String> { + let mut directory = Directory::open(context).await?; + let labels = directory.labels(account).await?; + // Native preference is full then lite. Check EVERY candidate forwards: + // transferred-away names survive forever in the append-only LabelStore. + for lite in [false, true] { + for label in labels + .iter() + .filter(|label| is_lite_username(label) == lite) + { + let owner = forward_owner( + &mut directory.snapshot, + &directory.controller, + &directory.registry, + &directory.tld, + label, + ) + .await?; + if owner == Some(account_to_h160(account)) { + return Ok(Some(label.clone())); + } + } + } + Ok(None) +} + +fn is_lite_username(label: &str) -> bool { + // Lookup is not registration: preserve every already-issued numeric suffix + // verbatim, including deployments with longer suffixes and DNS stems. + // isPopIssued below is the authority, not today's registration shape rules. + label.len() <= 32 + && label.split_once('.').is_some_and(|(stem, suffix)| { + is_dns_label(stem) + && !suffix.is_empty() + && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn account_for_owner(encoded: &[u8], owner: &[u8; 20]) -> Result<[u8; 32], String> { + let account: [u8; 32] = encoded + .try_into() + .map_err(|_| "dotNS owner runtime API did not return AccountId32")?; + if account == [0; 32] || account_to_h160(&account) != *owner { + return Err("dotNS owner account does not map back to authoritative H160".into()); + } + Ok(account) +} + +pub(super) fn verified_candidate( + candidates: &[[u8; 32]], + owner: &[u8; 20], +) -> Result, String> { + if candidates.len() > 32 { + return Err("too many username candidates".into()); + } + let mut verified = None; + for account in candidates { + if *account == [0; 32] { + return Err("zero username candidate".into()); + } + if account_to_h160(account) != *owner { + continue; + } + if verified.replace(*account).is_some() { + return Err("ambiguous username candidates".into()); + } + } + Ok(verified) +} + +/// Both deployed representations are chain-visible: earlier contracts mint a +/// dotted lite label as one second-level token; current DotnsPopController +/// registers it as a subnode beneath its numeric suffix. Read the registry's +/// owner (which itself delegates tokenized nodes to registrar.ownerOf). Never +/// flatten a suffix or choose between conflicting owners of the two spellings. +async fn forward_owner( + transport: &mut T, + controller: &[u8; 20], + registry: &[u8; 20], + tld: &[u8; 32], + username: &str, +) -> Result, String> { + if !(is_dns_label(username) || is_lite_username(username)) + || !is_pop_issued(transport, controller, username).await? + { + return Ok(None); + } + let atomic_node = namehash_under(tld, username); + let atomic = node_owner(transport, registry, &atomic_node).await?; + if !is_lite_username(username) { + return Ok(atomic); + } + let (stem, suffix) = username + .split_once('.') + .ok_or("invalid dotted lite label")?; + let subnode = namehash_under(&namehash_under(tld, suffix), stem); + let nested = node_owner(transport, registry, &subnode).await?; + match (atomic, nested) { + (Some(a), Some(b)) if a != b => Err("ambiguous dotNS lite name owner".into()), + (Some(owner), _) | (_, Some(owner)) => Ok(Some(owner)), + (None, None) => Ok(None), + } +} + +async fn node_owner( + transport: &mut T, + registry: &[u8; 20], + node: &[u8; 32], +) -> Result, String> { + let exists = transport + .view(registry, call_bytes32("recordExists(bytes32)", node)) + .await?; + if exists.len() != 32 { + return Err("dotNS recordExists returned a non-word".into()); + } + if !decode_bool(&exists).map_err(|error| error.to_string())? { + return Ok(None); + } + let owner = address( + &transport + .view(registry, call_bytes32("owner(bytes32)", node)) + .await?, + )?; + if owner == [0; 20] { + return Err("existing dotNS record has a zero owner".into()); + } + Ok(Some(owner)) +} + +fn address(bytes: &[u8]) -> Result<[u8; 20], String> { + if bytes.len() != 32 { + return Err("dotNS address is not one ABI word".into()); + } + decode_address(bytes).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use gateway::DotnsViewError; + use std::collections::HashMap; + + struct Registry { + issued: bool, + owners: HashMap<[u8; 32], [u8; 20]>, + } + + #[truapi_platform::async_trait] + impl DotnsTransport for Registry { + async fn storage(&mut self, _: Vec) -> Result>, String> { + unreachable!() + } + async fn view(&mut self, _: &[u8; 20], input: Vec) -> Result, DotnsViewError> { + let mut word = vec![0; 32]; + if input[..4] == gateway::selector("isPopIssued(string)") { + word[31] = self.issued as u8; + } else { + let node: [u8; 32] = input[4..].try_into().unwrap(); + if input[..4] == gateway::selector("recordExists(bytes32)") { + word[31] = self.owners.contains_key(&node) as u8; + } else { + assert_eq!(input[..4], gateway::selector("owner(bytes32)")); + word[12..].copy_from_slice(self.owners.get(&node).unwrap()); + } + } + Ok(word) + } + } + + #[test] + fn supports_both_deployed_lite_nodes_without_accepting_conflicting_owners() { + futures::executor::block_on(async { + let tld = tld_node(".paseo"); + let atomic = namehash_under(&tld, "alice.42"); + let nested = namehash_under(&namehash_under(&tld, "42"), "alice"); + let mut registry = Registry { + issued: true, + owners: HashMap::new(), + }; + registry.owners.insert(atomic, [1; 20]); + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice.42") + .await + .unwrap(), + Some([1; 20]) + ); + registry.owners.clear(); + registry.owners.insert(nested, [4; 20]); + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice.42") + .await + .unwrap(), + Some([4; 20]) + ); + registry.owners.insert(atomic, [1; 20]); + assert!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice.42") + .await + .is_err() + ); + registry.issued = false; + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice.42") + .await + .unwrap(), + None + ); + }); + } + + #[test] + fn forward_lookup_preserves_dns_stems_and_exact_long_numeric_suffixes() { + futures::executor::block_on(async { + let tld = tld_node(".paseo"); + let node = namehash_under(&namehash_under(&tld, "0123"), "alice-jane"); + let mut registry = Registry { + issued: true, + owners: HashMap::from([(node, [7; 20])]), + }; + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice-jane.0123") + .await + .unwrap(), + Some([7; 20]) + ); + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alice-jane.123") + .await + .unwrap(), + None + ); + assert_eq!( + forward_owner(&mut registry, &[2; 20], &[3; 20], &tld, "alicejane.0123") + .await + .unwrap(), + None + ); + }); + } + + #[test] + fn mapped_owner_cannot_be_replaced_by_claimed_account_or_alias() { + let account = [3; 32]; + let owner = account_to_h160(&account); + assert_eq!(account_for_owner(&account, &owner).unwrap(), account); + assert!(account_for_owner(&[4; 32], &owner).is_err()); + assert!(account_for_owner(&[3; 31], &owner).is_err()); + let mut padded = [0xee; 32]; + padded[..20].copy_from_slice(&[5; 20]); + assert_eq!(account_for_owner(&padded, &[5; 20]).unwrap(), padded); + } + + #[test] + fn backend_candidates_never_override_chain_owner_or_choose_an_ambiguous_match() { + let account = [3; 32]; + let owner = account_to_h160(&account); + assert_eq!( + verified_candidate(&[account], &owner).unwrap(), + Some(account) + ); + assert_eq!(verified_candidate(&[[4; 32]], &owner).unwrap(), None); + assert!(verified_candidate(&[account, account], &owner).is_err()); + assert!(verified_candidate(&[[0; 32]], &owner).is_err()); + assert!(verified_candidate(&[account; 33], &owner).is_err()); + // Revive's inverse fallback shares the H160 but is not evidence of the + // sr25519 identity's People key. Two matching accounts are ambiguous. + let mut padded = [0xee; 32]; + padded[..20].copy_from_slice(&owner); + assert!(verified_candidate(&[account, padded], &owner).is_err()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity/rpc.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity/rpc.rs new file mode 100644 index 000000000..cd92cef20 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity/rpc.rs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from the Host-owned Coinage RPC edge and deployed Chat recipient decoder. + +use super::super::NativeChatContext; +use crate::host_logic::dotns_gateway::{ + DotnsTransport, DotnsViewError, VIEW_CALL_ORIGIN, encode_revive_call, view_output, +}; +use crate::host_rpc_client::HostRpcClient; +use core::time::Duration; +use futures::FutureExt; +use serde_json::{Value, json}; +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; +use subxt_rpcs::{RpcClient, client::RpcParams}; +use truapi_platform::JsonRpcConnection; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; + +pub(super) const MAX_METADATA_BYTES: usize = 4 * 1024 * 1024; +const MAX_STORAGE_BYTES: usize = 64 * 1024; +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(45); +const STEP_TIMEOUT: Duration = Duration::from_secs(10); + +/// A finalized hash, metadata, storage and runtime calls share this connection. +/// HostRpcClient owns request correlation, cancellation and connection cleanup. +pub(super) struct Snapshot { + rpc: RpcClient, + at: [u8; 32], + started: Instant, + session_valid: Arc bool + Send + Sync>, +} + +impl Snapshot { + pub(super) async fn open( + context: &NativeChatContext, + genesis: [u8; 32], + ) -> Result { + if genesis == [0; 32] || !(context.session_valid)() { + return Err("Chat chain is unconfigured or session expired".into()); + } + let started = Instant::now(); + let connect = context.services.platform.connect(genesis).fuse(); + let timeout = futures_timer::Delay::new(STEP_TIMEOUT).fuse(); + futures::pin_mut!(connect, timeout); + let connection: Arc = futures::select! { + result = connect => result.map_err(|_| "configured Chat chain connection failed")?.into(), + _ = timeout => return Err("configured Chat chain connection timed out".into()), + }; + let mut snapshot = Self { + rpc: RpcClient::new(HostRpcClient::new( + connection, + context.services.spawner.clone(), + )), + at: [0; 32], + started, + session_valid: context.session_valid.clone(), + }; + if hash(&snapshot.call("chain_getBlockHash", json!([0])).await?)? != genesis { + return Err("Chat chain genesis differs from configured network".into()); + } + snapshot.at = hash(&snapshot.call("chain_getFinalizedHead", json!([])).await?)?; + if snapshot.at == [0; 32] { + return Err("Chat chain finalized hash is zero".into()); + } + Ok(snapshot) + } + + async fn call(&self, method: &str, params: Value) -> Result { + if !(self.session_valid)() { + return Err("Chat session expired".into()); + } + let remaining = LOOKUP_TIMEOUT + .checked_sub(self.started.elapsed()) + .ok_or("Chat identity lookup timed out")? + .min(STEP_TIMEOUT); + let mut encoded = RpcParams::new(); + for value in params.as_array().ok_or("invalid Chat RPC parameters")? { + encoded + .push(value) + .map_err(|_| "invalid Chat RPC parameter")?; + } + let request = self.rpc.request::(method, encoded).fuse(); + let timeout = futures_timer::Delay::new(remaining).fuse(); + futures::pin_mut!(request, timeout); + let result = futures::select! { + result = request => result.map_err(|_| format!("Chat identity RPC failed ({method})")), + _ = timeout => Err(format!("Chat identity RPC timed out ({method})")), + }; + if !(self.session_valid)() { + return Err("Chat session expired".into()); + } + result + } + + pub(super) async fn metadata(&self) -> Result, String> { + bytes( + &self + .call("state_getMetadata", json!([hex0x(&self.at)])) + .await?, + MAX_METADATA_BYTES, + ) + } + + pub(super) async fn runtime_call( + &self, + function: &str, + input: &[u8], + ) -> Result, String> { + bytes( + &self + .call( + "state_call", + json!([function, hex0x(input), hex0x(&self.at)]), + ) + .await?, + MAX_STORAGE_BYTES, + ) + } + + pub(super) async fn storage_value(&self, key: &[u8]) -> Result>, String> { + let response = self + .call( + "state_queryStorageAt", + json!([[hex0x(key)], hex0x(&self.at)]), + ) + .await?; + storage_response(&response, key, self.at) + } +} + +#[truapi_platform::async_trait] +impl DotnsTransport for Snapshot { + async fn storage(&mut self, key: Vec) -> Result>, String> { + self.storage_value(&key).await + } + + async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, DotnsViewError> { + let output = self + .runtime_call( + "ReviveApi_call", + &encode_revive_call(&VIEW_CALL_ORIGIN, dest, &input), + ) + .await + .map_err(DotnsViewError::Failed)?; + let output = view_output(&output)?; + validate_view_bounds(&input, &output).map_err(DotnsViewError::Failed)?; + Ok(output) + } +} + +fn validate_view_bounds(input: &[u8], output: &[u8]) -> Result<(), String> { + use crate::host_logic::dotns_gateway::selector; + if input.get(..4) == Some(selector("getLabels(uint256,uint256)").as_slice()) + || input.get(..4) == Some(selector("pendingClaims(address,uint256,uint256)").as_slice()) + { + // Both shared gateway readers request pages of sixteen. Check dynamic + // array lengths before their ABI decoders allocate or follow offsets; + // repeated offsets must not amplify a 64-KiB reply without a bound. + if output.get(..31) != Some([0; 31].as_slice()) || output.get(31) != Some(&32) { + return Err("noncanonical dotNS page array offset".into()); + } + let count = output.get(32..64).ok_or("truncated dotNS page length")?; + if count[..31] != [0; 31] || count[31] > 16 { + return Err("dotNS page exceeded requested label count".into()); + } + if output.len() < 64 + usize::from(count[31]) * 32 { + return Err("truncated dotNS page offset table".into()); + } + } + Ok(()) +} + +fn storage_response(response: &Value, key: &[u8], at: [u8; 32]) -> Result>, String> { + let blocks = response.as_array().ok_or("invalid Chat storage response")?; + if blocks.len() != 1 || hash(&blocks[0]["block"])? != at { + return Err("Chat storage response substituted finalized block".into()); + } + let rows = blocks[0]["changes"] + .as_array() + .ok_or("invalid Chat storage changes")?; + if rows.len() != 1 { + return Err("Chat storage response omitted or duplicated requested key".into()); + } + let row = rows[0].as_array().ok_or("invalid Chat storage row")?; + if row.len() != 2 || bytes(&row[0], MAX_STORAGE_BYTES)? != key { + return Err("Chat storage response substituted requested key".into()); + } + if row[1].is_null() { + Ok(None) + } else { + bytes(&row[1], MAX_STORAGE_BYTES).map(Some) + } +} + +fn bytes(value: &Value, max: usize) -> Result, String> { + let encoded = value + .as_str() + .and_then(|value| value.strip_prefix("0x")) + .ok_or("Chat chain bytes are not prefixed hex")?; + if encoded.len() > max * 2 || encoded.len() % 2 != 0 { + return Err("Chat chain bytes exceed decode bound or have odd length".into()); + } + hex::decode(encoded).map_err(|_| "Chat chain bytes contain invalid hex".into()) +} + +fn hash(value: &Value) -> Result<[u8; 32], String> { + bytes(value, 32)? + .try_into() + .map_err(|_| "Chat chain hash is not 32 bytes".into()) +} + +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_block_key_duplicate_and_missing_storage_substitutions() { + let at = [7; 32]; + let good = json!([{"block": hex0x(&at), "changes": [["0x0102", "0x42"]]}]); + assert_eq!( + storage_response(&good, &[1, 2], at).unwrap(), + Some(vec![0x42]) + ); + assert!(storage_response(&good, &[1, 3], at).is_err()); + assert!(storage_response(&good, &[1, 2], [8; 32]).is_err()); + for changes in [ + json!([]), + json!([["0x0102", null], ["0x0102", "0x42"]]), + json!([["0x0102", false]]), + ] { + assert!( + storage_response( + &json!([{"block": hex0x(&at), "changes": changes}]), + &[1, 2], + at + ) + .is_err() + ); + } + assert_eq!( + storage_response( + &json!([{"block": hex0x(&at), "changes": [["0x0102", null]]}]), + &[1, 2], + at + ) + .unwrap(), + None + ); + } + + #[test] + fn dynamic_directory_pages_cannot_amplify_unbounded_claimed_counts() { + let input = + crate::host_logic::dotns_gateway::call_u256_pair("getLabels(uint256,uint256)", 0, 16); + let mut page = vec![0; 64]; + page[31] = 32; + assert!(validate_view_bounds(&input, &page).is_ok()); + page[63] = 17; + assert!(validate_view_bounds(&input, &page).is_err()); + page[63] = 1; + assert!(validate_view_bounds(&input, &page).is_err()); + page.resize(96, 0); + assert!(validate_view_bounds(&input, &page).is_ok()); + page[32] = 1; + assert!(validate_view_bounds(&input, &page).is_err()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity/schema.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity/schema.rs new file mode 100644 index 000000000..0f153e618 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity/schema.rs @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Metadata layout and bounded SCALE decoder adapted from polkavm-app-kit +// polkavm-chat-v2/src/recipient.rs (57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17). + +use super::rpc::MAX_METADATA_BYTES; +use frame_metadata::v14::{StorageEntryType, StorageHasher}; +use frame_metadata::{META_RESERVED, RuntimeMetadata, RuntimeMetadataPrefixed}; +use parity_scale_codec::{Compact, Decode, Encode, Input}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive, form::PortableForm}; +use sp_crypto_hashing::{blake2_128, blake2_256, twox_64, twox_128, twox_256}; + +pub(super) struct ConsumerSchema { + map: StorageMap, + identifier: ByteEncoding, +} + +impl ConsumerSchema { + pub(super) fn new(bytes: &[u8]) -> Result { + let metadata = PalletSchema::new(bytes, "Resources")?; + let StorageEntryType::Map { + hashers, + key, + value, + } = metadata.entry("Consumers")? + else { + return Err("Resources.Consumers is not a map".into()); + }; + if hashers.len() != 1 + || ByteEncoding::from_type(&metadata.types, key.id)? != ByteEncoding::Fixed(32) + { + return Err("Resources.Consumers must declare a single AccountId32 key".into()); + } + let identifier = identifier_encoding(&metadata.types, value.id)?; + Ok(Self { + map: StorageMap::new(&metadata.prefix, "Consumers", hashers[0].clone()), + identifier, + }) + } + + pub(super) fn key(&self, account: &[u8; 32]) -> Vec { + self.map.key(account) + } + + pub(super) fn identifier(&self, value: &[u8]) -> Result<[u8; 32], String> { + self.identifier.decode_identifier(value) + } +} + +/// Older People runtimes retained a username -> AccountId32 index. It is only +/// a candidate source; Asset Hub's finalized dotNS registry remains authority. +pub(super) struct UsernameOwnerSchema { + map: StorageMap, + label: ByteEncoding, +} + +impl UsernameOwnerSchema { + pub(super) fn new(bytes: &[u8]) -> Result, String> { + let metadata = PalletSchema::new(bytes, "Resources")?; + if !metadata + .entries + .iter() + .any(|(name, _)| name == "UsernameOwnerOf") + { + return Ok(None); + } + let StorageEntryType::Map { + hashers, + key, + value, + } = metadata.entry("UsernameOwnerOf")? + else { + return Err("Resources.UsernameOwnerOf is not a map".into()); + }; + if hashers.len() != 1 + || ByteEncoding::from_type(&metadata.types, value.id)? != ByteEncoding::Fixed(32) + { + return Err( + "Resources.UsernameOwnerOf must declare AccountId32 owners and one key".into(), + ); + } + Ok(Some(Self { + map: StorageMap::new(&metadata.prefix, "UsernameOwnerOf", hashers[0].clone()), + label: ByteEncoding::from_type(&metadata.types, key.id)?, + })) + } + + pub(super) fn key(&self, username: &str) -> Result, String> { + let encoded = match self.label { + ByteEncoding::Sequence => username.as_bytes().encode(), + ByteEncoding::Fixed(length) if length as usize == username.len() => { + username.as_bytes().to_vec() + } + ByteEncoding::Fixed(_) => { + return Err("username length differs from legacy directory key".into()); + } + }; + Ok(self.map.key(&encoded)) + } +} + +pub(super) struct GatewaySchema { + lite_owner: Option<(StorageMap, ByteEncoding)>, +} + +impl GatewaySchema { + pub(super) fn lite_owner_key(&self, label: &str) -> Result>, String> { + let Some((map, encoding)) = &self.lite_owner else { + return Ok(None); + }; + let encoded = match encoding { + ByteEncoding::Sequence => label.as_bytes().encode(), + ByteEncoding::Fixed(length) if *length as usize == label.len() => { + label.as_bytes().to_vec() + } + ByteEncoding::Fixed(_) => { + return Err("lite username length does not match declared directory key".into()); + } + }; + Ok(Some(map.key(&encoded))) + } +} + +/// The shared dotNS discovery helper uses this declared plain H160 layout. +/// Refuse a renamed/retyped entry instead of interpreting arbitrary bytes as +/// the configured directory contract address. +pub(super) fn validate_gateway(bytes: &[u8]) -> Result { + let metadata = PalletSchema::new(bytes, "DotnsGateway")?; + let StorageEntryType::Plain(value) = metadata.entry("DispatcherAddress")? else { + return Err("DotnsGateway.DispatcherAddress is not plain storage".into()); + }; + if metadata.prefix != "DotnsGateway" + || ByteEncoding::from_type(&metadata.types, value.id)? != ByteEncoding::Fixed(20) + { + return Err( + "DotnsGateway.DispatcherAddress does not declare the deployed H160 layout".into(), + ); + } + let lite_owner = if metadata + .entries + .iter() + .any(|(name, _)| name == "LiteLabelOwner") + { + let StorageEntryType::Map { + hashers, + key, + value, + } = metadata.entry("LiteLabelOwner")? + else { + return Err("DotnsGateway.LiteLabelOwner is not a map".into()); + }; + if hashers.len() != 1 + || ByteEncoding::from_type(&metadata.types, value.id)? != ByteEncoding::Fixed(32) + { + return Err( + "DotnsGateway.LiteLabelOwner must declare AccountId32 owners and one key".into(), + ); + } + Some(( + StorageMap::new(&metadata.prefix, "LiteLabelOwner", hashers[0].clone()), + ByteEncoding::from_type(&metadata.types, key.id)?, + )) + } else { + None + }; + Ok(GatewaySchema { lite_owner }) +} + +struct PalletSchema { + types: PortableRegistry, + prefix: String, + entries: Vec<(String, StorageEntryType)>, +} + +impl PalletSchema { + fn new(bytes: &[u8], name: &str) -> Result { + let metadata: RuntimeMetadataPrefixed = decode_all(bytes)?; + if metadata.0 != META_RESERVED { + return Err("invalid Chat runtime metadata prefix".into()); + } + macro_rules! pallet { + ($metadata:expr) => {{ + let mut pallets = $metadata + .pallets + .into_iter() + .filter(|pallet| pallet.name == name); + let storage = pallets + .next() + .and_then(|pallet| pallet.storage) + .ok_or_else(|| format!("configured chain has no {name} directory pallet"))?; + if pallets.next().is_some() { + return Err("duplicate Chat directory pallet".into()); + } + Self { + types: $metadata.types, + prefix: storage.prefix, + entries: storage + .entries + .into_iter() + .map(|entry| (entry.name, entry.ty)) + .collect(), + } + }}; + } + Ok(match metadata.1 { + RuntimeMetadata::V14(metadata) => pallet!(metadata), + RuntimeMetadata::V15(metadata) => pallet!(metadata), + RuntimeMetadata::V16(metadata) => pallet!(metadata), + _ => return Err("Chat directory requires metadata V14, V15 or V16".into()), + }) + } + + fn entry(&self, name: &str) -> Result<&StorageEntryType, String> { + let mut entries = self.entries.iter().filter(|(entry, _)| entry == name); + let (_, entry) = entries + .next() + .ok_or_else(|| format!("Chat directory entry {name} is absent"))?; + if entries.next().is_some() { + return Err("duplicate Chat directory storage entry".into()); + } + Ok(entry) + } +} + +struct StorageMap { + prefix: [u8; 32], + hasher: StorageHasher, +} + +impl StorageMap { + fn new(pallet: &str, entry: &str, hasher: StorageHasher) -> Self { + let mut prefix = [0; 32]; + prefix[..16].copy_from_slice(&twox_128(pallet.as_bytes())); + prefix[16..].copy_from_slice(&twox_128(entry.as_bytes())); + Self { prefix, hasher } + } + + fn key(&self, encoded: &[u8]) -> Vec { + let mut key = Vec::with_capacity(64 + encoded.len()); + key.extend_from_slice(&self.prefix); + match self.hasher { + StorageHasher::Blake2_128 => key.extend_from_slice(&blake2_128(encoded)), + StorageHasher::Blake2_256 => key.extend_from_slice(&blake2_256(encoded)), + StorageHasher::Blake2_128Concat => { + key.extend_from_slice(&blake2_128(encoded)); + key.extend_from_slice(encoded); + } + StorageHasher::Twox128 => key.extend_from_slice(&twox_128(encoded)), + StorageHasher::Twox256 => key.extend_from_slice(&twox_256(encoded)), + StorageHasher::Twox64Concat => { + key.extend_from_slice(&twox_64(encoded)); + key.extend_from_slice(encoded); + } + StorageHasher::Identity => key.extend_from_slice(encoded), + } + key + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ByteEncoding { + Fixed(u32), + Sequence, +} + +impl ByteEncoding { + fn from_type(types: &PortableRegistry, mut id: u32) -> Result { + for _ in 0..16 { + let ty = types + .resolve(id) + .ok_or("missing Chat directory metadata type")?; + match &ty.type_def { + TypeDef::Array(array) if is_u8(types, array.type_param.id) => { + return Ok(Self::Fixed(array.len)); + } + TypeDef::Sequence(sequence) if is_u8(types, sequence.type_param.id) => { + return Ok(Self::Sequence); + } + TypeDef::Composite(composite) if composite.fields.len() == 1 => { + id = composite.fields[0].ty.id + } + TypeDef::Tuple(tuple) if tuple.fields.len() == 1 => id = tuple.fields[0].id, + _ => return Err("Chat directory type is not a byte array or vector".into()), + } + } + Err("Chat directory metadata exceeds type nesting bound".into()) + } + + fn decode_identifier(self, value: &[u8]) -> Result<[u8; 32], String> { + let mut input = value; + let length = match self { + Self::Fixed(length) => length, + Self::Sequence => { + Compact::::decode(&mut input) + .map_err(|_| "malformed Chat identifier length")? + .0 + } + }; + if !matches!(length, 32 | 65) { + return Err("Chat identifier must declare raw32 or typed65 bytes".into()); + } + let identifier = input + .get(..length as usize) + .ok_or("truncated Chat identifier")?; + let key = if length == 65 { + if identifier[0] != 0 { + return Err("Chat identifier is not type-0 X25519".into()); + } + // Native iOS intentionally ignores all 32 reserved container bytes. + &identifier[1..33] + } else { + identifier + }; + let key: [u8; 32] = key.try_into().map_err(|_| "invalid Chat key length")?; + validate_public_key(&key)?; + Ok(key) + } +} + +fn is_u8(types: &PortableRegistry, id: u32) -> bool { + types + .resolve(id) + .is_some_and(|ty| matches!(ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U8))) +} + +fn identifier_encoding(types: &PortableRegistry, mut id: u32) -> Result { + for _ in 0..16 { + let ty = types + .resolve(id) + .ok_or("missing Resources consumer metadata type")?; + match &ty.type_def { + TypeDef::Composite(composite) => { + let field = composite + .fields + .first() + .ok_or("consumer has no identifier_key")?; + if field.name.as_deref() == Some("identifier_key") { + let encoding = ByteEncoding::from_type(types, field.ty.id)?; + if matches!(encoding, ByteEncoding::Fixed(length) if !matches!(length, 32 | 65)) + { + return Err("unsupported fixed Chat identifier size".into()); + } + return Ok(encoding); + } + if composite.fields.len() == 1 && field.name.is_none() { + id = field.ty.id; + } else { + return Err("consumer must declare identifier_key as first field".into()); + } + } + TypeDef::Tuple(tuple) if tuple.fields.len() == 1 => id = tuple.fields[0].id, + _ => return Err("unsupported Resources consumer metadata type".into()), + } + } + Err("consumer metadata exceeds type nesting bound".into()) +} + +fn validate_public_key(key: &[u8; 32]) -> Result<(), String> { + // X25519 accepts masked/reduced aliases; identities must not. Compare the + // little-endian u-coordinate with p = 2^255 - 19 before scalar multiplication. + const P: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + if key.iter().rev().cmp(P.iter().rev()) != core::cmp::Ordering::Less { + return Err("noncanonical X25519 Chat identifier".into()); + } + // This fixed, non-secret scalar only tests contributory behavior; it is not + // the wallet's encryption key and no shared secret escapes the Host. + let probe = x25519_dalek::StaticSecret::from([0x42; 32]); + if !probe + .diffie_hellman(&x25519_dalek::PublicKey::from(*key)) + .was_contributory() + { + return Err("noncontributory X25519 Chat identifier".into()); + } + Ok(()) +} + +struct BudgetInput<'a> { + bytes: &'a [u8], + allocated: usize, + depth: usize, +} + +impl Input for BudgetInput<'_> { + fn remaining_len(&mut self) -> Result, parity_scale_codec::Error> { + Ok(Some(self.bytes.len())) + } + fn read(&mut self, into: &mut [u8]) -> Result<(), parity_scale_codec::Error> { + self.bytes.read(into) + } + fn descend_ref(&mut self) -> Result<(), parity_scale_codec::Error> { + self.depth += 1; + if self.depth > 64 { + return Err("Chat metadata nesting bound exceeded".into()); + } + Ok(()) + } + fn ascend_ref(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + fn on_before_alloc_mem(&mut self, size: usize) -> Result<(), parity_scale_codec::Error> { + self.allocated = self.allocated.saturating_add(size); + if self.allocated > 16 * 1024 * 1024 { + return Err("Chat metadata allocation bound exceeded".into()); + } + Ok(()) + } +} + +fn decode_all(bytes: &[u8]) -> Result { + if bytes.len() > MAX_METADATA_BYTES { + return Err("Chat metadata exceeds wire bound".into()); + } + let mut input = BudgetInput { + bytes, + allocated: 0, + depth: 0, + }; + let value = T::decode(&mut input).map_err(|_| "malformed or oversized Chat metadata")?; + if !input.bytes.is_empty() { + return Err("trailing Chat metadata bytes".into()); + } + Ok(value) +} + +#[cfg(test)] +#[path = "schema_tests.rs"] +mod tests; diff --git a/rust/crates/truapi-server/src/runtime/native_chat/identity/schema_tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/identity/schema_tests.rs new file mode 100644 index 000000000..63bc973d0 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/identity/schema_tests.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Metadata fixtures adapted from the deployed polkavm-chat-v2 recipient tests. + +use super::*; +use frame_metadata::v14::{ + ExtrinsicMetadata, PalletMetadata, PalletStorageMetadata, RuntimeMetadataV14, + StorageEntryMetadata, StorageEntryModifier, +}; +use parity_scale_codec::Encode; +use scale_info::{ + Path, PortableRegistryBuilder, Type, TypeDefArray, TypeDefSequence, build::Fields, +}; + +fn metadata(encoding: ByteEncoding, first_field: &str, account_len: u32) -> Vec { + let mut types = PortableRegistryBuilder::new(); + let byte = types.register_type(Type::new( + Path::default(), + vec![], + TypeDefPrimitive::U8, + vec![], + )); + let account = types.register_type(Type::new( + Path::default(), + vec![], + TypeDefArray::new(account_len, byte.into()), + vec![], + )); + let sequence = types.register_type(Type::new( + Path::default(), + vec![], + TypeDefSequence::new(byte.into()), + vec![], + )); + let identifier = match encoding { + ByteEncoding::Fixed(length) => types.register_type(Type::new( + Path::default(), + vec![], + TypeDefArray::new(length, byte.into()), + vec![], + )), + ByteEncoding::Sequence => types.register_type( + Type::builder_portable() + .path(Path::from_segments_unchecked(["BoundedBytes".into()])) + .composite(Fields::unnamed().field_portable(|field| field.ty(sequence))), + ), + }; + let consumer = types.register_type( + Type::builder_portable() + .path(Path::from_segments_unchecked(["ConsumerInfo".into()])) + .composite( + Fields::named() + .field_portable(|field| field.name(first_field.into()).ty(identifier)) + .field_portable(|field| field.name("unrelated_tail".into()).ty(sequence)), + ), + ); + RuntimeMetadataPrefixed( + META_RESERVED, + RuntimeMetadata::V14(RuntimeMetadataV14 { + types: types.finish(), + pallets: vec![PalletMetadata { + name: "Resources".into(), + storage: Some(PalletStorageMetadata { + prefix: "Resources".into(), + entries: vec![StorageEntryMetadata { + name: "Consumers".into(), + modifier: StorageEntryModifier::Optional, + ty: StorageEntryType::Map { + hashers: vec![StorageHasher::Blake2_128Concat], + key: account.into(), + value: consumer.into(), + }, + default: vec![0], + docs: vec![], + }], + }), + calls: None, + event: None, + constants: vec![], + error: None, + index: 10, + }], + extrinsic: ExtrinsicMetadata { + ty: byte.into(), + version: 4, + signed_extensions: vec![], + }, + ty: byte.into(), + }), + ) + .encode() +} + +fn container() -> Vec { + let mut result = vec![0; 65]; + result[1..33].copy_from_slice(&[0x37; 32]); + result +} + +#[test] +fn metadata_selects_raw_or_container_without_guessing_from_storage_length() { + let fixed = + ConsumerSchema::new(&metadata(ByteEncoding::Fixed(65), "identifier_key", 32)).unwrap(); + assert_eq!(fixed.identifier(&container()).unwrap(), [0x37; 32]); + let vector = + ConsumerSchema::new(&metadata(ByteEncoding::Sequence, "identifier_key", 32)).unwrap(); + assert_eq!( + vector.identifier(&container().encode()).unwrap(), + [0x37; 32] + ); + assert!(vector.identifier(&container()).is_err()); + let raw = + ConsumerSchema::new(&metadata(ByteEncoding::Fixed(32), "identifier_key", 32)).unwrap(); + let mut expected = [0x37; 32]; + expected[0] = 0; + assert_eq!(raw.identifier(&container()).unwrap(), expected); + let mut extended = vec![0x42; 32]; + extended.extend_from_slice(&[0; 40]); + assert_eq!(raw.identifier(&extended).unwrap(), [0x42; 32]); +} + +#[test] +fn undeclared_identifier_account_layout_and_malformed_keys_are_rejected() { + assert!(ConsumerSchema::new(&metadata(ByteEncoding::Fixed(64), "identifier_key", 32)).is_err()); + assert!(ConsumerSchema::new(&metadata(ByteEncoding::Fixed(65), "unrelated", 32)).is_err()); + assert!(ConsumerSchema::new(&metadata(ByteEncoding::Fixed(65), "identifier_key", 20)).is_err()); + let schema = + ConsumerSchema::new(&metadata(ByteEncoding::Fixed(65), "identifier_key", 32)).unwrap(); + let mut wrong_type = container(); + wrong_type[0] = 1; + assert!(schema.identifier(&wrong_type).is_err()); + let mut reserved = container(); + reserved[64] = 1; + assert_eq!(schema.identifier(&reserved).unwrap(), [0x37; 32]); + assert!(schema.identifier(&container()[..64]).is_err()); + assert!(schema.identifier(&[0; 65]).is_err()); + assert!( + ByteEncoding::Sequence + .decode_identifier(&Compact(u32::MAX).encode()) + .is_err() + ); + let mut malformed = metadata(ByteEncoding::Fixed(65), "identifier_key", 32); + malformed.push(0); + assert!(ConsumerSchema::new(&malformed).is_err()); + assert!(decode_all::>(&Compact(u32::MAX).encode()).is_err()); +} + +#[test] +fn zero_low_order_and_noncanonical_x25519_aliases_never_authenticate() { + let mut basepoint = [0; 32]; + basepoint[0] = 9; + assert!(validate_public_key(&basepoint).is_ok()); + let mut masked_alias = basepoint; + masked_alias[31] = 0x80; + assert!(validate_public_key(&masked_alias).is_err()); + let mut p = [0xff; 32]; + p[0] = 0xed; + p[31] = 0x7f; + assert!(validate_public_key(&p).is_err()); + p[0] += 9; + assert!(validate_public_key(&p).is_err()); + let mut one = [0; 32]; + one[0] = 1; + assert!(validate_public_key(&one).is_err()); + assert!(validate_public_key(&[0; 32]).is_err()); +} + +#[test] +fn legacy_username_index_requires_its_declared_account_layout() { + let bytes = metadata(ByteEncoding::Fixed(65), "identifier_key", 32); + assert!(UsernameOwnerSchema::new(&bytes).unwrap().is_none()); + let mut decoded: RuntimeMetadataPrefixed = decode_all(&bytes).unwrap(); + let RuntimeMetadata::V14(runtime) = &mut decoded.1 else { + unreachable!() + }; + let label = runtime.types.types.iter().find(|ty| { + matches!(&ty.ty.type_def, TypeDef::Sequence(sequence) if is_u8(&runtime.types, sequence.type_param.id)) + }).unwrap().id; + let entry = &mut runtime.pallets[0].storage.as_mut().unwrap().entries[0]; + let StorageEntryType::Map { key, value, .. } = &mut entry.ty else { + unreachable!() + }; + *value = *key; + *key = label.into(); + entry.name = "UsernameOwnerOf".into(); + let schema = UsernameOwnerSchema::new(&decoded.encode()) + .unwrap() + .unwrap(); + assert_ne!( + schema.key("alice.01").unwrap(), + schema.key("alice.1").unwrap() + ); + let RuntimeMetadata::V14(runtime) = &mut decoded.1 else { + unreachable!() + }; + let entry = &mut runtime.pallets[0].storage.as_mut().unwrap().entries[0]; + let StorageEntryType::Map { value, .. } = &mut entry.ty else { + unreachable!() + }; + *value = label.into(); + assert!(UsernameOwnerSchema::new(&decoded.encode()).is_err()); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/payments.rs b/rust/crates/truapi-server/src/runtime/native_chat/payments.rs new file mode 100644 index 000000000..74b7ca427 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/payments.rs @@ -0,0 +1,1357 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Wallet-owned native Coinage payments. Products receive only public cards; +//! entropy, memos, approval bindings and recovery evidence stay in the Host. + +mod engine; +mod inventory; +#[cfg(test)] +mod tests; + +use super::NativeChatContext; +use crate::runtime::{ + chat_device, coinage_chain::HostCoinageChain, coinage_store::HostCoinageStore, +}; +use engine::Engine; +use futures::lock::Mutex; +use parity_scale_codec::{Decode, Encode}; +use std::sync::Arc; +use truapi::latest::{ + self, HostNativeChatPayment, HostNativeChatPaymentDirection as Direction, + HostNativeChatPaymentFailure as Failure, HostNativeChatPaymentState as State, + HostProductDeviceChatError as Error, +}; +use truapi_coinage::{ + ClaimPlanStatus, ClaimPlanStore, Clock, CoinageStorageQuery, ExternalMemoClaiming, MemoEntry, + SystemClock, TransferMemo, TransferPreviewChoice, WalStore, +}; +use truapi_platform::{MainPurseChatPaymentReview, UserConfirmationReview, async_trait}; +use zeroize::{Zeroize, Zeroizing}; + +const OPERATION_MAGIC: &[u8; 4] = b"HCP1"; + +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct PaymentIntent { + pub product_id: String, + pub peer_identity: [u8; 32], + pub recipient_username: Option, + pub request_id: String, + pub amount_cents: u64, +} + +#[async_trait] +pub(crate) trait PaymentTransport: Send + Sync { + /// Idempotently take durable custody of this exact operation's encrypted, + /// signed native message. An error certifies NO durable acceptance; uncertain + /// storage/submission MUST return success and retain the message for retry. + async fn accept(&self, payment: &HostNativeChatPayment, memo: TransferMemo) -> Result<(), ()>; +} + +#[derive(Clone, Copy, PartialEq, Eq, Encode, Decode)] +enum Phase { + Review, + Approved, + HandoffReady, + Accepted, + Denied, + Incoming, +} + +#[derive(Clone, Encode, Decode)] +struct Operation { + product_id: String, + recipient_username: Option, + genesis_hash: [u8; 32], + card: HostNativeChatPayment, + phase: Phase, + max_debit_cents: u64, + denominations: Option<(u128, i16, i16, u8)>, + source_public: Vec<[u8; 32]>, + source_exponents: Vec>, + source_seen: Vec, + source_cleared: Vec, + memo_key: Option<[u8; 32]>, + source_fingerprint: Option<[u8; 32]>, + detection_anchor: Option<[u8; 32]>, + delivered: bool, + // Kept last so partial SCALE decoding never abandons a decoded secret field. + memo: Vec, +} + +impl Drop for Operation { + fn drop(&mut self) { + self.memo.zeroize(); + } +} + +impl Operation { + fn matches(&self, intent: &PaymentIntent, genesis: [u8; 32]) -> bool { + self.product_id == intent.product_id + && self.card.request_id == intent.request_id + && self.card.peer_identity == intent.peer_identity + && self.card.amount_cents == intent.amount_cents + && self.genesis_hash == genesis + && self.card.direction == Direction::Outgoing + } + + fn review(&self, coinage_instance_id: Option) -> MainPurseChatPaymentReview { + MainPurseChatPaymentReview { + calling_product_id: self.product_id.clone(), + recipient_identity: self.card.peer_identity, + recipient_username: self.recipient_username.clone(), + amount_cents: self.card.amount_cents, + max_debit_cents: self.max_debit_cents, + genesis_hash: self.genesis_hash, + coinage_instance_id, + operation_id: self.card.operation_id, + } + } +} + +/// One live wallet/network owner shared across products. Session cache eviction +/// releases it once owned work finishes; no session-bound signer is retained. +pub(crate) struct WalletCoinage { + root_public_key: [u8; 32], + genesis_hash: [u8; 32], + coinage_instance_id: Option, + store: Arc, + gate: Mutex<()>, +} + +impl WalletCoinage { + pub(crate) async fn open( + context: &NativeChatContext, + ) -> Result, latest::HostProductDeviceChatError> { + live(context)?; + let key = storage_key(context); + let store = HostCoinageStore::open( + context.services.platform.clone(), + context.session.public_key, + context.genesis_hash, + key, + context.services.spawner.clone(), + ) + .await + .map_err(|_| Error::StorageUnavailable)?; + live(context)?; + store + .bind_asset_instance(context.coinage_instance_id) + .await + .map_err(|error| { + if error == crate::runtime::coinage_store::StoreError::Conflict { + Error::OperationConflict + } else { + Error::StorageUnavailable + } + })?; + // Opening storage is not evidence of an empty native purse. Every send + // must finish the native persisted recovery scan before selection. + Ok(Arc::new(Self { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + coinage_instance_id: context.coinage_instance_id, + store, + gate: Mutex::new(()), + })) + } + + pub(crate) async fn send( + self: &Arc, + context: &NativeChatContext, + intent: PaymentIntent, + transport: Arc, + ) -> Result { + self.check(context)?; + validate_intent(&intent)?; + let wallet = self.clone(); + let context = context.clone(); + let (tx, rx) = futures::channel::oneshot::channel(); + (context.services.spawner.clone())(Box::pin(async move { + let result = wallet.send_once(&context, intent, transport).await; + let _ = tx.send(result); + })); + rx.await.map_err(|_| Error::StorageUnavailable)? + } + + async fn send_once( + self: &Arc, + context: &NativeChatContext, + intent: PaymentIntent, + transport: Arc, + ) -> Result { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + let id = operation_id( + self.root_public_key, + self.genesis_hash, + &intent.product_id, + &intent.request_id, + ); + let mut operation = match self.load(id).await? { + Some(operation) => { + if !operation.matches(&intent, self.genesis_hash) { + return Err(Error::OperationConflict); + } + if operation.phase == Phase::Denied { + return Err(Error::UserRejected); + } + if matches!(operation.card.state, State::Cleared | State::Failed { .. }) { + return Ok(operation.card.clone()); + } + operation + } + None => { + let operation = new_outgoing(id, self.genesis_hash, intent); + self.save(&operation).await?; + operation + } + }; + let journal_id = hex::encode(id); + let journals = self + .store + .load_operation(&journal_id) + .await + .map_err(|_| Error::StorageUnavailable)?; + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferRejected) + { + operation.card.state = State::Failed { + reason: Failure::Cancelled, + }; + self.save(&operation).await?; + return Ok(operation.card.clone()); + } + // Completed preparation only needs ciphertext repair. An accepted memo + // can precede split/unload funding, so unfinished preparation must still + // reach the exact-plan resume path below after renewed review. + if operation.phase == Phase::Accepted + || journals.iter().any(|entry| { + matches!( + entry.operation, + truapi_coinage::WalOperation::TransferAccepted + | truapi_coinage::WalOperation::TransferCompleted + ) + }) + { + if operation.phase != Phase::Accepted { + self.stored_memo(&operation)?; + operation.phase = Phase::Accepted; + self.save(&operation).await?; + } + if !operation.delivered && operation.card.state != State::Delivered { + self.replay_handoff(context, &operation, transport.clone()) + .await?; + } + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferCompleted) + { + return Ok(operation.card.clone()); + } + } + let engine = Engine::new(context, self.store.clone()).await?; + engine.synchronize(context, &self.store).await?; + let journals = engine + .sender + .operation_wal(&journal_id) + .await + .map_err(|_| Error::StorageUnavailable)?; + let denomination_binding = binding(&engine); + let preview = if journals.is_empty() { + let amount = engine + .denominations + .cash_cents_to_planks(u128::from(operation.card.amount_cents)) + .ok_or(Error::InvalidRequest)?; + let preview = engine + .sender + .preview(amount) + .await + .map_err(|error| match error { + truapi_coinage::RegularTransferError::Selection(_) => { + Error::InsufficientBalance + } + _ => Error::NetworkUnavailable, + })?; + operation.max_debit_cents = engine.debit_cents(preview.max_debit_amount)?; + operation.denominations = Some(denomination_binding); + Some(preview) + } else { + if operation.denominations != Some(denomination_binding) { + return Err(Error::NetworkUnavailable); + } + None + }; + self.save(&operation).await?; + self.check(context)?; + let approved = review_operation( + context.services.platform.as_ref(), + context.session_valid.as_ref(), + &operation, + self.coinage_instance_id, + ) + .await?; + self.check(context)?; + if !approved { + // A resumed, already accepted operation cannot be cancelled or have + // reservations released: denial means no *new* effects this time. + if journals.is_empty() && operation.memo_key.is_none() { + operation.phase = Phase::Denied; + operation.card.state = State::Failed { + reason: Failure::Cancelled, + }; + self.save(&operation).await?; + } + return Err(Error::UserRejected); + } + if operation.phase == Phase::Review { + operation.phase = Phase::Approved; + } + operation.card.state = State::Preparing; + self.save(&operation).await?; + self.check(context)?; + let wallet = self.clone(); + let guarded_context = context.clone(); + let handoff = move |memo: TransferMemo| async move { + wallet.handoff(&guarded_context, id, memo, transport).await + }; + let result = if let Some(preview) = preview { + engine + .sender + .confirm_operation( + &journal_id, + &preview.preview_id, + TransferPreviewChoice::Full, + handoff, + ) + .await + } else { + engine.sender.resume_operation(&journal_id, handoff).await + }; + let mut operation = self.load(id).await?.ok_or(Error::StorageUnavailable)?; + if result.is_err() { + operation.card.state = State::Recovering; + } + self.observe_outgoing(&engine, &mut operation).await?; + self.save(&operation).await?; + Ok(operation.card.clone()) + } + + async fn handoff( + &self, + context: &NativeChatContext, + id: [u8; 32], + memo: TransferMemo, + transport: Arc, + ) -> Result<(), ()> { + self.check(context).map_err(|_| ())?; + let mut operation = self.load(id).await.map_err(|_| ())?.ok_or(())?; + let amount = operation + .denominations + .ok_or(())? + .0 + .checked_mul(u128::from(operation.card.amount_cents)) + .ok_or(())?; + if memo.total_value != amount { + return Err(()); + } + // Resume after transport accepted but before the journal was advanced. + if operation.phase == Phase::Accepted { + return Ok(()); + } + let public = memo_public(&memo).map_err(|_| ())?; + if operation + .memo_key + .is_some_and(|key| key != memo.identifier()) + { + return Err(()); + } + if operation.phase == Phase::HandoffReady { + // The previous transport write may have committed. Replaying its + // immutable id resolves that ambiguity; a now-absent source is not + // grounds for asserting that the peer never accepted the secret. + transport.accept(&operation.card, memo).await?; + operation.phase = Phase::Accepted; + let _ = self.save(&operation).await; + return Ok(()); + } + operation.memo.zeroize(); + operation.memo = memo.scale_encoded(); + operation.memo_key = Some(memo.identifier()); + operation.source_fingerprint = Some(source_fingerprint(&public)); + operation.source_public = public; + operation + .source_exponents + .resize(operation.source_public.len(), None); + operation + .source_seen + .resize(operation.source_public.len(), false); + operation + .source_cleared + .resize(operation.source_public.len(), false); + let chain = Arc::new(HostCoinageChain::new( + context.services.platform.clone(), + self.genesis_hash, + context.coinage_instance_id, + context.entropy.clone(), + context.session_valid.clone(), + context.services.spawner.clone(), + self.store.clone(), + )); + let at = chain.finalized_head().await.map_err(|_| ())?; + let query = truapi_coinage::CoinOnChainQueryService::new(chain); + let rows = query + .fetch_coins(&operation.source_public, Some(at)) + .await + .map_err(|_| ())?; + let journals = self + .store + .load_operation(&hex::encode(id)) + .await + .map_err(|_| ())?; + let parent = journals + .iter() + .find(|entry| entry.operation.is_transfer_receipt()) + .ok_or(())?; + let key_factory = truapi_coinage::CoinKeypairFactory::new(&context.entropy); + let references = parent + .payload + .output_coins + .iter() + .map(|coin| { + key_factory + .public_key(coin.derivation_index) + .map(|public| (public, coin)) + }) + .collect::, _>>() + .map_err(|_| ())?; + for (i, row) in rows.into_iter().enumerate() { + let reference = references + .iter() + .find(|(public, _)| *public == operation.source_public[i]) + .ok_or(())? + .1; + let pass_through = journals + .iter() + .filter(|entry| entry.operation == truapi_coinage::WalOperation::SecretHandoff) + .any(|entry| entry.payload.input_coins.contains(reference)); + operation.source_exponents[i] = Some(reference.exponent); + match row { + Some(row) if row.exponent == reference.exponent => operation.source_seen[i] = true, + None if !pass_through => (), + _ => return Err(()), + } + } + operation.detection_anchor = Some(at); + operation.phase = Phase::HandoffReady; + operation.card.state = State::Delivering; + // Our local secret record alone is not recipient acceptance. A failure + // here may safely reject; poisoned WAL still guards reservations. + self.save(&operation).await.map_err(|_| ())?; + self.check(context).map_err(|_| ())?; + transport.accept(&operation.card, memo).await?; + // Acceptance is irreversible even if our final write is ambiguous. + operation.phase = Phase::Accepted; + let _ = self.save(&operation).await; + Ok(()) + } + + pub(crate) async fn receive_batch( + self: &Arc, + context: &NativeChatContext, + product_id: &str, + peer_identity: [u8; 32], + request_id: &str, + memos: Vec, + ) -> Result, Error> { + self.check(context)?; + let wallet = self.clone(); + let context = context.clone(); + let product_id = product_id.to_owned(); + let request_id = request_id.to_owned(); + let (tx, rx) = futures::channel::oneshot::channel(); + (context.services.spawner.clone())(Box::pin(async move { + let result = wallet + .receive_batch_once(&context, product_id, peer_identity, request_id, memos) + .await; + let accepted = result.is_ok(); + let _ = tx.send(result); + // Every secret and complete claim plan is durable before ACK. + // Claiming survives a disconnected receive caller. + if accepted { + let _ = wallet.reconcile_once(&context).await; + } + })); + rx.await.map_err(|_| Error::StorageUnavailable)? + } + + async fn receive_batch_once( + &self, + context: &NativeChatContext, + product_id: String, + peer_identity: [u8; 32], + request_id: String, + incoming: Vec, + ) -> Result, Error> { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + if product_id.is_empty() + || product_id.len() > 1024 + || request_id.is_empty() + || request_id.len() > 1024 + { + return Err(Error::InvalidRequest); + } + struct Admission { + message_id: String, + timestamp: u64, + memo: TransferMemo, + public: Vec<[u8; 32]>, + fingerprint: [u8; 32], + existing: Option, + plan_ready: bool, + } + let existing = self.operations().await?; + let mut existing_sources = std::collections::BTreeMap::new(); + let mut existing_messages = std::collections::BTreeMap::new(); + for (index, operation) in existing + .iter() + .enumerate() + .filter(|(_, operation)| operation.card.direction == Direction::Incoming) + { + for key in &operation.source_public { + if existing_sources.insert(*key, index).is_some() { + return Err(Error::StorageUnavailable); + } + } + if operation.product_id == product_id + && operation.card.peer_identity == peer_identity + && operation.card.request_id == request_id + { + existing_messages.insert(operation.card.message_id.as_str(), index); + } + } + let mut admissions: Vec = Vec::with_capacity(incoming.len()); + let mut batch_sources = std::collections::BTreeMap::new(); + let mut batch_messages = std::collections::BTreeMap::new(); + for incoming in incoming { + if incoming.message_id.is_empty() + || incoming.message_id.len() > 1024 + || incoming.total_value == 0 + || incoming.coin_keys.is_empty() + || incoming.coin_keys.len() > 4096 + { + return Err(Error::InvalidRequest); + } + let entries = incoming + .coin_keys + .iter() + .map(|bytes| { + <[u8; 64]>::try_from(bytes.as_slice()) + .map(MemoEntry) + .map_err(|_| Error::InvalidRequest) + }) + .collect::, _>>()?; + let memo = TransferMemo { + entries, + total_value: incoming.total_value, + }; + let public = memo_public(&memo)?; + let fingerprint = source_fingerprint(&public); + let mut duplicate = batch_messages.get(&incoming.message_id).copied(); + for key in &public { + if let Some(&index) = batch_sources.get(key) { + if duplicate.is_some_and(|previous| previous != index) { + return Err(Error::OperationConflict); + } + duplicate = Some(index); + } + } + if let Some(index) = duplicate { + let previous: &Admission = &admissions[index]; + if previous.fingerprint != fingerprint + || previous.memo.total_value != memo.total_value + { + return Err(Error::OperationConflict); + } + batch_messages.insert(incoming.message_id, index); + continue; + } + let mut matched = existing_messages.get(incoming.message_id.as_str()).copied(); + for key in &public { + if let Some(&index) = existing_sources.get(key) { + if matched.is_some_and(|previous| previous != index) { + return Err(Error::OperationConflict); + } + matched = Some(index); + } + } + let plan_ready = if let Some(index) = matched { + let operation = &existing[index]; + let expected = operation + .denominations + .and_then(|d| d.0.checked_mul(u128::from(operation.card.amount_cents))); + if operation.source_fingerprint != Some(fingerprint) + || expected != Some(memo.total_value) + || operation.product_id != product_id + || operation.card.peer_identity != peer_identity + { + return Err(Error::OperationConflict); + } + let key = operation.memo_key.ok_or(Error::StorageUnavailable)?; + self.store + .plan(&key) + .await + .map_err(|_| Error::StorageUnavailable)? + .is_some() + } else { + false + }; + let index = admissions.len(); + for key in &public { + batch_sources.insert(*key, index); + } + batch_messages.insert(incoming.message_id.clone(), index); + admissions.push(Admission { + message_id: incoming.message_id, + timestamp: incoming.timestamp, + memo, + public, + fingerprint, + existing: matched, + plan_ready, + }); + } + // Read chain denomination metadata before any custody/claim effects. + // A fully durable replay needs neither network access nor a new plan. + let engine = if admissions.iter().any(|entry| !entry.plan_ready) { + let engine = Engine::new(context, self.store.clone()).await?; + for entry in &admissions { + if engine.cents(entry.memo.total_value)? == 0 { + return Err(Error::InvalidRequest); + } + if let Some(index) = entry.existing { + if existing[index].denominations != Some(binding(&engine)) { + return Err(Error::NetworkUnavailable); + } + } + } + engine.synchronize(context, &self.store).await?; + Some(engine) + } else { + None + }; + let mut cards = Vec::with_capacity(admissions.len()); + for entry in admissions { + self.check(context)?; + let operation = if let Some(index) = entry.existing { + std::borrow::Cow::Borrowed(&existing[index]) + } else { + let engine = engine.as_ref().ok_or(Error::StorageUnavailable)?; + let id = hash( + &( + b"truapi/main-purse/incoming/v1".as_slice(), + self.root_public_key, + self.genesis_hash, + entry.fingerprint, + ) + .encode(), + ); + let operation = Operation { + product_id: product_id.clone(), + recipient_username: None, + genesis_hash: self.genesis_hash, + card: HostNativeChatPayment { + operation_id: id, + request_id: request_id.clone(), + message_id: entry.message_id, + timestamp: entry.timestamp, + peer_identity, + direction: Direction::Incoming, + amount_cents: engine.cents(entry.memo.total_value)?, + state: State::Claiming, + }, + phase: Phase::Incoming, + max_debit_cents: 0, + denominations: Some(binding(engine)), + source_exponents: vec![None; entry.public.len()], + source_seen: vec![false; entry.public.len()], + source_cleared: vec![false; entry.public.len()], + source_public: entry.public, + memo_key: Some(entry.memo.identifier()), + source_fingerprint: Some(entry.fingerprint), + detection_anchor: None, + delivered: false, + memo: entry.memo.scale_encoded(), + }; + self.save(&operation).await?; + std::borrow::Cow::Owned(operation) + }; + if !entry.plan_ready { + // Reordered retries retain the original memo's canonical plan. + let canonical; + let memo = if entry.existing.is_some() { + canonical = TransferMemo::from_scale_encoded(&operation.memo) + .map_err(|_| Error::StorageUnavailable)?; + &canonical + } else { + &entry.memo + }; + engine + .as_ref() + .ok_or(Error::StorageUnavailable)? + .claimer + .prepare_memo( + memo, + truapi_coinage::external_claim_message_id(&memo.identifier()), + ) + .await + .map_err(|_| Error::NetworkUnavailable)?; + } + cards.push(operation.card.clone()); + } + self.check(context)?; + Ok(cards) + } + + pub(crate) async fn reconcile( + self: &Arc, + context: &NativeChatContext, + ) -> Result<(), Error> { + self.check(context)?; + let wallet = self.clone(); + let context = context.clone(); + let (tx, rx) = futures::channel::oneshot::channel(); + (context.services.spawner.clone())(Box::pin(async move { + let _ = tx.send(wallet.reconcile_once(&context).await); + })); + rx.await.map_err(|_| Error::StorageUnavailable)? + } + + async fn reconcile_once(&self, context: &NativeChatContext) -> Result<(), Error> { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + let operations = self.operations().await?; + if operations + .iter() + .all(|operation| matches!(operation.card.state, State::Cleared | State::Failed { .. })) + { + return Ok(()); + } + let engine = Engine::new(context, self.store.clone()).await?; + engine.synchronize(context, &self.store).await?; + for mut operation in operations { + self.check(context)?; + if matches!(operation.card.state, State::Cleared | State::Failed { .. }) { + continue; + } + if operation.phase == Phase::Incoming { + if operation.denominations != Some(binding(&engine)) { + return Err(Error::NetworkUnavailable); + } + let memo = TransferMemo::from_scale_encoded(&operation.memo) + .map_err(|_| Error::StorageUnavailable)?; + let key = memo.identifier(); + // Claim uses exclusively supplied source secrets, never a + // main-purse fee/input selection and never outgoing resume. + let outcome = engine + .claimer + .claim_external_memo(memo, truapi_coinage::external_claim_message_id(&key)) + .await; + let plan = self + .store + .plan(&key) + .await + .map_err(|_| Error::StorageUnavailable)?; + let cleared = plan.as_ref().and_then(|p| p.claimed_amount).unwrap_or(0); + operation.card.state = if plan + .as_ref() + .is_some_and(|p| p.status == ClaimPlanStatus::Finished) + && Some(cleared) + == operation + .denominations + .ok_or(Error::StorageUnavailable)? + .0 + .checked_mul(u128::from(operation.card.amount_cents)) + { + State::Cleared + } else if cleared > 0 { + State::PartiallyCleared { + cleared_cents: engine.partial_cents(cleared)?, + } + } else if outcome.is_err() { + State::Recovering + } else { + State::Claiming + }; + } else if matches!( + operation.phase, + Phase::HandoffReady | Phase::Accepted | Phase::Approved + ) { + // Observation only: a new session must obtain a new review + // through send() before any additional split/unload signature. + self.observe_outgoing(&engine, &mut operation).await?; + } + self.save(&operation).await?; + } + Ok(()) + } + + async fn observe_outgoing( + &self, + engine: &Engine, + operation: &mut Operation, + ) -> Result<(), Error> { + if operation.source_public.is_empty() { + return Ok(()); + } + if operation.denominations != Some(binding(engine)) { + return Err(Error::NetworkUnavailable); + } + let journals = self + .store + .load_operation(&hex::encode(operation.card.operation_id)) + .await + .map_err(|_| Error::StorageUnavailable)?; + if journals.iter().any(|entry| { + matches!( + entry.operation, + truapi_coinage::WalOperation::TransferAccepted + | truapi_coinage::WalOperation::TransferCompleted + ) + }) { + operation.phase = Phase::Accepted; + } + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferRejected) + { + operation.card.state = State::Failed { + reason: Failure::Cancelled, + }; + return Ok(()); + } + // Completed proves each prepared output was finalized (never merely + // that its inputs disappeared); pass-through coins were independently + // verified at our durable pre-handoff anchor. This closes the fast-peer + // race where a recipient claims before the next observation. + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferCompleted) + { + operation.source_seen.fill(true); + } + let at = engine + .chain + .finalized_head() + .await + .map_err(|_| Error::NetworkUnavailable)?; + let rows = engine + .query + .fetch_coins(&operation.source_public, Some(at)) + .await + .map_err(|_| Error::NetworkUnavailable)?; + let mut cleared = 0u128; + for (i, row) in rows.into_iter().enumerate() { + if let Some(row) = row { + if operation.source_exponents[i].is_some_and(|e| e != row.exponent) { + return Err(Error::NetworkUnavailable); + } + operation.source_exponents[i] = Some(row.exponent); + operation.source_seen[i] = true; + } else if operation.source_seen[i] { + operation.source_cleared[i] = true; + } + if operation.source_cleared[i] { + cleared = cleared + .checked_add(engine.denominations.value_in_planks( + operation.source_exponents[i].ok_or(Error::StorageUnavailable)?, + )) + .ok_or(Error::InvalidRequest)?; + } + } + operation.detection_anchor = Some(at); + let total = engine + .denominations + .cash_cents_to_planks(u128::from(operation.card.amount_cents)) + .ok_or(Error::InvalidRequest)?; + if cleared > total { + return Err(Error::NetworkUnavailable); + } + if cleared == total { + operation.card.state = State::Cleared; + } else if cleared > 0 { + operation.card.state = State::PartiallyCleared { + cleared_cents: engine.partial_cents(cleared)?, + }; + } else if operation.phase == Phase::Accepted { + operation.card.state = if operation.delivered { + State::Delivered + } else { + State::Delivering + }; + } else { + operation.card.state = State::Recovering; + } + Ok(()) + } + + pub(crate) async fn views( + &self, + product_id: &str, + ) -> Result, Error> { + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + Ok(self + .operations() + .await? + .into_iter() + .filter(|operation| { + operation.product_id == product_id && operation.phase != Phase::Review + }) + .map(|operation| operation.card.clone()) + .collect()) + } + /// Public cards only. The authenticated actor's irreversible custody ledger + /// can repair a lost wallet acceptance write, but a prepared memo alone cannot. + pub(crate) async fn pending_handoffs( + &self, + context: &NativeChatContext, + product_id: &str, + accepted_operations: &[[u8; 32]], + ) -> Result, Error> { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + let accepted: std::collections::BTreeSet<_> = accepted_operations.iter().collect(); + let mut cards = Vec::new(); + for mut operation in self.operations().await? { + self.check(context)?; + if operation.phase == Phase::HandoffReady + && operation.product_id == product_id + && operation.genesis_hash == self.genesis_hash + && operation.card.direction == Direction::Outgoing + && accepted.contains(&operation.card.operation_id) + { + let journals = self + .store + .load_operation(&hex::encode(operation.card.operation_id)) + .await + .map_err(|_| Error::StorageUnavailable)?; + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferRejected) + { + return Err(Error::OperationConflict); + } + let parent_id = truapi_coinage::wal::operation_entry_id( + &hex::encode(operation.card.operation_id), + "parent", + ); + let parent = journals + .iter() + .find(|entry| entry.entry_id == parent_id) + .ok_or(Error::StorageUnavailable)?; + if !matches!( + parent.operation, + truapi_coinage::WalOperation::TransferPrepared + | truapi_coinage::WalOperation::TransferAccepted + | truapi_coinage::WalOperation::TransferCompleted + ) { + return Err(Error::StorageUnavailable); + } + self.stored_memo(&operation)?; + if parent.operation == truapi_coinage::WalOperation::TransferPrepared { + let mut receipt = parent.clone(); + receipt.operation = truapi_coinage::WalOperation::TransferAccepted; + self.check(context)?; + WalStore::save(self.store.as_ref(), &receipt) + .await + .map_err(|_| Error::StorageUnavailable)?; + } + operation.phase = Phase::Accepted; + self.check(context)?; + self.save(&operation).await?; + // Recording proven custody permits read-only recovery to retire + // consumed inputs. It does not execute any unfinished funding; + // signatures still require the reviewed exact-plan resume path. + } + if self.pending_memo(&operation, product_id).await?.is_some() { + cards.push(operation.card.clone()); + } + } + self.check(context)?; + Ok(cards) + } + + /// Re-encrypt an already accepted memo for the current authenticated roster. + /// No wallet state changes: failure or cancellation cannot revoke the + /// original acceptance, release reservations, or claim peer delivery. + pub(crate) async fn redeliver( + &self, + context: &NativeChatContext, + product_id: &str, + operation_id: [u8; 32], + transport: Arc, + ) -> Result<(), Error> { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + let operation = self + .load(operation_id) + .await? + .ok_or(Error::OperationNotFound)?; + if operation.product_id != product_id + || operation.genesis_hash != self.genesis_hash + || operation.card.direction != Direction::Outgoing + { + return Err(Error::OperationNotFound); + } + self.replay_handoff(context, &operation, transport).await + } + + // The caller holds the wallet gate and has reauthenticated storage. + async fn replay_handoff( + &self, + context: &NativeChatContext, + operation: &Operation, + transport: Arc, + ) -> Result<(), Error> { + self.check(context)?; + let memo = self + .pending_memo(operation, &operation.product_id) + .await? + .ok_or(Error::OperationConflict)?; + self.check(context)?; + let result = transport.accept(&operation.card, memo).await; + self.check(context)?; + result.map_err(|_| Error::NetworkUnavailable) + } + + async fn pending_memo( + &self, + operation: &Operation, + product_id: &str, + ) -> Result, Error> { + if operation.product_id != product_id + || operation.genesis_hash != self.genesis_hash + || operation.card.direction != Direction::Outgoing + || operation.phase != Phase::Accepted + || operation.delivered + || matches!( + operation.card.state, + State::Delivered | State::Cleared | State::Failed { .. } + ) + { + return Ok(None); + } + let journals = self + .store + .load_operation(&hex::encode(operation.card.operation_id)) + .await + .map_err(|_| Error::StorageUnavailable)?; + if journals + .iter() + .any(|entry| entry.operation == truapi_coinage::WalOperation::TransferRejected) + { + return Ok(None); + } + self.stored_memo(operation).map(Some) + } + + fn stored_memo(&self, operation: &Operation) -> Result { + let card = &operation.card; + if operation.genesis_hash != self.genesis_hash + || card.direction != Direction::Outgoing + || card.amount_cents == 0 + || operation.product_id.is_empty() + || operation.product_id.len() > 1024 + || card.request_id.is_empty() + || card.request_id.len() > 1024 + || card.operation_id + != operation_id( + self.root_public_key, + self.genesis_hash, + &operation.product_id, + &card.request_id, + ) + || card.message_id != format!("payment-{}", hex::encode(card.operation_id)) + { + return Err(Error::StorageUnavailable); + } + let amount = operation + .denominations + .filter(|binding| binding.0 != 0) + .and_then(|binding| binding.0.checked_mul(u128::from(card.amount_cents))) + .ok_or(Error::StorageUnavailable)?; + let memo = TransferMemo::from_scale_encoded(&operation.memo) + .map_err(|_| Error::StorageUnavailable)?; + if memo.entries.is_empty() + || memo.entries.len() > 4096 + || memo.total_value != amount + || operation.memo_key != Some(memo.identifier()) + { + return Err(Error::StorageUnavailable); + } + let public = memo_public(&memo).map_err(|_| Error::StorageUnavailable)?; + if public != operation.source_public + || operation.source_fingerprint != Some(source_fingerprint(&public)) + { + return Err(Error::StorageUnavailable); + } + Ok(memo) + } + + /// Called only after the parent has durably authenticated the peer ACK. + /// Delivery is independent from finalized monetary clearing. + pub(crate) async fn note_delivery( + &self, + context: &NativeChatContext, + product_id: &str, + operation_id: [u8; 32], + ) -> Result<(), Error> { + let _gate = self.gate.lock().await; + self.check(context)?; + self.store + .reauthenticate() + .await + .map_err(|_| Error::StorageUnavailable)?; + let mut operation = self + .load(operation_id) + .await? + .ok_or(Error::OperationNotFound)?; + if operation.product_id != product_id || operation.card.direction != Direction::Outgoing { + return Err(Error::OperationNotFound); + } + if !matches!(operation.phase, Phase::Accepted | Phase::HandoffReady) { + return Err(Error::OperationConflict); + } + operation.phase = Phase::Accepted; + operation.delivered = true; + if matches!( + operation.card.state, + State::Preparing | State::Delivering | State::Recovering + ) { + operation.card.state = State::Delivered; + } + self.check(context)?; + self.save(&operation).await + } + + fn check(&self, context: &NativeChatContext) -> Result<(), Error> { + live(context)?; + if context.coinage_instance_id != self.coinage_instance_id { + return Err(Error::OperationConflict); + } + if context.session.public_key != self.root_public_key + || context.genesis_hash != self.genesis_hash + { + return Err(Error::NotConnected); + } + Ok(()) + } + + async fn save(&self, operation: &Operation) -> Result<(), Error> { + let mut bytes = OPERATION_MAGIC.to_vec(); + operation.encode_to(&mut bytes); + self.store + .write_operation(operation.card.operation_id, bytes) + .await + .map_err(|_| Error::StorageUnavailable) + } + + async fn load(&self, id: [u8; 32]) -> Result, Error> { + self.store + .read_operation(id) + .await + .map_err(|_| Error::StorageUnavailable)? + .map(|bytes| { + let operation = decode_operation(&Zeroizing::new(bytes))?; + if operation.card.operation_id != id { + return Err(Error::StorageUnavailable); + } + Ok(operation) + }) + .transpose() + } + + async fn operations(&self) -> Result, Error> { + self.store + .list_operations() + .await + .map_err(|_| Error::StorageUnavailable)? + .into_iter() + .filter(|(id, _)| *id != inventory::progress_id()) + .map(|(id, bytes)| { + let operation = decode_operation(&bytes)?; + if operation.card.operation_id != id { + return Err(Error::StorageUnavailable); + } + Ok(operation) + }) + .collect() + } +} + +fn decode_operation(bytes: &[u8]) -> Result { + let mut input = bytes + .strip_prefix(OPERATION_MAGIC) + .ok_or(Error::StorageUnavailable)?; + let operation = Operation::decode(&mut input).map_err(|_| Error::StorageUnavailable)?; + if !input.is_empty() + || operation.source_public.len() != operation.source_seen.len() + || operation.source_public.len() != operation.source_cleared.len() + || operation.source_public.len() != operation.source_exponents.len() + { + return Err(Error::StorageUnavailable); + } + Ok(operation) +} + +fn new_outgoing(id: [u8; 32], genesis: [u8; 32], intent: PaymentIntent) -> Operation { + Operation { + product_id: intent.product_id, + recipient_username: intent.recipient_username, + genesis_hash: genesis, + card: HostNativeChatPayment { + operation_id: id, + request_id: intent.request_id, + message_id: format!("payment-{}", hex::encode(id)), + timestamp: SystemClock.now_ms().max(0) as u64, + peer_identity: intent.peer_identity, + direction: Direction::Outgoing, + amount_cents: intent.amount_cents, + state: State::Preparing, + }, + phase: Phase::Review, + max_debit_cents: 0, + denominations: None, + source_public: Vec::new(), + source_exponents: Vec::new(), + source_seen: Vec::new(), + source_cleared: Vec::new(), + memo_key: None, + source_fingerprint: None, + detection_anchor: None, + delivered: false, + memo: Vec::new(), + } +} + +fn validate_intent(intent: &PaymentIntent) -> Result<(), Error> { + if intent.amount_cents == 0 + || intent.product_id.is_empty() + || intent.product_id.len() > 1024 + || intent.request_id.is_empty() + || intent.request_id.len() > 1024 + || intent + .recipient_username + .as_ref() + .is_some_and(|name| name.len() > 1024) + { + return Err(Error::InvalidRequest); + } + Ok(()) +} + +fn operation_id(root: [u8; 32], genesis: [u8; 32], product: &str, request: &str) -> [u8; 32] { + hash( + &( + b"truapi/main-purse/outgoing/v1".as_slice(), + root, + genesis, + product, + request, + ) + .encode(), + ) +} + +fn hash(bytes: &[u8]) -> [u8; 32] { + sp_crypto_hashing::blake2_256(bytes) +} + +fn storage_key(context: &NativeChatContext) -> Zeroizing<[u8; 32]> { + let mut material = Zeroizing::new(b"truapi/main-purse/encryption/v1".to_vec()); + context.entropy.encode_to(&mut *material); + context.session.public_key.encode_to(&mut *material); + context.genesis_hash.encode_to(&mut *material); + Zeroizing::new(hash(&material)) +} + +fn binding(engine: &Engine) -> (u128, i16, i16, u8) { + let d = &engine.denominations; + (d.asset_unit, d.min_exponent, d.max_exponent, d.precision) +} + +fn memo_public(memo: &TransferMemo) -> Result, Error> { + let public = memo + .entries + .iter() + .map(|entry| { + schnorrkel::SecretKey::from_bytes(&entry.0) + .map(|secret| secret.to_public().to_bytes()) + .map_err(|_| Error::InvalidRequest) + }) + .collect::, _>>()?; + let mut sorted = public.clone(); + sorted.sort_unstable(); + if sorted.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(Error::InvalidRequest); + } + Ok(public) +} + +fn source_fingerprint(public: &[[u8; 32]]) -> [u8; 32] { + let mut sorted = public.to_vec(); + sorted.sort_unstable(); + hash(&(b"truapi/main-purse/memo-sources/v1".as_slice(), sorted).encode()) +} + +fn live(context: &NativeChatContext) -> Result<(), Error> { + if (context.session_valid)() { + Ok(()) + } else { + Err(Error::NotConnected) + } +} + +async fn review_operation( + platform: &dyn truapi_platform::UserConfirmation, + session_valid: &(dyn Fn() -> bool + Send + Sync), + operation: &Operation, + coinage_instance_id: Option, +) -> Result { + if !session_valid() { + return Err(Error::NotConnected); + } + let approved = platform + .confirm_user_action(UserConfirmationReview::MainPurseChatPayment( + operation.review(coinage_instance_id), + )) + .await + .map_err(|_| Error::AccessNotGranted)?; + if !session_valid() { + return Err(Error::NotConnected); + } + Ok(approved) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/payments/engine.rs b/rust/crates/truapi-server/src/runtime/native_chat/payments/engine.rs new file mode 100644 index 000000000..729618293 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/payments/engine.rs @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Session-bound effects over the wallet-owned repositories. + +use super::{HostCoinageChain, HostCoinageStore, NativeChatContext, live}; +use parity_scale_codec::DecodeAll; +use std::sync::Arc; +use truapi::latest::HostProductDeviceChatError as Error; +use truapi_coinage::members::RingPosition; +use truapi_coinage::*; + +pub(super) struct Engine { + pub chain: Arc, + pub denominations: DenominationBreakdownContext, + pub sender: Arc, + pub claimer: ExternalSecretClaimService, + pub recovery: TransferRecoveryService, + pub query: CoinOnChainQueryService, +} + +impl Engine { + pub async fn new( + context: &NativeChatContext, + store: Arc, + ) -> Result { + live(context)?; + let chain = Arc::new(HostCoinageChain::new( + context.services.platform.clone(), + context.genesis_hash, + context.coinage_instance_id, + context.entropy.clone(), + context.session_valid.clone(), + context.services.spawner.clone(), + store.clone(), + )); + let denominations = chain + .denomination_context() + .await + .map_err(|_| Error::NetworkUnavailable)?; + let max_consolidation = chain + .max_consolidation() + .await + .map_err(|_| Error::NetworkUnavailable)?; + let allocator = Arc::new(CoinAllocator::new(store.clone())); + let sender = Arc::new(RegularCoinTransferService::new( + &context.entropy, + RegularCoinTransferParts { + spawner: context.services.spawner.clone(), + coins: store.clone(), + vouchers: store.clone(), + wal: store.clone(), + allocator: allocator.clone(), + submitter: chain.clone(), + denominations: denominations.clone(), + max_consolidation, + clock: Arc::new(SystemClock), + session_generation: 0, + committer: Some(store.clone()), + }, + )); + let claimer = ExternalSecretClaimService::new( + &context.entropy, + allocator, + store.clone(), + store.clone(), + chain.clone(), + ); + let recovery = + TransferRecoveryService::new(store.clone(), store.clone(), store, chain.clone()); + let query = CoinOnChainQueryService::new(chain.clone()); + Ok(Self { + chain, + denominations, + sender, + claimer, + recovery, + query, + }) + } + + /// This runs only under the wallet gate, including the complete recovery + /// probe pass. A stale session's engine is never retained for a new login. + pub async fn synchronize( + &self, + context: &NativeChatContext, + store: &Arc, + ) -> Result<(), Error> { + live(context)?; + super::inventory::refresh_or_resume( + store.clone(), + self.chain.clone(), + context.entropy.clone(), + self.chain.voucher_crypto(), + SystemClock.now_ms(), + ) + .await + .map_err(|_| Error::NetworkUnavailable)?; + self.recovery + .recover() + .await + .map_err(|_| Error::NetworkUnavailable)?; + let at = self + .chain + .finalized_head() + .await + .map_err(|_| Error::NetworkUnavailable)?; + let key_factory = CoinKeypairFactory::new(&context.entropy); + let coins = CoinRepository::list(store.as_ref()) + .await + .map_err(|_| Error::StorageUnavailable)?; + for chunk in coins.chunks(500) { + live(context)?; + let publics = chunk + .iter() + .map(|coin| key_factory.public_key(coin.derivation_index)) + .collect::, _>>() + .map_err(|_| Error::NotConnected)?; + let rows = self + .query + .fetch_coins(&publics, Some(at)) + .await + .map_err(|_| Error::NetworkUnavailable)?; + for (coin, row) in chunk.iter().zip(rows) { + // Never reclaim an accepted but not-yet-claimed outgoing secret. + if coin.state != CoinState::Available { + continue; + } + let mut updated = coin.clone(); + match row { + Some(row) if row.exponent == coin.exponent => updated.age = Some(row.age), + Some(_) => return Err(Error::NetworkUnavailable), + None => updated.state = CoinState::Spent, + } + if updated != *coin { + CoinRepository::upsert(store.as_ref(), &updated) + .await + .map_err(|_| Error::StorageUnavailable)?; + } + } + } + let public_chain = self.chain.clone(); + let alias_chain = self.chain.clone(); + let vouchers_query = VoucherOnChainQueryService::new( + self.chain.clone(), + Arc::new(move |index| public_chain.voucher_public_key(index)), + Arc::new(move |index| alias_chain.voucher_alias(index)), + ); + let vouchers = VoucherRepository::list(store.as_ref()) + .await + .map_err(|_| Error::StorageUnavailable)?; + for chunk in vouchers.chunks(500) { + let ids: Vec<_> = chunk + .iter() + .map(|voucher| voucher.derivation_index) + .collect(); + let rows = vouchers_query + .fetch_vouchers(&ids, Some(at)) + .await + .map_err(|_| Error::NetworkUnavailable)?; + let rings: Vec<_> = rows + .iter() + .filter_map(|row| match row { + Some(VoucherOnChainInfo { + exponent, + ring_position: RingPosition::Included { ring_index, .. }, + is_unloaded: false, + }) => Some((*exponent, *ring_index)), + _ => None, + }) + .collect::>() + .into_iter() + .collect(); + let keys: Vec<_> = rings + .iter() + .map( + |(exponent, index)| truapi_coinage::CoinageStorageKey::RingKeysStatus { + exponent: *exponent, + ring_index: *index, + }, + ) + .collect(); + let values = if keys.is_empty() { + Vec::new() + } else { + self.chain + .query(&keys, Some(at)) + .await + .map_err(|_| Error::NetworkUnavailable)? + }; + if values.len() != rings.len() { + return Err(Error::NetworkUnavailable); + } + let statuses = rings + .into_iter() + .zip(values) + .map(|(ring, bytes)| { + let status = bytes + .map(|bytes| { + members::RingStatus::decode_all(&mut &bytes[..]) + .map_err(|_| Error::NetworkUnavailable) + }) + .transpose()?; + Ok((ring, status)) + }) + .collect::, Error>>()?; + for (voucher, row) in chunk.iter().zip(rows) { + if voucher.local_state != VoucherLocalState::Available { + continue; + } + let mut updated = voucher.clone(); + updated.remote_state = match row { + Some(row) if row.exponent != voucher.exponent => { + return Err(Error::NetworkUnavailable); + } + Some(row) if row.is_unloaded => VoucherRemoteState::Unloaded, + Some(VoucherOnChainInfo { + exponent, + ring_position: + RingPosition::Included { + ring_index, + ring_position: included_at, + .. + }, + .. + }) => { + match statuses + .get(&(exponent, ring_index)) + .and_then(|status| *status) + { + Some(status) if status.included > included_at => { + updated.privacy = if ring_readiness_upgraded(status.included) { + VoucherPrivacyLevel::Full + } else { + VoucherPrivacyLevel::Degraded + }; + VoucherRemoteState::InRecycler { + recycler_index: ring_index, + } + } + _ => VoucherRemoteState::Onboarding, + } + } + Some(_) => VoucherRemoteState::Onboarding, + None => VoucherRemoteState::Unlocated, + }; + if updated != *voucher { + VoucherRepository::upsert(store.as_ref(), &updated) + .await + .map_err(|_| Error::StorageUnavailable)?; + } + } + } + live(context) + } + + pub fn cents(&self, amount: u128) -> Result { + self.denominations + .cash_cents_from_planks(amount) + .and_then(|n| u64::try_from(n).ok()) + .ok_or(Error::InvalidRequest) + } + + pub fn partial_cents(&self, amount: u128) -> Result { + let unit = self.denominations.asset_unit; + if unit == 0 { + return Err(Error::NetworkUnavailable); + } + // Public cents cannot represent a fractional-cent cleared prefix. + // Report only the whole cents proven; never round settlement upward. + u64::try_from(amount / unit).map_err(|_| Error::InvalidRequest) + } + + pub fn debit_cents(&self, amount: u128) -> Result { + let unit = self.denominations.asset_unit; + if unit == 0 { + return Err(Error::NetworkUnavailable); + } + u64::try_from(amount.div_ceil(unit)).map_err(|_| Error::InvalidRequest) + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/payments/inventory.rs b/rust/crates/truapi-server/src/runtime/native_chat/payments/inventory.rs new file mode 100644 index 000000000..013efbcac --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/payments/inventory.rs @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer core/crates/brevity-coinage/src/backup.rs. +// Copyright the Brevity contributors. See truapi-coinage/NOTICE and LICENSE. + +//! Native backup recovery with durable, head-pinned batch checkpoints. +//! +//! Preserve Brevity's 500-index batches, four consecutive empty batches, +//! inclusive saved-counter sweep, stable refresh horizon, and highest +//! *found* index counters (never the scan horizon). Host additions make each +//! batch's imported records, counters, and progress one atomic transaction. +//! An absent database is unscanned, not proof of an empty purse. + +use std::sync::Arc; + +use parity_scale_codec::{Decode, DecodeAll, Encode}; +use truapi_coinage::{ + Coin, CoinKeypairFactory, CoinOnChainQueryService, CoinState, CoinageIndexStore, + CoinageStorageQuery, IndexKind, RECYCLER_ALIAS_CONTEXT, Voucher, VoucherCryptography, + VoucherKeypairFactory, VoucherLocalState, VoucherOnChainQueryService, VoucherPrivacyLevel, + VoucherRemoteState, members::RingPosition, +}; +use zeroize::Zeroizing; + +use crate::runtime::{coinage_chain::HostCoinageChain, coinage_store::HostCoinageStore}; + +const SCAN_BATCH_SIZE: u32 = 500; +const SCAN_GAP_LIMIT: u32 = 4; +const PROGRESS_VERSION: u8 = 1; +const INDEX_SPACE_END: u64 = u32::MAX as u64 + 1; + +/// Exclude this Host-private checkpoint from payment-operation enumeration. +pub(super) fn progress_id() -> [u8; 32] { + sp_crypto_hashing::blake2_256(b"truapi/main-purse/coinage/inventory/v1") +} + +/// Both repositories were fully scanned at this finalized snapshot. The caller +/// must still reconcile existing reservations and refresh live state before use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct InventoryScanOutcome { + pub coin_horizon: u32, + pub voucher_horizon: u32, + pub finalized_head: [u8; 32], +} + +#[derive(Clone, Encode, Decode)] +struct ScanProgress { + // Capture the original surviving counter before any recovered row raises it. + // None means gap scan; it must not turn into a bounded scan on restart. + through: Option, + // Automatic discovery revisits the old frontier before applying the gap + // stop. A quiet refresh does not march that frontier forward indefinitely. + minimum_horizon: Option, + // u64 represents exhaustion after scanning u32::MAX without wrapping/reuse. + next_index: u64, + empty_batches: u32, + complete: bool, +} + +impl ScanProgress { + fn initial(through: Option) -> Self { + Self { + through, + minimum_horizon: None, + next_index: 0, + empty_batches: 0, + complete: false, + } + } + + fn refresh(minimum_horizon: Option) -> Self { + Self { + through: None, + minimum_horizon, + next_index: 0, + empty_batches: 0, + complete: false, + } + } + + fn validate(&self) -> Result<(), String> { + if self.next_index > INDEX_SPACE_END || self.empty_batches > SCAN_GAP_LIMIT { + return Err("invalid Coinage inventory scan progress".into()); + } + let done = match self.through { + Some(through) => { + let end = u64::from(through) + 1; + if self.next_index > end + || self.empty_batches != 0 + || self.minimum_horizon.is_some() + { + return Err("invalid bounded Coinage inventory scan progress".into()); + } + self.next_index == end + } + None => { + (self.empty_batches == SCAN_GAP_LIMIT + && self + .minimum_horizon + .is_none_or(|minimum| self.next_index > u64::from(minimum))) + || self.next_index == INDEX_SPACE_END + } + }; + if self.complete != done || (self.complete && self.next_index == 0) { + return Err("inconsistent Coinage inventory scan completion".into()); + } + Ok(()) + } + + fn range(&self) -> Result<(u32, u32), String> { + let start = u32::try_from(self.next_index) + .map_err(|_| "Coinage inventory derivation space exhausted")?; + let end = start.saturating_add(SCAN_BATCH_SIZE - 1); + Ok((start, self.through.map_or(end, |through| end.min(through)))) + } + + fn advance(&mut self, end: u32, found: bool) { + self.next_index = u64::from(end) + 1; + self.complete = match self.through { + Some(through) => end == through, + None => { + self.empty_batches = if found { + 0 + } else { + (self.empty_batches + 1).min(SCAN_GAP_LIMIT) + }; + (self.empty_batches == SCAN_GAP_LIMIT + && self.minimum_horizon.is_none_or(|minimum| end >= minimum)) + || end == u32::MAX + } + }; + } + + fn horizon(&self) -> Result { + if !self.complete { + return Err("Coinage inventory scan is incomplete".into()); + } + self.next_index + .checked_sub(1) + .and_then(|index| u32::try_from(index).ok()) + .ok_or_else(|| "invalid Coinage inventory scan horizon".into()) + } +} + +#[derive(Clone, Encode, Decode)] +struct Progress { + version: u8, + finalized_head: [u8; 32], + recovered_at_ms: i64, + coins: ScanProgress, + vouchers: ScanProgress, +} + +impl Progress { + fn restore(bytes: &[u8]) -> Result { + let progress = Self::decode_all(&mut &bytes[..]) + .map_err(|_| "invalid Coinage inventory checkpoint")?; + if progress.version != PROGRESS_VERSION { + return Err("unsupported Coinage inventory checkpoint version".into()); + } + progress.coins.validate()?; + progress.vouchers.validate()?; + Ok(progress) + } + + fn complete(&self) -> bool { + self.coins.complete && self.vouchers.complete + } + + fn outcome(&self) -> Result { + Ok(InventoryScanOutcome { + coin_horizon: self.coins.horizon()?, + voucher_horizon: self.vouchers.horizon()?, + finalized_head: self.finalized_head, + }) + } +} + +struct QueryScan { + coin_keys: CoinKeypairFactory, + coins: CoinOnChainQueryService, + vouchers: VoucherOnChainQueryService, +} + +impl QueryScan { + fn new( + chain: Arc, + entropy: Zeroizing>, + crypto: Arc, + ) -> Self { + let coin_keys = CoinKeypairFactory::new(&entropy); + let voucher_keys = Arc::new(VoucherKeypairFactory::new(&entropy)); + // Factories own zeroize-on-drop key material; no entropy crosses RPC. + drop(entropy); + let public_key_provider = { + let keys = voucher_keys.clone(); + let crypto = crypto.clone(); + Arc::new(move |index| keys.public_key(index, crypto.as_ref())) + }; + let alias_provider = Arc::new(move |index| { + voucher_keys.alias(index, RECYCLER_ALIAS_CONTEXT, crypto.as_ref()) + }); + Self { + coin_keys, + coins: CoinOnChainQueryService::new(chain.clone()), + vouchers: VoucherOnChainQueryService::new(chain, public_key_provider, alias_provider), + } + } + + async fn coins(&self, start: u32, end: u32, at: [u8; 32]) -> Result, String> { + let public_keys = (start..=end) + .map(|index| self.coin_keys.public_key(index)) + .collect::, _>>()?; + let rows = self + .coins + .fetch_coins(&public_keys, Some(at)) + .await + .map_err(|error| error.to_string())?; + Ok((start..=end) + .zip(rows) + .filter_map(|(derivation_index, row)| { + row.map(|coin| Coin { + exponent: coin.exponent, + derivation_index, + age: Some(coin.age), + state: CoinState::Available, + }) + }) + .collect()) + } + + async fn vouchers( + &self, + start: u32, + end: u32, + at: [u8; 32], + now_ms: i64, + ) -> Result<(Vec, Option), String> { + let indices = (start..=end).collect::>(); + let rows = self + .vouchers + .fetch_recovery_vouchers(&indices, at) + .await + .map_err(|error| error.to_string())?; + let mut vouchers = Vec::new(); + let mut highest = None; + for (derivation_index, row) in indices.into_iter().zip(rows) { + let Some(voucher) = row else { continue }; + // Even an unloaded voucher is a hit: it resets the gap and burns + // its derivation index, but is never imported as spendable money. + highest = Some(derivation_index); + if voucher.is_unloaded { + continue; + } + let remote_state = match voucher.ring_position { + RingPosition::Included { ring_index, .. } => VoucherRemoteState::InRecycler { + recycler_index: ring_index, + }, + RingPosition::Onboarding { .. } | RingPosition::Suspended => { + VoucherRemoteState::Onboarding + } + }; + vouchers.push(Voucher { + exponent: voucher.exponent, + derivation_index, + allocated_at_ms: now_ms, + ready_at_ms: 0, + remote_state, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Degraded, + }); + } + Ok((vouchers, highest)) + } +} + +/// Revisit every previously scanned or locally allocated index at a new +/// finalized head, then apply the native four-empty-batch stop. This catches +/// another device using an index inside an earlier empty gap. Only new hits +/// near the frontier extend it; passive refreshes do not add 2,000 indices. +/// Calls at an already completed finalized head reuse the existing outcome. + +pub(super) async fn refresh_or_resume( + store: Arc, + chain: Arc, + entropy: Zeroizing>, + voucher_crypto: Arc, + now_ms: i64, +) -> Result { + let id = progress_id(); + let mut expected = store + .read_operation(id) + .await + .map_err(|error| error.to_string())?; + let mut progress = match expected.as_deref() { + Some(bytes) => { + let previous = Progress::restore(bytes)?; + if !previous.complete() { + previous + } else { + let finalized_head = chain.finalized_head().await?; + if finalized_head == previous.finalized_head { + return previous.outcome(); + } + let (coin_index, voucher_index) = futures::try_join!( + store.current_index(IndexKind::Coin), + store.current_index(IndexKind::Voucher), + )?; + Progress { + version: PROGRESS_VERSION, + finalized_head, + recovered_at_ms: now_ms, + coins: ScanProgress::refresh(Some( + previous.coins.horizon()?.max(coin_index.unwrap_or(0)), + )), + vouchers: ScanProgress::refresh(Some( + previous.vouchers.horizon()?.max(voucher_index.unwrap_or(0)), + )), + } + } + } + None => { + let (coins, vouchers, finalized_head) = futures::try_join!( + store.current_index(IndexKind::Coin), + store.current_index(IndexKind::Voucher), + chain.finalized_head(), + )?; + Progress { + version: PROGRESS_VERSION, + finalized_head, + recovered_at_ms: now_ms, + coins: ScanProgress::initial(coins), + vouchers: ScanProgress::initial(vouchers), + } + } + }; + // Persist the initial plan before any batch can advance allocation counters. + let encoded = progress.encode(); + if expected.as_ref() != Some(&encoded) { + store + .commit_inventory_batch( + id, + expected, + encoded.clone(), + Vec::new(), + Vec::new(), + None, + None, + ) + .await + .map_err(|error| error.to_string())?; + expected = Some(encoded); + } + let query = QueryScan::new(chain, entropy, voucher_crypto); + while !progress.complete() { + if !progress.coins.complete { + let (start, end) = progress.coins.range()?; + let coins = query.coins(start, end, progress.finalized_head).await?; + let highest = coins.last().map(|coin| coin.derivation_index); + progress.coins.advance(end, highest.is_some()); + let encoded = progress.encode(); + store + .commit_inventory_batch( + id, + expected, + encoded.clone(), + coins, + Vec::new(), + highest, + None, + ) + .await + .map_err(|error| error.to_string())?; + expected = Some(encoded); + } + if !progress.vouchers.complete { + let (start, end) = progress.vouchers.range()?; + let (vouchers, highest) = query + .vouchers( + start, + end, + progress.finalized_head, + progress.recovered_at_ms, + ) + .await?; + progress.vouchers.advance(end, highest.is_some()); + let encoded = progress.encode(); + store + .commit_inventory_batch( + id, + expected, + encoded.clone(), + Vec::new(), + vouchers, + None, + highest, + ) + .await + .map_err(|error| error.to_string())?; + expected = Some(encoded); + } + } + progress.outcome() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_gap_resets_on_used_evidence_and_survives_restart() { + let mut progress = ScanProgress::initial(None); + assert_eq!(progress.range().unwrap(), (0, 499)); + progress.advance(499, false); + progress.advance(999, true); + let mut restarted = ScanProgress::decode_all(&mut &progress.encode()[..]).unwrap(); + restarted.validate().unwrap(); + for end in [1499, 1999, 2499] { + restarted.advance(end, false); + assert!(!restarted.complete); + } + restarted.advance(2999, false); + assert_eq!(restarted.horizon().unwrap(), 2999); + } + + #[test] + fn bounded_scan_is_inclusive_and_index_exhaustion_never_wraps() { + let mut bounded = ScanProgress::initial(Some(500)); + assert_eq!(bounded.range().unwrap(), (0, 499)); + bounded.advance(499, false); + assert_eq!(bounded.range().unwrap(), (500, 500)); + bounded.advance(500, true); + assert_eq!(bounded.horizon().unwrap(), 500); + let mut exhausted = ScanProgress { + next_index: u64::from(u32::MAX), + ..ScanProgress::refresh(None) + }; + assert_eq!(exhausted.range().unwrap(), (u32::MAX, u32::MAX)); + exhausted.advance(u32::MAX, false); + exhausted.validate().unwrap(); + assert_eq!(exhausted.horizon().unwrap(), u32::MAX); + assert!(exhausted.range().is_err()); + } + + #[test] + fn passive_refresh_revisits_old_gap_without_unbounded_frontier_growth() { + let mut quiet = ScanProgress::refresh(Some(1999)); + for end in [499, 999, 1499, 1999] { + quiet.advance(end, false); + } + assert_eq!(quiet.horizon().unwrap(), 1999); + let mut changed = ScanProgress::refresh(Some(1999)); + changed.advance(499, false); + changed.advance(999, false); + changed.advance(1499, true); + for end in [1999, 2499, 2999] { + changed.advance(end, false); + assert!(!changed.complete); + } + changed.advance(3499, false); + assert_eq!(changed.horizon().unwrap(), 3499); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/payments/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/payments/tests.rs new file mode 100644 index 000000000..289a9092c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/payments/tests.rs @@ -0,0 +1,1142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +use super::*; +use crate::{ + runtime::{authority::AuthoritySession, services::RuntimeServices}, + test_support::{StubPlatform, test_spawner}, +}; +use futures::executor::block_on; +use std::sync::atomic::{AtomicBool, Ordering}; +use truapi_coinage::{ClaimPlan, CodableClaimPlanEntry, CoinageIndexStore, IndexKind}; + +fn context(platform: Arc) -> NativeChatContext { + NativeChatContext { + services: RuntimeServices::new( + platform, + truapi_platform::HostInfo { + name: "Payment test".into(), + icon: None, + version: None, + platform: latest::HostPlatform::Unknown, + }, + [2; 32], + [3; 32], + [4; 32], + test_spawner(), + ), + session: AuthoritySession { + public_key: [1; 32], + identity_account_id: Some([5; 32]), + lite_username: None, + full_username: None, + validation_id: vec![1], + }, + entropy: Zeroizing::new(vec![0x44; 16]), + session_valid: Arc::new(|| true), + network_suffix: "test".into(), + genesis_hash: [2; 32], + coinage_instance_id: None, + } +} + +fn intent() -> PaymentIntent { + PaymentIntent { + product_id: "chat.dot".into(), + peer_identity: [7; 32], + recipient_username: Some("alice.dot".into()), + request_id: "request-1".into(), + amount_cents: 25, + } +} + +struct NoTransport; +#[async_trait] +impl PaymentTransport for NoTransport { + async fn accept(&self, _: &HostNativeChatPayment, _: TransferMemo) -> Result<(), ()> { + panic!("rejected or conflicting operation must not reach transport") + } +} + +#[test] +fn cached_wallet_rejects_asset_retargeting_before_review_or_chain_effects() { + block_on(async { + let platform = Arc::new(StubPlatform::default()); + let mut context = context(platform); + context.coinage_instance_id = Some(0); + let wallet = WalletCoinage::open(&context).await.unwrap(); + context.coinage_instance_id = Some(1); + assert_eq!( + wallet.send(&context, intent(), Arc::new(NoTransport)).await, + Err(Error::OperationConflict) + ); + drop(wallet); + assert!(matches!( + WalletCoinage::open(&context).await, + Err(Error::OperationConflict) + )); + }); +} + +#[test] +fn durable_denial_and_conflicting_body_never_reach_chain_or_transport() { + block_on(async { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + }); + let context = context(platform.clone()); + let wallet = WalletCoinage::open(&context).await.unwrap(); + assert_eq!( + wallet.reconcile(&context).await, + Ok(()), + "ordinary Chat must work without a Coinage RPC" + ); + let intent = intent(); + let id = operation_id([1; 32], [2; 32], &intent.product_id, &intent.request_id); + let mut operation = new_outgoing(id, [2; 32], intent.clone()); + operation.phase = Phase::Denied; + operation.card.state = State::Failed { + reason: Failure::Cancelled, + }; + wallet.save(&operation).await.unwrap(); + drop(wallet); + let restarted = WalletCoinage::open(&context).await.unwrap(); + assert_eq!( + restarted + .send(&context, intent.clone(), Arc::new(NoTransport)) + .await, + Err(Error::UserRejected) + ); + let mut changed = intent.clone(); + changed.amount_cents += 1; + assert_eq!( + restarted + .send(&context, changed, Arc::new(NoTransport)) + .await, + Err(Error::OperationConflict) + ); + assert_eq!( + restarted.reconcile(&context).await, + Ok(()), + "denied payments cannot require chain recovery" + ); + assert_eq!( + restarted + .store + .current_index(IndexKind::Coin) + .await + .unwrap(), + None + ); + assert!( + WalStore::load_all(restarted.store.as_ref()) + .await + .unwrap() + .is_empty() + ); + assert!(platform.chain_connects.lock().unwrap().is_empty()); + assert!( + platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn operation_identity_rejects_cross_product_peer_network_and_amount_rebinding() { + let original = intent(); + let id = operation_id([1; 32], [2; 32], &original.product_id, &original.request_id); + let operation = new_outgoing(id, [2; 32], original.clone()); + for changed in [ + PaymentIntent { + peer_identity: [8; 32], + ..original.clone() + }, + PaymentIntent { + amount_cents: 26, + ..original.clone() + }, + PaymentIntent { + product_id: "other.dot".into(), + ..original.clone() + }, + ] { + assert!(!operation.matches(&changed, [2; 32])); + } + assert!(!operation.matches(&original, [3; 32])); + assert!(operation.matches(&original, [2; 32])); + let renamed = PaymentIntent { + recipient_username: Some("renamed.dot".into()), + ..original + }; + assert!(operation.matches(&renamed, [2; 32])); +} + +struct LogoutOnReview { + valid: Arc, + expected: MainPurseChatPaymentReview, +} +#[async_trait] +impl truapi_platform::UserConfirmation for LogoutOnReview { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + assert_eq!( + review, + UserConfirmationReview::MainPurseChatPayment(self.expected.clone()) + ); + self.valid.store(false, Ordering::SeqCst); + Ok(true) + } +} + +#[test] +fn exact_review_is_never_reused_across_logout_and_denial_is_not_approval() { + block_on(async { + let mut operation = new_outgoing([9; 32], [2; 32], intent()); + operation.max_debit_cents = 28; + let valid = Arc::new(AtomicBool::new(true)); + let platform = LogoutOnReview { + valid: valid.clone(), + expected: MainPurseChatPaymentReview { + calling_product_id: "chat.dot".into(), + recipient_identity: [7; 32], + recipient_username: Some("alice.dot".into()), + amount_cents: 25, + max_debit_cents: 28, + genesis_hash: [2; 32], + coinage_instance_id: None, + operation_id: [9; 32], + }, + }; + let live = move || valid.load(Ordering::SeqCst); + assert_eq!( + review_operation(&platform, &live, &operation, None).await, + Err(Error::NotConnected) + ); + let denied = StubPlatform::default(); + assert_eq!( + review_operation(&denied, &|| true, &operation, None).await, + Ok(false) + ); + }); +} + +fn source(seed: u8) -> MemoEntry { + MemoEntry( + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .unwrap() + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_bytes(), + ) +} + +fn received(memo: &TransferMemo, reverse: bool) -> chat_device::PaymentMemo { + let mut keys: Vec<_> = memo.entries.iter().map(|entry| entry.0.to_vec()).collect(); + if reverse { + keys.reverse(); + } + chat_device::PaymentMemo { + message_id: "duplicate-message".into(), + timestamp: 2, + total_value: memo.total_value, + coin_keys: Zeroizing::new(keys), + } +} + +#[test] +fn incoming_ack_requires_durable_plan_and_duplicate_sources_keep_one_card() { + block_on(async { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + }); + let context = context(platform); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let memo = TransferMemo { + entries: vec![source(12), source(13)], + total_value: 240, + }; + let public = memo_public(&memo).unwrap(); + let mut operation = new_outgoing([9; 32], [2; 32], intent()); + operation.phase = Phase::Incoming; + operation.card.direction = Direction::Incoming; + operation.card.amount_cents = 24; + operation.card.state = State::Claiming; + operation.denominations = Some((10, 0, 8, 2)); + operation.memo_key = Some(memo.identifier()); + operation.source_fingerprint = Some(source_fingerprint(&public)); + operation.source_exponents = vec![None; 2]; + operation.source_seen = vec![false; 2]; + operation.source_cleared = vec![false; 2]; + operation.source_public = public; + operation.memo = memo.scale_encoded(); + wallet.save(&operation).await.unwrap(); + // Persisting only secrets is insufficient for an ACK. + assert_eq!( + wallet + .receive_batch_once( + &context, + "chat.dot".into(), + [7; 32], + "request-2".into(), + vec![received(&memo, true)] + ) + .await, + Err(Error::NetworkUnavailable) + ); + ClaimPlanStore::save( + wallet.store.as_ref(), + &ClaimPlan { + memo_key: memo.identifier(), + message_id: Some(truapi_coinage::external_claim_message_id( + &memo.identifier(), + )), + entries: vec![ + CodableClaimPlanEntry { + entry_index: 0, + exponent: 4, + derivation_index: 20, + }, + CodableClaimPlanEntry { + entry_index: 1, + exponent: 3, + derivation_index: 21, + }, + ], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: 240, + }, + ) + .await + .unwrap(); + let replay = wallet + .receive_batch_once( + &context, + "chat.dot".into(), + [7; 32], + "request-2".into(), + vec![received(&memo, true)], + ) + .await + .unwrap() + .remove(0); + assert_eq!(replay.operation_id, operation.card.operation_id); + assert_eq!(replay.message_id, operation.card.message_id); + assert_eq!( + wallet.views("chat.dot").await.unwrap(), + vec![operation.card.clone()] + ); + let mut changed = received(&memo, false); + changed.total_value += 10; + assert_eq!( + wallet + .receive_batch_once( + &context, + "chat.dot".into(), + [7; 32], + "request-2".into(), + vec![changed] + ) + .await, + Err(Error::OperationConflict) + ); + assert_eq!( + wallet + .receive_batch_once( + &context, + "other.dot".into(), + [7; 32], + "request-2".into(), + vec![received(&memo, false)] + ) + .await, + Err(Error::OperationConflict) + ); + }); +} + +#[test] +fn malformed_later_memo_cannot_start_an_earlier_claim() { + block_on(async { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + }); + let context = context(platform.clone()); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let memo = TransferMemo { + entries: vec![source(14)], + total_value: 160, + }; + let first = received(&memo, false); + let mut invalid = received(&memo, false); + invalid.message_id = "malformed-second".into(); + invalid.coin_keys[0].pop(); + assert_eq!( + wallet + .receive_batch_once( + &context, + "chat.dot".into(), + [7; 32], + "batch".into(), + vec![first, invalid] + ) + .await, + Err(Error::InvalidRequest) + ); + assert!(wallet.views("chat.dot").await.unwrap().is_empty()); + assert_eq!( + wallet.store.current_index(IndexKind::Coin).await.unwrap(), + None + ); + assert!( + WalStore::load_all(wallet.store.as_ref()) + .await + .unwrap() + .is_empty() + ); + assert!(platform.chain_connects.lock().unwrap().is_empty()); + }); +} + +#[test] +fn delivery_ack_never_claims_settlement_or_overwrites_partial_clearing() { + block_on(async { + let context = context(Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + })); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let mut operation = new_outgoing([9; 32], [2; 32], intent()); + operation.phase = Phase::Accepted; + operation.card.state = State::PartiallyCleared { cleared_cents: 10 }; + wallet.save(&operation).await.unwrap(); + assert_eq!( + wallet.note_delivery(&context, "other.dot", [9; 32]).await, + Err(Error::OperationNotFound) + ); + wallet + .note_delivery(&context, "chat.dot", [9; 32]) + .await + .unwrap(); + assert_eq!( + wallet.views("chat.dot").await.unwrap()[0].state, + State::PartiallyCleared { cleared_cents: 10 } + ); + assert!(wallet.views("other.dot").await.unwrap().is_empty()); + operation.card.state = State::Delivering; + wallet.save(&operation).await.unwrap(); + wallet + .note_delivery(&context, "chat.dot", [9; 32]) + .await + .unwrap(); + drop(wallet); + let restored = WalletCoinage::open(&context).await.unwrap(); + assert_eq!( + restored.views("chat.dot").await.unwrap()[0].state, + State::Delivered + ); + }); +} + +fn accepted_outgoing(request_id: &str) -> Operation { + let intent = PaymentIntent { + request_id: request_id.into(), + ..intent() + }; + let id = operation_id([1; 32], [2; 32], &intent.product_id, &intent.request_id); + let mut operation = new_outgoing(id, [2; 32], intent); + let memo = TransferMemo { + entries: vec![source(12), source(13), source(14)], + total_value: 250, + }; + let public = memo_public(&memo).unwrap(); + operation.phase = Phase::Accepted; + operation.card.state = State::Delivering; + operation.max_debit_cents = 25; + operation.denominations = Some((10, 0, 8, 2)); + operation.memo_key = Some(memo.identifier()); + operation.source_fingerprint = Some(source_fingerprint(&public)); + operation.source_exponents = vec![Some(4), Some(3), Some(0)]; + operation.source_seen = vec![true; 3]; + operation.source_cleared = vec![false; 3]; + operation.source_public = public; + operation.detection_anchor = Some([8; 32]); + operation.memo = memo.scale_encoded(); + operation +} + +// Actor integration fixtures seed custody and its post-transport crash window. +// Subsequent delivery, encrypted restore, and memo replay use the actual wallet API. +impl WalletCoinage { + pub(in crate::runtime::native_chat) async fn seed_accepted_for_test( + &self, + context: &NativeChatContext, + intent: PaymentIntent, + timestamp: u64, + memo: &TransferMemo, + ) -> Result { + self.check(context)?; + let id = operation_id( + self.root_public_key, + self.genesis_hash, + &intent.product_id, + &intent.request_id, + ); + let mut operation = new_outgoing(id, self.genesis_hash, intent); + let public = memo_public(memo)?; + operation.phase = Phase::Accepted; + operation.card.state = State::Delivering; + operation.card.timestamp = timestamp; + operation.max_debit_cents = operation.card.amount_cents; + operation.denominations = Some((10, 0, 8, 2)); + operation.memo_key = Some(memo.identifier()); + operation.source_fingerprint = Some(source_fingerprint(&public)); + operation.source_exponents = vec![None; public.len()]; + operation.source_seen = vec![true; public.len()]; + operation.source_cleared = vec![false; public.len()]; + operation.source_public = public; + operation.detection_anchor = Some([8; 32]); + operation.memo = memo.scale_encoded(); + self.stored_memo(&operation)?; + self.save(&operation).await?; + Ok(operation.card.clone()) + } + + pub(in crate::runtime::native_chat) async fn seed_handoff_ready_for_test( + &self, + context: &NativeChatContext, + id: [u8; 32], + ) -> Result<(), Error> { + self.check(context)?; + let mut operation = self.load(id).await?.ok_or(Error::OperationNotFound)?; + operation.phase = Phase::HandoffReady; + self.save(&operation).await?; + save_receipt( + self, + &operation, + truapi_coinage::WalOperation::TransferPrepared, + ) + .await; + Ok(()) + } + + pub(in crate::runtime::native_chat) async fn seed_incoming_for_test( + &self, + context: &NativeChatContext, + product: &str, + peer: [u8; 32], + request_id: &str, + message_id: &str, + timestamp: u64, + memo: &TransferMemo, + with_plan: bool, + ) -> Result { + self.check(context)?; + let public = memo_public(memo)?; + let fingerprint = source_fingerprint(&public); + let id = hash( + &( + b"truapi/main-purse/incoming/v1".as_slice(), + self.root_public_key, + self.genesis_hash, + fingerprint, + ) + .encode(), + ); + if memo.total_value == 0 || memo.total_value % 10 != 0 { + return Err(Error::InvalidRequest); + } + let amount_cents = + u64::try_from(memo.total_value / 10).map_err(|_| Error::InvalidRequest)?; + let operation = Operation { + product_id: product.into(), + recipient_username: None, + genesis_hash: self.genesis_hash, + card: HostNativeChatPayment { + operation_id: id, + request_id: request_id.into(), + message_id: message_id.into(), + timestamp, + peer_identity: peer, + direction: Direction::Incoming, + amount_cents, + state: State::Claiming, + }, + phase: Phase::Incoming, + max_debit_cents: 0, + denominations: Some((10, 0, 8, 2)), + source_exponents: vec![None; public.len()], + source_seen: vec![false; public.len()], + source_cleared: vec![false; public.len()], + source_public: public, + memo_key: Some(memo.identifier()), + source_fingerprint: Some(fingerprint), + detection_anchor: None, + delivered: false, + memo: memo.scale_encoded(), + }; + self.save(&operation).await?; + if with_plan { + self.persist_incoming_plan_for_test(memo).await?; + } + Ok(operation.card.clone()) + } + + pub(in crate::runtime::native_chat) async fn persist_incoming_plan_for_test( + &self, + memo: &TransferMemo, + ) -> Result<(), Error> { + let mut remaining = memo.total_value / 10; + let mut entries = Vec::new(); + while remaining != 0 { + let exponent = 127 - remaining.leading_zeros(); + if exponent > 8 { + return Err(Error::InvalidRequest); + } + let derivation_index = self + .store + .get_next_index(IndexKind::Coin) + .await + .map_err(|_| Error::StorageUnavailable)?; + entries.push(CodableClaimPlanEntry { + entry_index: entries.len() as i16, + exponent: exponent as i16, + derivation_index, + }); + remaining -= 1u128 << exponent; + } + if entries.len() != memo.entries.len() || memo.total_value % 10 != 0 { + return Err(Error::InvalidRequest); + } + ClaimPlanStore::save( + self.store.as_ref(), + &ClaimPlan { + memo_key: memo.identifier(), + message_id: Some(truapi_coinage::external_claim_message_id( + &memo.identifier(), + )), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: memo.total_value, + }, + ) + .await + .map_err(|_| Error::StorageUnavailable) + } +} + +#[derive(Default)] +struct ReplayTransport { + attempts: parking_lot::Mutex>)>>, + reject: AtomicBool, +} + +#[async_trait] +impl PaymentTransport for ReplayTransport { + async fn accept(&self, card: &HostNativeChatPayment, memo: TransferMemo) -> Result<(), ()> { + self.attempts + .lock() + .push((card.clone(), Zeroizing::new(memo.scale_encoded()))); + if self.reject.load(Ordering::SeqCst) { + Err(()) + } else { + Ok(()) + } + } +} + +async fn save_receipt( + wallet: &WalletCoinage, + operation: &Operation, + kind: truapi_coinage::WalOperation, +) { + WalStore::save( + wallet.store.as_ref(), + &truapi_coinage::TransferWalEntry { + entry_id: truapi_coinage::wal::operation_entry_id( + &hex::encode(operation.card.operation_id), + "parent", + ), + operation: kind, + payload: truapi_coinage::WalPayload::default(), + checkpoint: truapi_coinage::CheckpointBlock::Pending, + created_at_ms: 1, + }, + ) + .await + .unwrap(); +} + +#[test] +fn accepted_replay_survives_transport_failure_and_restart_without_new_payment_or_delivery() { + block_on(async { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + }); + let context = context(platform.clone()); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let operation = accepted_outgoing("accepted-replay"); + wallet.save(&operation).await.unwrap(); + save_receipt( + &wallet, + &operation, + truapi_coinage::WalOperation::TransferAccepted, + ) + .await; + let reserved = truapi_coinage::Coin { + exponent: 5, + derivation_index: 42, + age: Some(0), + state: truapi_coinage::CoinState::PendingTransfer, + }; + truapi_coinage::CoinRepository::upsert(wallet.store.as_ref(), &reserved) + .await + .unwrap(); + let transport = Arc::new(ReplayTransport::default()); + transport.reject.store(true, Ordering::SeqCst); + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + transport.clone() + ) + .await, + Err(Error::NetworkUnavailable) + ); + assert_eq!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap(), + vec![operation.card.clone()] + ); + assert_eq!( + truapi_coinage::CoinRepository::list(wallet.store.as_ref()) + .await + .unwrap(), + vec![reserved.clone()] + ); + drop(wallet); + let restarted = WalletCoinage::open(&context).await.unwrap(); + transport.reject.store(false, Ordering::SeqCst); + restarted + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + transport.clone(), + ) + .await + .unwrap(); + // Durable custody by the replacement transport is not a peer ACK. + assert_eq!( + restarted + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap(), + vec![operation.card.clone()] + ); + assert_eq!( + restarted.views("chat.dot").await.unwrap(), + vec![operation.card.clone()] + ); + assert_eq!( + truapi_coinage::CoinRepository::list(restarted.store.as_ref()) + .await + .unwrap(), + vec![reserved] + ); + let attempts = transport.attempts.lock(); + assert_eq!(attempts.len(), 2); + for (card, bytes) in attempts.iter() { + assert_eq!(card, &operation.card); + assert!( + bytes.as_slice() == operation.memo.as_slice(), + "replay must retain the exact native memo" + ); + } + assert!(platform.chain_connects.lock().unwrap().is_empty()); + assert!( + platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn completed_send_retry_repairs_transport_custody_offline_without_review() { + block_on(async { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("offline"), + ..Default::default() + }); + let context = context(platform.clone()); + let wallet = WalletCoinage::open(&context).await.unwrap(); + // The core receipt survived, but the Host's post-acceptance write did not. + let mut operation = accepted_outgoing("completed-retry"); + operation.phase = Phase::HandoffReady; + wallet.save(&operation).await.unwrap(); + save_receipt( + &wallet, + &operation, + truapi_coinage::WalOperation::TransferCompleted, + ) + .await; + assert!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap() + .is_empty() + ); + let transport = Arc::new(ReplayTransport::default()); + let retry = PaymentIntent { + request_id: operation.card.request_id.clone(), + ..intent() + }; + let card = wallet + .send(&context, retry, transport.clone()) + .await + .unwrap(); + assert_eq!(card, operation.card); + let attempts = transport.attempts.lock(); + assert_eq!(attempts.len(), 1); + assert!(attempts[0].1.as_slice() == operation.memo.as_slice()); + drop(attempts); + assert_eq!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap(), + vec![card] + ); + assert!(platform.chain_connects.lock().unwrap().is_empty()); + assert!( + platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + }); +} + +#[test] +fn replay_rejects_other_product_wallet_network_and_expired_session() { + block_on(async { + let context = context(Arc::new(StubPlatform::default())); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let operation = accepted_outgoing("scoped-replay"); + wallet.save(&operation).await.unwrap(); + assert!( + wallet + .pending_handoffs(&context, "other.dot", &[]) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + wallet + .redeliver( + &context, + "other.dot", + operation.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::OperationNotFound) + ); + let mut other_wallet = context.clone(); + other_wallet.session.public_key = [9; 32]; + let mut other_network = context.clone(); + other_network.genesis_hash = [9; 32]; + let mut expired = context.clone(); + expired.session_valid = Arc::new(|| false); + for invalid in [other_wallet, other_network, expired] { + assert_eq!( + wallet.pending_handoffs(&invalid, "chat.dot", &[]).await, + Err(Error::NotConnected) + ); + assert_eq!( + wallet + .redeliver( + &invalid, + "chat.dot", + operation.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::NotConnected) + ); + } + let valid = Arc::new(AtomicBool::new(true)); + let mut queued_context = context.clone(); + let validity = valid.clone(); + queued_context.session_valid = Arc::new(move || validity.load(Ordering::SeqCst)); + let gate = wallet.gate.lock().await; + let replay = wallet.redeliver( + &queued_context, + "chat.dot", + operation.card.operation_id, + Arc::new(NoTransport), + ); + futures::pin_mut!(replay); + assert!(futures::poll!(replay.as_mut()).is_pending()); + valid.store(false, Ordering::SeqCst); + drop(gate); + assert_eq!(replay.await, Err(Error::NotConnected)); + assert_eq!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap(), + vec![operation.card.clone()] + ); + }); +} + +#[test] +fn replay_excludes_unaccepted_rejected_cleared_failed_and_acknowledged_secrets() { + block_on(async { + let context = context(Arc::new(StubPlatform::default())); + let wallet = WalletCoinage::open(&context).await.unwrap(); + for (index, phase) in [ + Phase::Review, + Phase::Approved, + Phase::HandoffReady, + Phase::Denied, + Phase::Incoming, + ] + .into_iter() + .enumerate() + { + let mut operation = accepted_outgoing(&format!("unaccepted-{index}")); + operation.phase = phase; + if phase == Phase::Incoming { + operation.card.direction = Direction::Incoming; + } + wallet.save(&operation).await.unwrap(); + let error = if phase == Phase::Incoming { + Error::OperationNotFound + } else { + Error::OperationConflict + }; + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(error) + ); + } + for (index, state) in [ + State::Delivered, + State::Cleared, + State::Failed { + reason: Failure::Cancelled, + }, + ] + .into_iter() + .enumerate() + { + let mut operation = accepted_outgoing(&format!("terminal-{index}")); + operation.card.state = state; + wallet.save(&operation).await.unwrap(); + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::OperationConflict) + ); + } + // Rejection evidence wins even over a stale Accepted card. + let rejected = accepted_outgoing("rejected"); + wallet.save(&rejected).await.unwrap(); + save_receipt( + &wallet, + &rejected, + truapi_coinage::WalOperation::TransferRejected, + ) + .await; + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + rejected.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::OperationConflict) + ); + let acknowledged = accepted_outgoing("acknowledged"); + wallet.save(&acknowledged).await.unwrap(); + wallet + .note_delivery(&context, "chat.dot", acknowledged.card.operation_id) + .await + .unwrap(); + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + acknowledged.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::OperationConflict) + ); + assert!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap() + .is_empty() + ); + // Partial clearing is not terminal: replay retains the complete memo, + // including spent entries, instead of constructing a different payment. + let mut partial = accepted_outgoing("partial"); + partial.card.state = State::PartiallyCleared { cleared_cents: 16 }; + partial.source_cleared[0] = true; + wallet.save(&partial).await.unwrap(); + assert_eq!( + wallet + .pending_handoffs(&context, "chat.dot", &[]) + .await + .unwrap(), + vec![partial.card.clone()] + ); + let transport = Arc::new(ReplayTransport::default()); + wallet + .redeliver( + &context, + "chat.dot", + partial.card.operation_id, + transport.clone(), + ) + .await + .unwrap(); + assert!(transport.attempts.lock()[0].1.as_slice() == partial.memo.as_slice()); + }); +} + +#[test] +fn replay_never_exports_a_memo_with_corrupt_immutable_bindings_or_shape() { + block_on(async { + let context = context(Arc::new(StubPlatform::default())); + let wallet = WalletCoinage::open(&context).await.unwrap(); + for mutation in 0..7 { + let mut operation = accepted_outgoing("corrupt"); + match mutation { + 0 => operation.card.amount_cents += 1, + 1 => operation.source_fingerprint = Some([0; 32]), + 2 => operation.memo_key = Some([0; 32]), + 3 => operation.source_public.reverse(), + 4 => operation.memo.push(0), + 5 => operation.card.message_id = "different-message".into(), + 6 => { + let memo = TransferMemo { + entries: vec![source(12), source(12)], + total_value: 250, + }; + operation.memo_key = Some(memo.identifier()); + operation.memo = memo.scale_encoded(); + } + _ => unreachable!(), + } + wallet.save(&operation).await.unwrap(); + assert_eq!( + wallet.pending_handoffs(&context, "chat.dot", &[]).await, + Err(Error::StorageUnavailable) + ); + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + Arc::new(NoTransport) + ) + .await, + Err(Error::StorageUnavailable) + ); + } + }); +} + +#[test] +fn actor_custody_cannot_override_missing_or_rejected_wallet_recovery_evidence() { + block_on(async { + for (receipt, expected) in [ + (None, Error::StorageUnavailable), + ( + Some(truapi_coinage::WalOperation::TransferRejected), + Error::OperationConflict, + ), + ] { + let platform = Arc::new(StubPlatform::default()); + let context = context(platform.clone()); + let wallet = WalletCoinage::open(&context).await.unwrap(); + let mut operation = accepted_outgoing("conflicting-custody"); + operation.phase = Phase::HandoffReady; + wallet.save(&operation).await.unwrap(); + if let Some(receipt) = receipt { + save_receipt(&wallet, &operation, receipt).await; + } + let accepted = [operation.card.operation_id]; + assert_eq!( + wallet + .pending_handoffs(&context, "chat.dot", &accepted) + .await, + Err(expected) + ); + let transport = Arc::new(ReplayTransport::default()); + assert_eq!( + wallet + .redeliver( + &context, + "chat.dot", + operation.card.operation_id, + transport.clone(), + ) + .await, + Err(Error::OperationConflict) + ); + assert!(transport.attempts.lock().is_empty()); + assert!(platform.chain_connects.lock().unwrap().is_empty()); + assert!( + platform + .main_purse_chat_payment_reviews + .lock() + .unwrap() + .is_empty() + ); + } + }); +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/store.rs b/rust/crates/truapi-server/src/runtime/native_chat/store.rs new file mode 100644 index 000000000..7e0a4b455 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/store.rs @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-private, wallet/network/product-bound Chat snapshots. +//! +//! One live store owns each typed slot, including across session replacement. +//! CoreStorage must atomically and durably replace complete values; the Host +//! executor must outlive owned persistence tasks. Another process must not write +//! the same slot concurrently. + +use std::{ + collections::HashMap, + sync::{ + Arc, LazyLock, Weak, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, +}; + +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{AeadInPlace, KeyInit}, +}; +use futures::{channel::oneshot, lock::Mutex}; +use hkdf::Hkdf; +use parity_scale_codec::{Decode, Encode}; +use parking_lot::Mutex as SyncMutex; +use sha2::Sha256; +use truapi::latest::HostProductDeviceChatError as ChatError; +use truapi_platform::{CoreStorage, CoreStorageKey}; +use zeroize::Zeroizing; + +use super::NativeChatContext; +use crate::subscription::Spawner; + +mod codec; +#[cfg(test)] +mod tests; + +const MAGIC: &[u8; 4] = b"HCHS"; +const VERSION: u16 = 3; +const HEADER_BYTES: usize = 4 + 2 + 24; +const TAG_BYTES: usize = 16; +const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; +const MAX_DECODE_BYTES: usize = 32 * 1024 * 1024; +const MAX_DECODE_DEPTH: u32 = 64; + +// Clean slots exist only while an open/store holds a lease. Uncertain slots +// retain strong, nonsecret poison independently of those leases until durable +// repair or the backing platform's death. Weak gates alone would lose custody +// after a failed first write whose value is not visible on the next read. +struct SlotState { + owner: Arc>>, + uncertain: AtomicBool, + // Updated under SLOTS. Arc counts include other destructors' not-yet-dropped + // fields, so inspecting them can miss reclamation when leases drop together. + leases: AtomicUsize, +} + +struct PlatformSlots { + storage: Weak, + slots: HashMap>, Arc>, +} + +static SLOTS: LazyLock>> = + LazyLock::new(|| SyncMutex::new(HashMap::new())); + +struct SlotGate { + storage_id: usize, + key: Arc>, + state: Arc, +} + +impl Drop for SlotGate { + fn drop(&mut self) { + let mut platforms = SLOTS.lock(); + let last_lease = self.state.leases.fetch_sub(1, Ordering::Relaxed) == 1; + let Some(platform) = platforms.get_mut(&self.storage_id) else { + return; + }; + // Acquiring another lease also holds SLOTS, so this check and removal + // cannot race a new open. Owned writes retain their store's lease. + if last_lease + && !self.state.uncertain.load(Ordering::Acquire) + && platform + .slots + .get(&self.key) + .is_some_and(|state| Arc::ptr_eq(state, &self.state)) + { + platform.slots.remove(&self.key); + } + } +} + +fn slot_gate(storage: &Arc, key: &CoreStorageKey) -> SlotGate { + let storage_id = Arc::as_ptr(storage) as *const () as usize; + let mut platforms = SLOTS.lock(); + let same_platform = platforms.get(&storage_id).is_some_and(|platform| { + platform + .storage + .upgrade() + .is_some_and(|existing| Arc::ptr_eq(&existing, storage)) + }); + if !same_platform { + // Sweep only when attaching a new platform, not for each file chunk. + // The Weak check, not a possibly reused address, proves its identity. + platforms.retain(|_, platform| platform.storage.strong_count() != 0); + platforms.insert( + storage_id, + PlatformSlots { + storage: Arc::downgrade(storage), + slots: HashMap::new(), + }, + ); + } + let platform = platforms.get_mut(&storage_id).expect("registered platform"); + let encoded = key.encode(); + let (key, state) = if let Some((key, state)) = platform.slots.get_key_value(&encoded) { + state.leases.fetch_add(1, Ordering::Relaxed); + (key.clone(), state.clone()) + } else { + let key = Arc::new(encoded); + let state = Arc::new(SlotState { + owner: Arc::new(Mutex::new(Weak::new())), + uncertain: AtomicBool::new(false), + leases: AtomicUsize::new(1), + }); + platform.slots.insert(key.clone(), state.clone()); + (key, state) + }; + SlotGate { + storage_id, + key, + state, + } +} + +struct SnapshotCipher { + key: Zeroizing<[u8; 32]>, + aad: Vec, +} + +impl SnapshotCipher { + fn new(storage_key: &CoreStorageKey, entropy: &[u8]) -> Result { + if entropy.is_empty() { + return Err(ChatError::NotConnected); + } + let mut aad = b"truapi/host/native-chat/snapshot\0".to_vec(); + aad.extend_from_slice(MAGIC); + aad.extend_from_slice(&VERSION.to_le_bytes()); + storage_key.encode_to(&mut aad); + let mut key = Zeroizing::new([0; 32]); + Hkdf::::new(Some(b"truapi/host/native-chat/snapshot-key/v1"), entropy) + .expand(&aad, &mut *key) + .map_err(|_| ChatError::StorageUnavailable)?; + Ok(Self { key, aad }) + } + + fn decrypt(&self, stored: &[u8]) -> Result { + if stored.len() < HEADER_BYTES + TAG_BYTES + || stored.len() > HEADER_BYTES + MAX_SNAPSHOT_BYTES + TAG_BYTES + || &stored[..4] != MAGIC + || u16::from_le_bytes([stored[4], stored[5]]) != VERSION + { + return Err(ChatError::StorageUnavailable); + } + let mut plaintext = Zeroizing::new(stored[HEADER_BYTES..].to_vec()); + XChaCha20Poly1305::new((&*self.key).into()) + .decrypt_in_place( + XNonce::from_slice(&stored[6..HEADER_BYTES]), + &self.aad, + &mut *plaintext, + ) + .map_err(|_| ChatError::StorageUnavailable)?; + codec::decode(&plaintext) + } + + fn encrypt(&self, state: &T) -> Result, ChatError> { + let mut plaintext = codec::encode(state)?; + // A generic state can be small on the wire yet exceed restore's depth + // or allocation limits. Refuse to commit a snapshot we cannot reopen. + drop(codec::decode::(&plaintext)?); + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut nonce).map_err(|_| ChatError::StorageUnavailable)?; + XChaCha20Poly1305::new((&*self.key).into()) + .encrypt_in_place(XNonce::from_slice(&nonce), &self.aad, &mut *plaintext) + .map_err(|_| ChatError::StorageUnavailable)?; + let mut envelope = Vec::with_capacity(HEADER_BYTES + plaintext.len()); + envelope.extend_from_slice(MAGIC); + envelope.extend_from_slice(&VERSION.to_le_bytes()); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&plaintext); + Ok(envelope) + } +} + +/// Generic transactional state; never expose this or its closure results to a +/// product without the actor's typed public projection. +/// +/// `T` must zeroize its own secrets, including partial decode failure paths. +/// Custom Decode implementations must honor SCALE Input's allocation/depth +/// hooks; derived Decode and SCALE's ordinary collections already do so. +/// There is deliberately no Debug implementation for the store or its state. +pub(super) struct ChatStateStore { + storage: Arc, + storage_key: CoreStorageKey, + cipher: SnapshotCipher, + owner: Arc<()>, + gate: SlotGate, + // Only access while holding gate. The short sync lock makes T: Send enough, + // without requiring T: Sync or holding a sync guard over storage I/O. + published: SyncMutex, + spawner: Spawner, +} + +impl ChatStateStore { + /// Restore authenticated state, or initialize only a genuinely absent slot. + /// Never fall back to a new device after corruption or an ambiguous write. + pub(super) async fn open( + context: &NativeChatContext, + product_id: &str, + initial: impl FnOnce() -> Result, + ) -> Result, ChatError> { + let key = CoreStorageKey::NativeChatDevice { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + product_id: product_id.to_owned(), + }; + Self::open_storage( + context.services.platform.clone(), + key, + &context.entropy, + context.services.spawner.clone(), + initial, + ) + .await + } + + /// One immutable, bounded private file chunk in a distinct authenticated slot. + pub(super) async fn open_file_chunk( + context: &NativeChatContext, + product_id: &str, + attachment_id: [u8; 32], + chunk_index: u32, + initial: impl FnOnce() -> Result, + ) -> Result, ChatError> { + context.require_current()?; + Self::open_storage( + context.services.platform.clone(), + CoreStorageKey::NativeChatFileChunk { + root_public_key: context.session.public_key, + genesis_hash: context.genesis_hash, + product_id: product_id.to_owned(), + attachment_id, + chunk_index, + }, + &context.entropy, + context.services.spawner.clone(), + initial, + ) + .await + } + + async fn open_storage( + storage: Arc, + storage_key: CoreStorageKey, + entropy: &[u8], + spawner: Spawner, + initial: impl FnOnce() -> Result, + ) -> Result, ChatError> { + let cipher = SnapshotCipher::new(&storage_key, entropy)?; + let gate = slot_gate(&storage, &storage_key); + let mut owner = gate.state.owner.clone().lock_owned().await; + if owner.strong_count() != 0 { + // Serializing writes is not enough: a second store's cached state + // could overwrite the first store's subsequent durable updates. + return Err(ChatError::StorageUnavailable); + } + let stored = storage + .read_core_storage(storage_key.clone()) + .await + .map_err(|_| ChatError::StorageUnavailable)?; + let (state, replacement) = match stored { + Some(bytes) => { + let state = cipher.decrypt(&bytes)?; + // A post-rename failure did not prove directory durability. + // Authenticate, then successfully rewrite the same envelope. + let replacement = if gate.state.uncertain.load(Ordering::Acquire) { + Some(bytes) + } else { + None + }; + (state, replacement) + } + None => { + if gate.state.uncertain.load(Ordering::Acquire) { + // Absence is not proof the prior operation never happened. + return Err(ChatError::StorageUnavailable); + } + let state = initial()?; + let bytes = cipher.encrypt(&state)?; + (state, Some(bytes)) + } + }; + let store = Arc::new(Self { + storage, + storage_key, + cipher, + owner: Arc::new(()), + gate, + published: SyncMutex::new(state), + spawner: spawner.clone(), + }); + *owner = Arc::downgrade(&store.owner); + let Some(bytes) = replacement else { + return Ok(store); + }; + // Set before dispatch: even an executor dropping an unpolled task must + // not let a later open silently replace the attempted device identity. + store.gate.state.uncertain.store(true, Ordering::Release); + let (tx, rx) = oneshot::channel(); + (spawner)(Box::pin(async move { + let result = store + .storage + .write_core_storage(store.storage_key.clone(), bytes) + .await + .map_err(|_| ChatError::StorageUnavailable); + if result.is_ok() { + store.gate.state.uncertain.store(false, Ordering::Release); + } + let _ = tx.send(result.map(|()| store)); + // Release an undeliverable store before unlocking: a waiting open + // must not see custody from a canceled initialization's result. + drop(owner); + })); + rx.await.map_err(|_| ChatError::StorageUnavailable)? + } + + pub(super) async fn read(&self, read: impl FnOnce(&T) -> R) -> Result { + let _owner = self.gate.state.owner.lock().await; + if self.gate.state.uncertain.load(Ordering::Acquire) { + return Err(ChatError::StorageUnavailable); + } + Ok(read(&self.published.lock())) + } + + /// Stage, commit durably, then publish, independent of the caller's lifetime. + pub(super) async fn update( + self: &Arc, + mutation: impl FnOnce(&mut T) -> Result + Send + 'static, + ) -> Result { + let store = self.clone(); + let (tx, rx) = oneshot::channel(); + (self.spawner)(Box::pin(async move { + let owner = store.gate.state.owner.clone().lock_owned().await; + let result = async { + if store.gate.state.uncertain.load(Ordering::Acquire) { + return Err(ChatError::StorageUnavailable); + } + let mut candidate = store.published.lock().clone(); + let output = mutation(&mut candidate)?; + let encrypted = store.cipher.encrypt(&candidate)?; + store.gate.state.uncertain.store(true, Ordering::Release); + store + .storage + .write_core_storage(store.storage_key.clone(), encrypted) + .await + .map_err(|_| ChatError::StorageUnavailable)?; + // No await between success and publication. Any write error + // leaves the shared latch set, blocking stale reads and writes. + *store.published.lock() = candidate; + store.gate.state.uncertain.store(false, Ordering::Release); + Ok(output) + } + .await; + drop(store); + let _ = tx.send(result); + drop(owner); + })); + rx.await.map_err(|_| ChatError::StorageUnavailable)? + } + + /// Recover only by authenticating the actual slot and completing a durable + /// rewrite. Missing, malformed, or unreadable storage never clears poison. + pub(super) async fn reauthenticate(self: &Arc) -> Result<(), ChatError> { + let store = self.clone(); + let (tx, rx) = oneshot::channel(); + (self.spawner)(Box::pin(async move { + let owner = store.gate.state.owner.clone().lock_owned().await; + let result = async { + if !store.gate.state.uncertain.load(Ordering::Acquire) { + return Ok(()); + } + let bytes = store + .storage + .read_core_storage(store.storage_key.clone()) + .await + .map_err(|_| ChatError::StorageUnavailable)? + .ok_or(ChatError::StorageUnavailable)?; + let durable = store.cipher.decrypt(&bytes)?; + store + .storage + .write_core_storage(store.storage_key.clone(), bytes) + .await + .map_err(|_| ChatError::StorageUnavailable)?; + *store.published.lock() = durable; + store.gate.state.uncertain.store(false, Ordering::Release); + Ok(()) + } + .await; + drop(store); + let _ = tx.send(result); + drop(owner); + })); + rx.await.map_err(|_| ChatError::StorageUnavailable)? + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/store/codec.rs b/rust/crates/truapi-server/src/runtime/native_chat/store/codec.rs new file mode 100644 index 000000000..2c2de5946 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/store/codec.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Bounded SCALE snapshots. Plaintext staging never leaves a zeroizing buffer. + +use parity_scale_codec::{Decode, DecodeLimit, Encode, MemTrackingInput, Output}; +use zeroize::Zeroizing; + +use super::{ChatError, MAX_DECODE_BYTES, MAX_DECODE_DEPTH, MAX_SNAPSHOT_BYTES, TAG_BYTES}; + +struct Size(usize); + +impl Output for Size { + fn write(&mut self, bytes: &[u8]) { + self.0 = self.0.saturating_add(bytes.len()); + } +} + +struct BoundedOutput { + bytes: Zeroizing>, + limit: usize, + overflow: bool, +} + +impl Output for BoundedOutput { + fn write(&mut self, bytes: &[u8]) { + if self.overflow || bytes.len() > self.limit.saturating_sub(self.bytes.len()) { + self.overflow = true; + } else { + self.bytes.extend_from_slice(bytes); + } + } +} + +pub(super) fn encode(state: &T) -> Result>, ChatError> { + // Do not trust size_hint(), nor allow Vec growth to abandon plaintext in + // a freed allocation. The second pass is bounded even for a custom Encode. + let mut size = Size(0); + state.encode_to(&mut size); + if size.0 > MAX_SNAPSHOT_BYTES { + return Err(ChatError::StorageUnavailable); + } + let mut output = BoundedOutput { + bytes: Zeroizing::new(Vec::with_capacity(size.0 + TAG_BYTES)), + limit: size.0, + overflow: false, + }; + state.encode_to(&mut output); + if output.overflow || output.bytes.len() != size.0 { + return Err(ChatError::StorageUnavailable); + } + Ok(output.bytes) +} + +pub(super) fn decode(plaintext: &[u8]) -> Result { + if plaintext.len() > MAX_SNAPSHOT_BYTES { + return Err(ChatError::StorageUnavailable); + } + let mut remaining = plaintext; + let state = T::decode_with_depth_limit( + MAX_DECODE_DEPTH, + &mut MemTrackingInput::new(&mut remaining, MAX_DECODE_BYTES), + ) + .map_err(|_| ChatError::StorageUnavailable)?; + // DecodeAll's exact-consumption rule, with both depth and memory tracking + // applied to the same decode (DecodeAll itself only accepts a byte slice). + if !remaining.is_empty() { + return Err(ChatError::StorageUnavailable); + } + Ok(state) +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/store/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/store/tests.rs new file mode 100644 index 000000000..174622546 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/store/tests.rs @@ -0,0 +1,769 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Deterministic durability and authenticated restore regressions; no sleeps. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use futures::executor::block_on; +use parity_scale_codec::{Compact, Input}; +use truapi::latest::GenericError; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use super::*; + +#[derive(Clone, Encode, Zeroize, ZeroizeOnDrop)] +struct Secret([u8; 32]); + +impl Decode for Secret { + fn decode(input: &mut I) -> Result { + let mut bytes = Zeroizing::new([0; 32]); + input.read(&mut *bytes)?; + Ok(Self(*bytes)) + } +} + +#[derive(Clone, Encode, Decode)] +struct State { + device: Secret, + roster: Vec<[u8; 32]>, + outbox: BTreeMap>, + processed: Vec, +} + +type PublicSnapshot = ([u8; 32], Vec<[u8; 32]>, BTreeMap>, Vec); + +fn initial() -> Result { + Ok(State { + device: Secret([71; 32]), + roster: Vec::new(), + outbox: BTreeMap::new(), + processed: Vec::new(), + }) +} + +fn snapshot(state: &State) -> PublicSnapshot { + ( + sp_crypto_hashing::blake2_256(&state.device.0), + state.roster.clone(), + state.outbox.clone(), + state.processed.clone(), + ) +} + +fn slot() -> CoreStorageKey { + CoreStorageKey::NativeChatDevice { + root_public_key: [1; 32], + genesis_hash: [2; 32], + product_id: "chat.test".into(), + } +} + +#[derive(Default)] +struct DurableSlot { + values: SyncMutex, Vec>>, + fail_before: AtomicBool, + fail_after: AtomicBool, + writes: AtomicUsize, + pause_next: SyncMutex, oneshot::Receiver<()>)>>, +} + +impl DurableSlot { + fn pause(&self) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + *self.pause_next.lock() = Some((entered_tx, resume_rx)); + (entered_rx, resume_tx) + } + + fn bytes(&self) -> Vec { + self.values.lock().get(&slot().encode()).unwrap().clone() + } + + fn replace(&self, key: &CoreStorageKey, bytes: Vec) { + self.values.lock().insert(key.encode(), bytes); + } +} + +#[async_trait::async_trait] +impl CoreStorage for DurableSlot { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self.values.lock().get(&key.encode()).cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + bytes: Vec, + ) -> Result<(), GenericError> { + self.writes.fetch_add(1, Ordering::SeqCst); + let pause = self.pause_next.lock().take(); + if let Some((entered, resume)) = pause { + let _ = entered.send(()); + resume.await.unwrap(); + } + if self.fail_before.swap(false, Ordering::SeqCst) { + return Err(GenericError { + reason: "private pre-replacement platform diagnostic".into(), + }); + } + self.values.lock().insert(key.encode(), bytes); + if self.fail_after.swap(false, Ordering::SeqCst) { + return Err(GenericError { + reason: "private post-rename platform diagnostic".into(), + }); + } + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.values.lock().remove(&key.encode()); + Ok(()) + } +} + +async fn open(storage: Arc) -> Arc> { + ChatStateStore::open_storage( + storage, + slot(), + &[3; 32], + crate::test_support::test_spawner(), + initial, + ) + .await + .unwrap() +} + +fn change(state: &mut State) -> Result<(), ChatError> { + state.roster.push([9; 32]); + state.outbox.insert(12, b"committed outbox text".to_vec()); + state.processed.push(13); + Ok(()) +} + +#[test] +fn restart_authenticates_same_device_and_atomic_chat_state() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let device = store.read(|state| snapshot(state).0).await.unwrap(); + store.update(change).await.unwrap(); + let committed = store.read(snapshot).await.unwrap(); + assert_eq!(committed.0, device); + assert_eq!(committed.1, vec![[9; 32]]); + assert_eq!(committed.2.get(&12).unwrap(), b"committed outbox text"); + assert_eq!(committed.3, vec![13]); + drop(store); + let restored = ChatStateStore::::open_storage( + storage, + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("an existing device must never be reinitialized"), + ) + .await + .unwrap(); + assert_eq!(restored.read(snapshot).await.unwrap(), committed); + }); +} + +#[test] +fn canceled_update_still_commits_and_publishes_before_reads() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let (entered, resume) = storage.pause(); + let mut caller = Box::pin(store.update(change)); + assert!(futures::poll!(&mut caller).is_pending()); + entered.await.unwrap(); + let mut reader = Box::pin(store.read(snapshot)); + assert!(futures::poll!(&mut reader).is_pending()); + drop(caller); + resume.send(()).unwrap(); + let committed = reader.await.unwrap(); + assert_eq!(committed.1, vec![[9; 32]]); + assert_eq!(committed.2.get(&12).unwrap(), b"committed outbox text"); + assert_eq!(committed.3, vec![13]); + drop(store); + assert_eq!(open(storage).await.read(snapshot).await.unwrap(), committed); + }); +} + +#[test] +fn post_rename_failure_blocks_stale_state_until_cancel_safe_reauthentication() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + storage.fail_after.store(true, Ordering::SeqCst); + assert_eq!( + store.update(change).await, + Err(ChatError::StorageUnavailable) + ); + let accepted = storage.bytes(); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!( + store + .read(|_| panic!("poisoned state cannot authorize a read")) + .await, + Err::<(), _>(ChatError::StorageUnavailable) + ); + assert_eq!( + store + .update(|_| panic!("poisoned state cannot authorize a mutation")) + .await, + Err::<(), _>(ChatError::StorageUnavailable) + ); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + assert_eq!(storage.bytes(), accepted); + + storage.values.lock().remove(&slot().encode()); + assert_eq!( + store.reauthenticate().await, + Err(ChatError::StorageUnavailable) + ); + assert_eq!( + store.read(snapshot).await, + Err(ChatError::StorageUnavailable) + ); + let mut tampered = accepted.clone(); + *tampered.last_mut().unwrap() ^= 1; + storage.replace(&slot(), tampered); + assert_eq!( + store.reauthenticate().await, + Err(ChatError::StorageUnavailable) + ); + storage.replace(&slot(), accepted); + storage.fail_before.store(true, Ordering::SeqCst); + assert_eq!( + store.reauthenticate().await, + Err(ChatError::StorageUnavailable) + ); + assert_eq!( + store.read(snapshot).await, + Err(ChatError::StorageUnavailable) + ); + + let (entered, resume) = storage.pause(); + let mut caller = Box::pin(store.reauthenticate()); + assert!(futures::poll!(&mut caller).is_pending()); + entered.await.unwrap(); + drop(caller); + resume.send(()).unwrap(); + let recovered = store.read(snapshot).await.unwrap(); + assert_eq!(recovered.1, vec![[9; 32]]); + assert_eq!(recovered.2.get(&12).unwrap(), b"committed outbox text"); + assert_eq!(recovered.3, vec![13]); + store + .update(|state| { + state.processed.push(14); + Ok(()) + }) + .await + .unwrap(); + drop(store); + assert_eq!( + open(storage) + .await + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![13, 14] + ); + }); +} + +#[test] +fn canceled_initialization_cannot_mint_a_competing_device() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let calls = AtomicUsize::new(0); + let (entered, resume) = storage.pause(); + let mut first = Box::pin(ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || { + calls.fetch_add(1, Ordering::SeqCst); + initial() + }, + )); + assert!(futures::poll!(&mut first).is_pending()); + entered.await.unwrap(); + drop(first); + let mut replacement = Box::pin(ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("in-flight initialization owns the device identity"), + )); + assert!(futures::poll!(&mut replacement).is_pending()); + resume.send(()).unwrap(); + let store = replacement.await.unwrap(); + assert_eq!( + store.read(snapshot).await.unwrap(), + snapshot(&initial().unwrap()) + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(storage.writes.load(Ordering::SeqCst), 1); + }); +} + +#[test] +fn concurrent_opens_and_owned_updates_keep_exclusive_slot_custody() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let (entered, resume) = storage.pause(); + let mut first = Box::pin(open(storage.clone())); + assert!(futures::poll!(&mut first).is_pending()); + entered.await.unwrap(); + let mut competing = Box::pin(ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("a live store already owns this slot"), + )); + assert!(futures::poll!(&mut competing).is_pending()); + resume.send(()).unwrap(); + let store = first.await; + assert!(matches!( + competing.await, + Err(ChatError::StorageUnavailable) + )); + assert_eq!(storage.writes.load(Ordering::SeqCst), 1); + + let (entered, resume) = storage.pause(); + let mut caller = Box::pin(store.update(change)); + assert!(futures::poll!(&mut caller).is_pending()); + entered.await.unwrap(); + drop(caller); + drop(store); + let mut replacement = Box::pin(ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("an owned update must finish before the next store opens"), + )); + assert!(futures::poll!(&mut replacement).is_pending()); + assert_eq!(storage.writes.load(Ordering::SeqCst), 2); + resume.send(()).unwrap(); + let restored = replacement.await.unwrap(); + assert_eq!( + restored + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![13] + ); + restored + .update(|state| { + state.processed.push(14); + Ok(()) + }) + .await + .unwrap(); + assert_eq!( + restored + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![13, 14] + ); + }); +} + +#[test] +fn dropped_poisoned_store_keeps_custody_until_durable_repair() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + storage.fail_after.store(true, Ordering::SeqCst); + assert_eq!( + store.update(change).await, + Err(ChatError::StorageUnavailable) + ); + let accepted = storage.bytes(); + drop(store); + storage.values.lock().remove(&slot().encode()); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("dropping the last poisoned store cannot authorize a new device"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + assert_eq!(storage.writes.load(Ordering::SeqCst), 2); + storage.replace(&slot(), accepted.clone()); + storage.fail_before.store(true, Ordering::SeqCst); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("repair must authenticate the existing device"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + storage.values.lock().remove(&slot().encode()); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("failed repair must retain poison without a live store"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + storage.replace(&slot(), accepted.clone()); + let recovered = open(storage.clone()).await; + assert_eq!( + recovered + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![13] + ); + assert_eq!(storage.bytes(), accepted); + assert_eq!(storage.writes.load(Ordering::SeqCst), 4); + }); +} + +#[test] +fn same_slot_on_different_platforms_has_independent_custody() { + block_on(async { + let first = Arc::new(DurableSlot::default()); + let second = Arc::new(DurableSlot::default()); + let first_store = open(first.clone()).await; + let second_store = open(second.clone()).await; + first.fail_after.store(true, Ordering::SeqCst); + assert_eq!( + first_store.update(change).await, + Err(ChatError::StorageUnavailable) + ); + assert_eq!( + second_store.read(snapshot).await.unwrap(), + snapshot(&initial().unwrap()) + ); + second_store.update(change).await.unwrap(); + assert_eq!( + second_store + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![13] + ); + }); +} + +#[test] +fn completed_attachment_chunks_release_bookkeeping_but_preserve_storage() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let storage_id = Arc::as_ptr(&storage) as usize; + for chunk_index in 0..64 { + let key = CoreStorageKey::NativeChatFileChunk { + root_public_key: [1; 32], + genesis_hash: [2; 32], + product_id: "chat.test".into(), + attachment_id: [7; 32], + chunk_index, + }; + let store = ChatStateStore::::open_storage( + storage.clone(), + key.clone(), + &[3; 32], + crate::test_support::test_spawner(), + || { + let mut state = initial()?; + state.processed.push(u64::from(chunk_index)); + Ok(state) + }, + ) + .await + .unwrap(); + let released = Arc::downgrade(&store.gate.state); + drop(store); + assert!(released.upgrade().is_none()); + let restored = ChatStateStore::::open_storage( + storage.clone(), + key, + &[3; 32], + crate::test_support::test_spawner(), + || panic!("reclaiming a clean gate must not delete durable chunks"), + ) + .await + .unwrap(); + assert_eq!( + restored + .read(|state| state.processed.clone()) + .await + .unwrap(), + vec![u64::from(chunk_index)] + ); + drop(restored); + // This is the resource bound under review: neither strong gates + // nor dead weak/key entries may accumulate per completed chunk. + assert!(SLOTS.lock().get(&storage_id).unwrap().slots.is_empty()); + } + assert_eq!(storage.writes.load(Ordering::SeqCst), 64); + }); +} + +#[test] +fn ambiguous_initialization_requires_authenticated_bytes_never_absence() { + block_on(async { + let absent = Arc::new(DurableSlot::default()); + absent.fail_before.store(true, Ordering::SeqCst); + assert!(matches!( + ChatStateStore::::open_storage( + absent.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + initial, + ) + .await, + Err(ChatError::StorageUnavailable) + )); + assert!(matches!( + ChatStateStore::::open_storage( + absent.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("absence cannot disprove an earlier initialization attempt"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + assert_eq!(absent.writes.load(Ordering::SeqCst), 1); + + let accepted = Arc::new(DurableSlot::default()); + accepted.fail_after.store(true, Ordering::SeqCst); + assert!(matches!( + ChatStateStore::::open_storage( + accepted.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + initial, + ) + .await, + Err(ChatError::StorageUnavailable) + )); + let bytes = accepted.bytes(); + let recovered = ChatStateStore::::open_storage( + accepted.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("authenticated initialization already exists"), + ) + .await + .unwrap(); + assert_eq!( + recovered.read(snapshot).await.unwrap(), + snapshot(&initial().unwrap()) + ); + assert_eq!(accepted.bytes(), bytes); + assert_eq!(accepted.writes.load(Ordering::SeqCst), 2); + }); +} + +#[test] +fn wrong_owner_network_product_entropy_and_tampering_fail_closed() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + drop(open(storage.clone()).await); + let bytes = storage.bytes(); + for key in [ + CoreStorageKey::NativeChatDevice { + root_public_key: [4; 32], + genesis_hash: [2; 32], + product_id: "chat.test".into(), + }, + CoreStorageKey::NativeChatDevice { + root_public_key: [1; 32], + genesis_hash: [4; 32], + product_id: "chat.test".into(), + }, + CoreStorageKey::NativeChatDevice { + root_public_key: [1; 32], + genesis_hash: [2; 32], + product_id: "other.test".into(), + }, + ] { + storage.replace(&key, bytes.clone()); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + key, + &[3; 32], + crate::test_support::test_spawner(), + || panic!("wrong ownership is not an absent slot"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + } + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[4; 32], + crate::test_support::test_spawner(), + || panic!("wrong entropy is not an absent slot"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + for offset in [0, 4, 6, HEADER_BYTES, bytes.len() - 1] { + let mut tampered = bytes.clone(); + tampered[offset] ^= 1; + storage.replace(&slot(), tampered); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("tampering is not an absent slot"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + } + }); +} + +// Construct authenticated malicious plaintext, so decoder tests cannot pass +// merely because AEAD authentication rejected an invalid envelope first. +fn seal(plaintext: &[u8]) -> Vec { + let cipher = SnapshotCipher::new(&slot(), &[3; 32]).unwrap(); + let nonce = [5; 24]; + let mut ciphertext = Zeroizing::new(plaintext.to_vec()); + XChaCha20Poly1305::new((&*cipher.key).into()) + .encrypt_in_place(XNonce::from_slice(&nonce), &cipher.aad, &mut *ciphertext) + .unwrap(); + let mut envelope = MAGIC.to_vec(); + envelope.extend_from_slice(&VERSION.to_le_bytes()); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&ciphertext); + envelope +} + +#[derive(Clone, Encode, Decode)] +enum Nested { + End, + More(Box), +} + +#[test] +fn malformed_oversized_and_authenticated_decode_bombs_fail_closed() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let encoded = codec::encode(&initial().unwrap()).unwrap(); + let mut trailing = encoded.clone(); + trailing.push(0); + for bytes in [ + Vec::new(), + vec![0; HEADER_BYTES + TAG_BYTES - 1], + vec![0; HEADER_BYTES + MAX_SNAPSHOT_BYTES + TAG_BYTES + 1], + seal(&encoded[..encoded.len() - 1]), + seal(&trailing), + ] { + storage.replace(&slot(), bytes); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("malformed storage must not initialize a device"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + } + let mut nested = Nested::End; + for _ in 0..=MAX_DECODE_DEPTH { + nested = Nested::More(Box::new(nested)); + } + storage.replace(&slot(), seal(&nested.encode())); + assert!(matches!( + ChatStateStore::::open_storage( + storage.clone(), + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("depth overflow must not initialize state"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + + // SCALE maps account for their declared node allocation before decode. + // This valid small wire payload would otherwise decode to one entry; + // repeated keys do not evade the allocation budget of the input. + let mut oversized_map = Compact(6_000_000u32).encode(); + oversized_map.resize(oversized_map.len() + 12_000_000, 0); + storage.replace(&slot(), seal(&oversized_map)); + assert!(matches!( + ChatStateStore::>::open_storage( + storage, + slot(), + &[3; 32], + crate::test_support::test_spawner(), + || panic!("allocation overflow must not initialize state"), + ) + .await, + Err(ChatError::StorageUnavailable) + )); + }); +} + +#[test] +fn rejected_or_unrestorable_mutations_do_not_replace_committed_state() { + block_on(async { + let storage = Arc::new(DurableSlot::default()); + let store = open(storage.clone()).await; + let committed = store.read(snapshot).await.unwrap(); + let bytes = storage.bytes(); + assert_eq!( + store + .update(|state| { + change(state)?; + Err::<(), _>(ChatError::OperationConflict) + }) + .await, + Err(ChatError::OperationConflict) + ); + assert_eq!(store.read(snapshot).await.unwrap(), committed); + assert_eq!(storage.bytes(), bytes); + assert_eq!( + store + .update(|state| { + state.outbox.insert(1, vec![0; MAX_SNAPSHOT_BYTES]); + Ok(()) + }) + .await, + Err(ChatError::StorageUnavailable) + ); + assert_eq!(store.read(snapshot).await.unwrap(), committed); + assert_eq!(storage.bytes(), bytes); + assert_eq!(storage.writes.load(Ordering::SeqCst), 1); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 7f9d37373..9c60c5849 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -55,7 +55,7 @@ use crate::subscription::Spawner; use futures::StreamExt; use tracing::{instrument, warn}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; -use truapi::{CallContext, CallError, v01}; +use truapi::{CallContext, CallError, latest, v01}; use truapi_platform::{ CoreStorageKey, PairingHostConfig, Platform, ProductContext, SignVrfReview, UserConfirmationReview, normalize_product_identifier, @@ -2452,7 +2452,7 @@ impl PairingHost { cx: &CallContext, session: &AuthoritySession, request: ProductDeviceChatAuthorityRequest, - ) -> Result { + ) -> Result { let private_session = self .current_private_session(session) .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; @@ -2740,7 +2740,7 @@ impl ProductAuthority for PairingHost { cx: &CallContext, session: &AuthoritySession, request: ProductDeviceChatAuthorityRequest, - ) -> Result { + ) -> Result { PairingHost::product_device_chat(self, cx, session, request).await } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 8a89b0ef0..d1b97acb2 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -27,7 +27,7 @@ use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; use tracing::{debug, instrument, warn}; -use truapi::{CallContext, latest, v01}; +use truapi::{CallContext, latest}; /// Active peer-disconnect watcher for one SSO session; aborts on drop. pub(super) struct SsoDisconnectMonitor { @@ -495,104 +495,24 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, request: ProductDeviceChatAuthorityRequest, - ) -> Result { - let (calling_product_id, operation) = match request { - ProductDeviceChatAuthorityRequest::Bind { - calling_product_id, - derivation_index, - peer_identity_account_id, - peer_chat_public_key, - .. - } => ( - calling_product_id, - SsoProductDeviceChatOperation::Bind { - derivation_index, - peer_identity_account_id, - peer_chat_public_key, - }, - ), - ProductDeviceChatAuthorityRequest::Seal { - calling_product_id, - peer_chat_public_key, - cipher_suite, - plaintext, - } => ( - calling_product_id, - SsoProductDeviceChatOperation::Seal { - peer_chat_public_key, - cipher_suite, - plaintext, - }, - ), - ProductDeviceChatAuthorityRequest::Open { - calling_product_id, - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - } => ( - calling_product_id, - SsoProductDeviceChatOperation::Open { - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - }, - ), - ProductDeviceChatAuthorityRequest::SignRequestProof { - calling_product_id, - product_account_id, - payload, - } => ( - calling_product_id, - SsoProductDeviceChatOperation::SignRequestProof { - derivation_index: product_account_id.derivation_index, - payload, - }, - ), - ProductDeviceChatAuthorityRequest::Identity { calling_product_id } => { - (calling_product_id, SsoProductDeviceChatOperation::Identity) - } - ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - calling_product_id, - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - } => ( - calling_product_id, - SsoProductDeviceChatOperation::VerifyPeerDevice { - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - }, - ), - }; + ) -> Result { self.call( cx, session, ProductRequest { - calling_product_id, - payload: operation, + calling_product_id: request.calling_product_id, + payload: SsoProductDeviceChatOperation::V2(request.operation), }, ) .await + .map_err(|error| ProductDeviceChatAuthorityError::from(remote_authority_error(error)))? + .map(|response| { + let truapi::versioned::account::HostProductDeviceChatResponse::V1(response) = response; + response + }) .map_err(|error| { - ProductDeviceChatAuthorityError::Unavailable(remote_authority_error(error).to_string()) - })? - .map_err(|error| match error { - v01::HostProductDeviceChatError::NotConnected => { - ProductDeviceChatAuthorityError::Disconnected - } - v01::HostProductDeviceChatError::Rejected => ProductDeviceChatAuthorityError::Rejected, - v01::HostProductDeviceChatError::InvalidPeerKey => { - ProductDeviceChatAuthorityError::InvalidPeerKey - } - v01::HostProductDeviceChatError::InvalidCiphertext => { - ProductDeviceChatAuthorityError::InvalidCiphertext - } - v01::HostProductDeviceChatError::Unknown { reason } => { - ProductDeviceChatAuthorityError::Unavailable(reason) - } + let truapi::versioned::account::HostProductDeviceChatError::V1(error) = error; + ProductDeviceChatAuthorityError::Domain(error) }) } diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 9b5153e86..4d1741a1a 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -41,6 +41,8 @@ pub(crate) struct RuntimeServices { /// Host Pocket adapter, installed once at startup by a host with a Pocket /// surface. Unset leaves every product Pocket call `Unsupported`. pocket_platform: OnceLock>, + /// Optional native authenticated username index; only supplies candidates. + identity_backend: OnceLock>, /// Asset Hub the dotNS contracts are deployed on. All-zero says this host /// has none, which leaves every manifest unresolvable. asset_hub_chain_genesis_hash: [u8; 32], @@ -48,6 +50,8 @@ pub(crate) struct RuntimeServices { pub(crate) worker_ledger: WorkerLedger, /// Shared chainHead-v1 runtime behind the Chain surface. pub(crate) chain: ChainRuntime, + /// Configured People chain for native Chat identity and main-purse Coinage. + pub(crate) people_chain_genesis_hash: [u8; 32], /// People-chain statement store RPC client. pub(crate) statement_store: StatementStoreRpc, /// In-core Bulletin submission over the configured Bulletin chain. @@ -99,9 +103,11 @@ impl RuntimeServices { chat_platform: None, permission_status: OnceLock::new(), pocket_platform: OnceLock::new(), + identity_backend: OnceLock::new(), asset_hub_chain_genesis_hash, worker_ledger: WorkerLedger::default(), chain, + people_chain_genesis_hash, statement_store, bulletin, chain_context: crate::runtime::statement_allowance::ChainContextCache::default(), @@ -151,6 +157,19 @@ impl RuntimeServices { self.permission_status.set(host).is_ok() } + pub(crate) fn install_identity_backend_host( + &self, + host: Arc, + ) -> bool { + self.identity_backend.set(host).is_ok() + } + + pub(crate) fn identity_backend_host( + &self, + ) -> Option> { + self.identity_backend.get().cloned() + } + /// The Asset Hub dotNS reads run against, when one is configured. /// /// Taken by construction, so the chain a grant is adjudicated against diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c6f72adc2..4b3d933d0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -41,8 +41,9 @@ use super::authority::{ AuthorityError, AuthoritySession, AutoSigningGrant, BulletinAllowanceKey, CreateTransactionAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, authority_session_validation_id, execute_product_device_chat, + StatementStoreAllowanceKey, authority_session_validation_id, }; +use super::native_chat::{NativeChatContext, NativeChatRegistry}; use super::ring_vrf_registry::RingVrfRegistryStore; use super::{RuntimeServices, connected_session_ui_info, validate_vrf_transcript}; use crate::host_logic::entropy::derive_product_entropy; @@ -115,6 +116,7 @@ pub(crate) struct SigningHost { /// [`truapi_platform::SigningHostConfig::network_suffix`]. Every reserved /// RFC-0022 derivation (`uid.`, `peopl.`) ends in it. network_suffix: String, + coinage_instance_id: Option, session_state: Arc, auth_state: AuthStateMachine, ring_resolver: Arc, @@ -123,31 +125,45 @@ pub(crate) struct SigningHost { /// In-memory grants and the activation generation that owns them. The /// lifecycle mutex also makes session replacement and snapshot creation /// atomic with respect to generation changes. - local_grants: Mutex, + local_grants: Arc>, /// Durable RFC-0024 registry, scoped by the active wallet root. ring_vrf_registry: Arc, /// Serializes replay-ledger updates within each wallet and peer scope. sso_replay_locks: SsoReplayLocks, + /// Wallet/network engine and product-scoped Host-only transport devices. + native_chat: NativeChatRegistry, renewal: allowance_renewal::RenewalState, } +impl Drop for SigningHost { + fn drop(&mut self) { + self.clear_local_session(); + } +} + impl SigningHost { /// Build a signing host with no active session, serving the network whose /// dotNS TLD is `network_suffix`. - pub(crate) fn new(services: Arc, network_suffix: String) -> Arc { + pub(crate) fn new( + services: Arc, + network_suffix: String, + coinage_instance_id: Option, + ) -> Arc { let platform = services.platform.clone(); let ring_resolver = ChainRingResolver::new(services.chain.clone()); Arc::new(Self { services, platform: platform.clone(), network_suffix, + coinage_instance_id, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, root_entropy: Mutex::new(None), - local_grants: Mutex::new(LocalGrantState::default()), + local_grants: Arc::new(Mutex::new(LocalGrantState::default())), ring_vrf_registry: RingVrfRegistryStore::new(platform), sso_replay_locks: SsoReplayLocks::default(), + native_chat: NativeChatRegistry::default(), renewal: allowance_renewal::RenewalState::default(), }) } @@ -190,13 +206,15 @@ impl SigningHost { services, platform: platform.clone(), network_suffix: network_suffix.to_string(), + coinage_instance_id: None, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, root_entropy: Mutex::new(None), - local_grants: Mutex::new(LocalGrantState::default()), + local_grants: Arc::new(Mutex::new(LocalGrantState::default())), ring_vrf_registry: RingVrfRegistryStore::new(platform), sso_replay_locks: SsoReplayLocks::default(), + native_chat: NativeChatRegistry::default(), renewal: allowance_renewal::RenewalState::default(), }) } @@ -298,7 +316,7 @@ impl SigningHost { /// Fence in-flight grant work and revoke this product's grants from the /// current local activation while preserving unrelated products. - pub(crate) fn clear_product_state(&self, product_id: &str) -> Result<(), AuthorityError> { + pub(crate) async fn clear_product_state(&self, product_id: &str) -> Result<(), AuthorityError> { let product_id = normalize_product_identifier(product_id).map_err(|error| { AuthorityError::Unavailable { reason: error.to_string(), @@ -308,6 +326,19 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned") .revoke_product(&product_id); + if let Some(session) = self.current_local_session() { + let context = self.native_chat_context(&session).map_err(|error| { + AuthorityError::Unavailable { + reason: format!("native Chat product state unavailable: {error:?}"), + } + })?; + let forgotten = self.native_chat.forget_product(&context, &product_id).await; + // The owned forget task rebinds unrelated receivers on every outcome, + // including cancellation of this administration call. + forgotten.map_err(|_| AuthorityError::Unavailable { + reason: "native Chat product state could not be cleared".into(), + })?; + } Ok(()) } @@ -362,6 +393,13 @@ impl SigningHost { .lock() .expect("signing host entropy mutex poisoned") = Some(secret); self.session_state.set_session(session); + drop(state); + self.native_chat.release(); + if let Some(session) = self.current_local_session() { + if let Ok(context) = self.native_chat_context(&session) { + self.native_chat.resume_receiving(context); + } + } } fn clear_local_session(&self) { @@ -375,6 +413,8 @@ impl SigningHost { .expect("signing host entropy mutex poisoned") .take(); self.session_state.clear_session(); + drop(state); + self.native_chat.release(); } fn current_local_session(&self) -> Option { @@ -409,6 +449,35 @@ impl SigningHost { Ok((current, state.activation_generation)) } + fn native_chat_context( + &self, + session: &AuthoritySession, + ) -> Result { + self.require_current_session(session)?; + let entropy = self.root_entropy()?; + self.require_current_session(session)?; + let session_state = self.session_state.clone(); + let local_grants = self.local_grants.clone(); + let validation_id = session.validation_id.clone(); + let session_valid = Arc::new(move || { + let grants = local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + session_state.current().is_some_and(|current| { + local_session_validation_id(¤t, grants.activation_generation) == validation_id + }) + }); + Ok(NativeChatContext { + services: self.services.clone(), + session: session.clone(), + entropy, + session_valid, + network_suffix: self.network_suffix.clone(), + genesis_hash: self.services.people_chain_genesis_hash, + coinage_instance_id: self.coinage_instance_id, + }) + } + fn ring_vrf_entropy( &self, session: &AuthoritySession, @@ -1137,43 +1206,20 @@ impl ProductAuthority for SigningHost { _cx: &CallContext, session: &AuthoritySession, request: ProductDeviceChatAuthorityRequest, - ) -> Result { - self.require_current_session(session) - .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; - let request = match request { - ProductDeviceChatAuthorityRequest::SignRequestProof { - calling_product_id, - product_account_id, - payload, - } => { - if product_account_id.dot_ns_identifier != calling_product_id { - return Err(ProductDeviceChatAuthorityError::Unavailable( - "product account does not belong to the calling product".to_string(), - )); - } - let keypair = self.product_keypair(&product_account_id).map_err(|error| { - ProductDeviceChatAuthorityError::Unavailable(error.to_string()) - })?; - let signature = keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) - .to_bytes(); - return Ok(v01::HostProductDeviceChatResponse::RequestProofSigned { signature }); - } - request => request, - }; - let entropy = self - .root_entropy() - .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; - let (identity, identity_chat_private_key) = - sso_responder::derive_responder_identity(&entropy, self.network_suffix()) - .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; - let identity_chat_private_key = Zeroizing::new(identity_chat_private_key); - execute_product_device_chat( - &identity_chat_private_key, - identity.statement_public_key, - request, - ) + ) -> Result + { + self.require_current_session(session)?; + let calling_product_id = normalize_product_identifier(&request.calling_product_id) + .map_err(|_| { + ProductDeviceChatAuthorityError::Domain( + truapi::latest::HostProductDeviceChatError::InvalidRequest, + ) + })?; + let context = self.native_chat_context(session)?; + self.native_chat + .execute(context, calling_product_id, request.operation) + .await + .map_err(ProductDeviceChatAuthorityError::Domain) } async fn allocate_resources( @@ -1360,6 +1406,7 @@ mod tests { use super::super::authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; @@ -1510,7 +1557,11 @@ mod tests { config.asset_hub_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); + let signing_host = SigningHostRole::new( + services.clone(), + config.network_suffix, + config.coinage_instance_id, + ); (services, signing_host) } @@ -3449,6 +3500,78 @@ mod tests { assert_eq!(reviews[0].calling_product_id, "other.dot"); } + #[test] + fn lock_and_wallet_replacement_release_native_custody_and_fence_old_authority() { + futures::executor::block_on(async { + use truapi::latest::{HostProductDeviceChatError, HostProductDeviceChatRequest}; + let (_services, authority) = signing_runtime(); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let session = authority.current_local_session().unwrap(); + let context = authority.native_chat_context(&session).unwrap(); + let first = authority + .native_chat + .execute( + context.clone(), + "chat.dot".into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + let wallet = authority.native_chat.wallet(&context).await.unwrap(); + let lifetime = Arc::downgrade(&wallet); + drop(wallet); + authority.clear_local_session(); + assert!(lifetime.upgrade().is_none()); + assert_eq!( + context.require_current(), + Err(HostProductDeviceChatError::NotConnected) + ); + assert_eq!( + authority + .native_chat + .execute( + context, + "chat.dot".into(), + HostProductDeviceChatRequest::Initialize, + ) + .await, + Err(HostProductDeviceChatError::NotConnected) + ); + + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let session = authority.current_local_session().unwrap(); + let context = authority.native_chat_context(&session).unwrap(); + let restored = authority + .native_chat + .execute( + context.clone(), + "chat.dot".into(), + HostProductDeviceChatRequest::Initialize, + ) + .await + .unwrap(); + assert_eq!(restored.device, first.device); + let wallet = authority.native_chat.wallet(&context).await.unwrap(); + let lifetime = Arc::downgrade(&wallet); + drop(wallet); + authority + .activate_local_session(vec![0xCD; 16]) + .await + .unwrap(); + assert!(lifetime.upgrade().is_none()); + assert_eq!( + context.require_current(), + Err(HostProductDeviceChatError::NotConnected) + ); + }); + } + #[test] fn product_clear_revokes_only_current_activation_grant_and_fences_stale_work() { let platform = Arc::new(StubPlatform::default()); @@ -3463,8 +3586,7 @@ mod tests { .grant_auto_signing(&stale_session, "other.dot") .expect("other product grant succeeds"); - authority - .clear_product_state("myapp.dot") + futures::executor::block_on(authority.clear_product_state("myapp.dot")) .expect("product clear succeeds"); let current_session = authority.current_session().expect("session remains active"); @@ -4081,58 +4203,46 @@ mod tests { } #[test] - fn product_chat_request_proof_signs_unframed_payload() { - let (services, activation) = signing_runtime_with_platform(Arc::new(StubPlatform { - chat_authority_confirmed: true, - ..StubPlatform::default() - })); - futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - let runtime = product_runtime(services, activation); - let cx = CallContext::default(); - let payload = b"canonical chat request proof".to_vec(); - let request = truapi::versioned::account::HostProductDeviceChatRequest::V1( - v01::HostProductDeviceChatRequest::SignRequestProof { - product_account_id: v01::ProductAccountId { - dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - payload: payload.clone(), - }, - ); - let response = futures::executor::block_on(runtime.product_device_chat(&cx, request)) - .expect("Chat request proof signing succeeds"); - let truapi::versioned::account::HostProductDeviceChatResponse::V1( - v01::HostProductDeviceChatResponse::RequestProofSigned { signature }, - ) = response - else { - panic!("unexpected Chat response"); - }; - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); - let signature = schnorrkel::Signature::from_bytes(&signature).expect("64-byte signature"); - assert!( - keypair - .public - .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) - .is_ok(), - "signature verifies over the canonical unframed payload", - ); - assert!( - keypair - .public - .verify_simple( - SR25519_SIGNING_CONTEXT, - b"canonical chat request proof", - &signature, - ) - .is_err(), - "Chat proof signing never applies wallet-message framing", - ); + fn chat_context_rejects_logout_and_same_wallet_reactivation() { + futures::executor::block_on(async { + let (_, authority) = signing_runtime(); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let session = authority.current_session().unwrap(); + let context = authority.native_chat_context(&session).unwrap(); + assert!((context.session_valid)()); + authority.disconnect().await; + assert!(!(context.session_valid)()); + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + assert!(!(context.session_valid)()); + assert!(matches!( + authority + .product_device_chat( + &CallContext::default(), + &session, + ProductDeviceChatAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + operation: truapi::latest::HostProductDeviceChatRequest::Initialize, + }, + ) + .await, + Err(ProductDeviceChatAuthorityError::Disconnected) + )); + let current = authority.current_session().unwrap(); + assert!((authority + .native_chat_context(¤t) + .unwrap() + .session_valid)()); + }); } #[test] - fn product_chat_username_grant_does_not_authorize_crypto() { + fn product_chat_username_grant_does_not_authorize_chat() { futures::executor::block_on(async { let platform = Arc::new(StubPlatform::default()); let (services, activation) = signing_runtime_with_platform(platform.clone()); @@ -4149,21 +4259,15 @@ mod tests { .await .unwrap(); let cx = CallContext::default(); - let request = - HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { - product_account_id: product_account(0), - peer_identity_account_id: [0x33; 32], - peer_chat_public_key: x25519_dalek::PublicKey::from( - &x25519_dalek::StaticSecret::from([0x22; 32]), - ) - .to_bytes(), - }); + let request = HostProductDeviceChatRequest::V1( + truapi::latest::HostProductDeviceChatRequest::Initialize, + ); for _ in 0..2 { assert!(matches!( runtime.product_device_chat(&cx, request.clone()).await, Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Rejected + truapi::latest::HostProductDeviceChatError::AccessNotGranted ))) )); } @@ -4192,7 +4296,7 @@ mod tests { } #[test] - fn product_chat_consent_is_cached_and_revocation_blocks_crypto() { + fn product_chat_consent_is_cached_and_revocation_blocks_chat() { futures::executor::block_on(async { let platform = Arc::new(StubPlatform { chat_authority_confirmed: true, @@ -4204,13 +4308,21 @@ mod tests { .await .unwrap(); let runtime = product_runtime(services, activation); - runtime - .set_permission_authorization_status( - PermissionAuthorizationRequest::IdentityDisclosure, - PermissionAuthorizationStatus::Authorized, - ) + let cx = CallContext::default(); + let initialize = HostProductDeviceChatRequest::V1( + truapi::latest::HostProductDeviceChatRequest::Initialize, + ); + let HostProductDeviceChatResponse::V1(first) = runtime + .product_device_chat(&cx, initialize.clone()) .await .unwrap(); + let HostProductDeviceChatResponse::V1(second) = runtime + .product_device_chat(&cx, initialize.clone()) + .await + .unwrap(); + assert_eq!(first.device, second.device); + assert_eq!(first.device.product_account.dot_ns_identifier, "myapp.dot"); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); runtime .set_permission_authorization_status( PermissionAuthorizationRequest::ChatAuthority, @@ -4218,92 +4330,53 @@ mod tests { ) .await .unwrap(); - let cx = CallContext::default(); - let peer_chat_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) - .to_bytes(); - let bind = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { - product_account_id: product_account(0), - peer_identity_account_id: [0x33; 32], - peer_chat_public_key, - }); assert!(matches!( - runtime.product_device_chat(&cx, bind.clone()).await, + runtime.product_device_chat(&cx, initialize).await, Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Rejected + truapi::latest::HostProductDeviceChatError::AccessNotGranted ))) )); - assert!(platform.chat_authority_reviews.lock().is_empty()); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + }); + } - runtime - .set_permission_authorization_status( - PermissionAuthorizationRequest::ChatAuthority, - PermissionAuthorizationStatus::NotDetermined, - ) - .await - .unwrap(); - assert!(matches!( - runtime - .product_device_chat(&cx, bind.clone()) - .await - .unwrap(), - HostProductDeviceChatResponse::V1( - v01::HostProductDeviceChatResponse::IdentityBinding { .. } - ) - )); - let plaintext = b"Chat consent protects private messages".to_vec(); - let seal = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Seal { - product_account_id: product_account(0), - peer_chat_public_key, - cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, - plaintext: plaintext.clone(), + #[test] + fn product_chat_authority_does_not_grant_statement_delivery() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() }); - let HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Sealed { - combined_ciphertext, - }) = runtime - .product_device_chat(&cx, seal.clone()) + let (services, activation) = signing_runtime_with_platform(platform); + activation + .activate_local_session(ENTROPY.to_vec()) .await - .unwrap() - else { - panic!("expected sealed Chat message"); - }; - let open = HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Open { - product_account_id: product_account(0), - peer_chat_public_key, - cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, - combined_ciphertext, - }); - assert_eq!( - runtime - .product_device_chat(&cx, open.clone()) - .await - .unwrap(), - HostProductDeviceChatResponse::V1(v01::HostProductDeviceChatResponse::Opened { - plaintext - }) - ); - assert_eq!(platform.chat_authority_reviews.lock().len(), 1); - + .unwrap(); + let runtime = product_runtime(services, activation); runtime .set_permission_authorization_status( - PermissionAuthorizationRequest::ChatAuthority, + PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::StatementSubmit, + }), PermissionAuthorizationStatus::Denied, ) .await .unwrap(); - for request in [bind, seal, open] { - assert!(matches!( - runtime.product_device_chat(&cx, request).await, - Err(CallError::Domain(HostProductDeviceChatError::V1( - v01::HostProductDeviceChatError::Rejected - ))) - )); - } - assert_eq!( - platform.chat_authority_reviews.lock().len(), - 1, - "revocation must not be overridden by another prompt" + let request = HostProductDeviceChatRequest::V1( + truapi::latest::HostProductDeviceChatRequest::SendPayment { + peer_identity: [0x55; 32], + request_id: "not-authorized".to_string(), + amount_cents: 10, + }, ); + assert!(matches!( + runtime + .product_device_chat(&CallContext::default(), request) + .await, + Err(CallError::Domain(HostProductDeviceChatError::V1( + truapi::latest::HostProductDeviceChatError::AccessNotGranted + ))) + )); }); } @@ -4331,7 +4404,7 @@ mod tests { } #[test] - fn sso_chat_username_grant_does_not_authorize_crypto() { + fn sso_chat_username_grant_does_not_authorize_chat() { futures::executor::block_on(async { let platform = Arc::new(StubPlatform::default()); let (_, activation) = signing_runtime_with_platform(platform.clone()); @@ -4349,19 +4422,16 @@ mod tests { ) .await .unwrap(); - let request = SsoProductDeviceChatOperation::Bind { - derivation_index: v01::DerivationIndex::Index(0), - peer_identity_account_id: [0x33; 32], - peer_chat_public_key: x25519_dalek::PublicKey::from( - &x25519_dalek::StaticSecret::from([0x22; 32]), - ) - .to_bytes(), - }; + let request = SsoProductDeviceChatOperation::V2( + truapi::latest::HostProductDeviceChatRequest::Initialize, + ); for _ in 0..2 { assert_eq!( sso_chat(&service, request.clone()).await, - Err(v01::HostProductDeviceChatError::Rejected) + Err(HostProductDeviceChatError::V1( + truapi::latest::HostProductDeviceChatError::AccessNotGranted, + )) ); } assert_eq!( @@ -4387,7 +4457,7 @@ mod tests { } #[test] - fn sso_chat_consent_is_cached_and_revocation_blocks_crypto() { + fn sso_chat_consent_is_cached_and_revocation_blocks_chat() { futures::executor::block_on(async { let platform = Arc::new(StubPlatform { chat_authority_confirmed: true, @@ -4401,13 +4471,16 @@ mod tests { let service = super::sso_service::SigningHostSsoService::new(activation); let permissions = PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); - permissions - .set_authorization_status( - &PermissionAuthorizationRequest::IdentityDisclosure, - PermissionAuthorizationStatus::Authorized, - ) - .await - .unwrap(); + let initialize = SsoProductDeviceChatOperation::V2( + truapi::latest::HostProductDeviceChatRequest::Initialize, + ); + let HostProductDeviceChatResponse::V1(first) = + sso_chat(&service, initialize.clone()).await.unwrap(); + let HostProductDeviceChatResponse::V1(second) = + sso_chat(&service, initialize.clone()).await.unwrap(); + assert_eq!(first.device, second.device); + assert_eq!(first.device.product_account.dot_ns_identifier, "myapp.dot"); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); permissions .set_authorization_status( &PermissionAuthorizationRequest::ChatAuthority, @@ -4415,71 +4488,52 @@ mod tests { ) .await .unwrap(); - let peer_chat_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])) - .to_bytes(); - let bind = SsoProductDeviceChatOperation::Bind { - derivation_index: v01::DerivationIndex::Index(0), - peer_identity_account_id: [0x33; 32], - peer_chat_public_key, - }; assert_eq!( - sso_chat(&service, bind.clone()).await, - Err(v01::HostProductDeviceChatError::Rejected) + sso_chat(&service, initialize).await, + Err(HostProductDeviceChatError::V1( + truapi::latest::HostProductDeviceChatError::AccessNotGranted, + )) ); - assert!(platform.chat_authority_reviews.lock().is_empty()); + assert_eq!(platform.chat_authority_reviews.lock().len(), 1); + }); + } - permissions - .set_authorization_status( - &PermissionAuthorizationRequest::ChatAuthority, - PermissionAuthorizationStatus::NotDetermined, - ) + #[test] + fn sso_chat_authority_does_not_grant_statement_delivery() { + futures::executor::block_on(async { + let platform = Arc::new(StubPlatform { + chat_authority_confirmed: true, + ..StubPlatform::default() + }); + let (_, activation) = signing_runtime_with_platform(platform.clone()); + activation + .activate_local_session(ENTROPY.to_vec()) .await .unwrap(); - assert!(matches!( - sso_chat(&service, bind.clone()).await.unwrap(), - v01::HostProductDeviceChatResponse::IdentityBinding { .. } - )); - let plaintext = b"SSO Chat consent protects private messages".to_vec(); - let seal = SsoProductDeviceChatOperation::Seal { - peer_chat_public_key, - cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, - plaintext: plaintext.clone(), - }; - let v01::HostProductDeviceChatResponse::Sealed { - combined_ciphertext, - } = sso_chat(&service, seal.clone()).await.unwrap() - else { - panic!("expected sealed SSO Chat message"); - }; - let open = SsoProductDeviceChatOperation::Open { - peer_chat_public_key, - cipher_suite: v01::HostProductDeviceChatCipherSuite::LegacyV2, - combined_ciphertext, - }; - assert_eq!( - sso_chat(&service, open.clone()).await.unwrap(), - v01::HostProductDeviceChatResponse::Opened { plaintext } - ); - assert_eq!(platform.chat_authority_reviews.lock().len(), 1); - + let service = super::sso_service::SigningHostSsoService::new(activation); + let permissions = + PermissionsService::new(platform.as_ref(), platform.as_ref(), "myapp.dot"); permissions .set_authorization_status( - &PermissionAuthorizationRequest::ChatAuthority, + &PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::StatementSubmit, + }), PermissionAuthorizationStatus::Denied, ) .await .unwrap(); - for request in [bind, seal, open] { - assert_eq!( - sso_chat(&service, request).await, - Err(v01::HostProductDeviceChatError::Rejected) - ); - } + let request = SsoProductDeviceChatOperation::V2( + truapi::latest::HostProductDeviceChatRequest::SendPayment { + peer_identity: [0x55; 32], + request_id: "not-authorized".to_string(), + amount_cents: 10, + }, + ); assert_eq!( - platform.chat_authority_reviews.lock().len(), - 1, - "revocation must not be overridden by another SSO prompt" + sso_chat(&service, request).await, + Err(HostProductDeviceChatError::V1( + truapi::latest::HostProductDeviceChatError::AccessNotGranted, + )) ); }); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index a99c11fca..1555687ef 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -21,7 +21,6 @@ use truapi::v01; use super::sso_replay::{ReplayExecution, SsoReplayScope, execute_once}; use super::{SigningHost, SigningHostSsoService}; -#[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; use crate::host_logic::product_account::derive_sr25519_hard_path; @@ -56,7 +55,6 @@ use crate::runtime::statement_store_rpc::StatementStoreRpcClientError; const SSO_ENCRYPTION_DOMAIN: &[u8] = b"sso"; /// Leave the product runtime one minute to receive and process the SSO response /// before its 300-second remote-authority deadline expires. -#[cfg(not(target_arch = "wasm32"))] const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(240); /// Upper bound on undecodable request ids acknowledged within one serve loop. @@ -170,7 +168,6 @@ pub(super) enum AllowanceAllocationError { #[error("{0}")] StatementStoreRpcClient(#[from] StatementStoreRpcClientError), /// Runtime service could not open the required Bulletin RPC client. - #[cfg(not(target_arch = "wasm32"))] #[error("{context}: {source}")] ChainRpcClient { /// Client context, naming which chain failed. @@ -201,6 +198,9 @@ impl AllowanceAllocationError { pub(super) fn into_authority_error(self) -> AuthorityError { match self { Self::Authority(err) => err, + Self::StatementAllowance(StatementAllowanceError::SessionInvalidated) => { + AuthorityError::Disconnected + } other => AuthorityError::Unavailable { reason: other.to_string(), }, @@ -275,7 +275,8 @@ async fn establish_pairing_session( services .statement_store .submit(statement, "sso-responder handshake") - .await?; + .await + .map_err(|error| error.to_string())?; debug!("answered pairing handshake"); Ok(EstablishedPairing { @@ -758,7 +759,6 @@ async fn register_statement_store_target( Ok(()) } -#[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_bulletin_allowance( services: &RuntimeServices, signing_host: &SigningHost, @@ -827,16 +827,19 @@ pub(super) async fn allocate_bulletin_allowance( period_duration, )?; signing_host.require_current_session(session)?; - let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { - rpc: people_rpc, - metadata: &chain.metadata, - chain_state: &chain.state, - entropy: membership.entropy, - network_suffix: &network_suffix, - target: &target, - period, - ring: &membership.ring, - }) + let outcome = claim_long_term_storage( + statement_allowance::LongTermStorageClaim { + rpc: people_rpc, + metadata: &chain.metadata, + chain_state: &chain.state, + entropy: membership.entropy, + network_suffix: &network_suffix, + target: &target, + period, + ring: &membership.ring, + }, + || signing_host.require_current_session(session).is_ok(), + ) .await?; let statement_allowance::LongTermStorageOutcome::Claimed { block_hash, @@ -989,19 +992,6 @@ pub(super) async fn allocate_smart_contract_allowance( Err(AllowanceAllocationError::NativeOnly { resource: "PGAS" }) } -#[cfg(target_arch = "wasm32")] -pub(super) async fn allocate_bulletin_allowance( - _services: &RuntimeServices, - _signing_host: &SigningHost, - _session: &AuthoritySession, - _product_id: &str, - _policy: OnExistingAllowancePolicy, -) -> Result, AllowanceAllocationError> { - Err(AllowanceAllocationError::NativeOnly { - resource: "Bulletin", - }) -} - #[cfg(not(target_arch = "wasm32"))] pub(super) fn current_unix_secs() -> Result { std::time::SystemTime::now() @@ -1090,7 +1080,11 @@ mod tests { config.asset_hub_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHost::new(services.clone(), config.network_suffix); + let signing_host = SigningHost::new( + services.clone(), + config.network_suffix, + config.coinage_instance_id, + ); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); (services, signing_host) diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 759c7c604..9d44782e2 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use tracing::warn; -use truapi::{latest as api, v01}; +use truapi::latest as api; use truapi_platform::{ CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, SignPayloadReview, SignRawReview, StatementStoreProductSignReview, UserConfirmationReview, @@ -36,8 +36,8 @@ use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::validate_unsigned_statement_signing_payload; use crate::runtime::authority::{ AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, + ProductDeviceChatAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + chat_requires_statement_submit, }; use crate::runtime::sso_service::{SsoReply, SsoRequestContext}; @@ -570,16 +570,22 @@ impl SigningHostSsoService { .map_err(|error| error.to_string()) } - /// Perform a Chat identity operation without exposing wallet key material. + /// Execute the same typed Chat operations as local products; never return secrets. async fn product_device_chat( &self, cx: &SsoRequestContext, request: ProductRequest, ) -> ProductDeviceChatResponse { + use truapi::versioned::account::{ + HostProductDeviceChatError as WireError, HostProductDeviceChatResponse as WireResponse, + }; + + self.signing_host + .require_current_session(&cx.session) + .map_err(|_| WireError::V1(api::HostProductDeviceChatError::NotConnected))?; let calling_product_id = normalize_product_identifier(&request.calling_product_id) - .map_err(|_| v01::HostProductDeviceChatError::Unknown { - reason: "invalid calling product identifier".to_string(), - })?; + .map_err(|_| WireError::V1(api::HostProductDeviceChatError::InvalidRequest))?; + let SsoProductDeviceChatOperation::V2(operation) = request.payload; let permissions = PermissionsService::new( self.signing_host.platform.as_ref(), self.signing_host.platform.as_ref(), @@ -588,107 +594,38 @@ impl SigningHostSsoService { if permissions .check_or_prompt_chat_authority() .await - .map_err(|error| v01::HostProductDeviceChatError::Unknown { - reason: error.reason, - })? + .map_err(|_| WireError::V1(api::HostProductDeviceChatError::StorageUnavailable))? != PermissionAuthorizationStatus::Authorized { - return Err(v01::HostProductDeviceChatError::Rejected); + return Err(WireError::V1( + api::HostProductDeviceChatError::AccessNotGranted, + )); } - - let authority_request = match request.payload { - SsoProductDeviceChatOperation::Bind { - derivation_index, - peer_identity_account_id, - peer_chat_public_key, - } => { - let product_account = api::ProductAccountId { - dot_ns_identifier: calling_product_id.clone(), - derivation_index: derivation_index.clone(), - }; - let device_account_id = self - .signing_host - .product_keypair(&product_account) - .map_err(|error| v01::HostProductDeviceChatError::Unknown { - reason: error.to_string(), - })? - .public - .to_bytes(); - ProductDeviceChatAuthorityRequest::Bind { + if chat_requires_statement_submit(&operation) + && permissions + .check_or_prompt_remote(api::RemotePermissionRequest { + permission: api::RemotePermission::StatementSubmit, + }) + .await + .map_err(|_| WireError::V1(api::HostProductDeviceChatError::StorageUnavailable))? + != PermissionAuthorizationStatus::Authorized + { + return Err(WireError::V1( + api::HostProductDeviceChatError::AccessNotGranted, + )); + } + self.signing_host + .product_device_chat( + &cx.call, + &cx.session, + ProductDeviceChatAuthorityRequest { calling_product_id, - device_account_id, - derivation_index, - peer_identity_account_id, - peer_chat_public_key, - } - } - SsoProductDeviceChatOperation::Seal { - peer_chat_public_key, - cipher_suite, - plaintext, - } => ProductDeviceChatAuthorityRequest::Seal { - calling_product_id, - peer_chat_public_key, - cipher_suite, - plaintext, - }, - SsoProductDeviceChatOperation::Open { - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - } => ProductDeviceChatAuthorityRequest::Open { - calling_product_id, - peer_chat_public_key, - cipher_suite, - combined_ciphertext, - }, - SsoProductDeviceChatOperation::SignRequestProof { - derivation_index, - payload, - } => ProductDeviceChatAuthorityRequest::SignRequestProof { - product_account_id: api::ProductAccountId { - dot_ns_identifier: calling_product_id.clone(), - derivation_index, + operation, }, - calling_product_id, - payload, - }, - SsoProductDeviceChatOperation::Identity => { - ProductDeviceChatAuthorityRequest::Identity { calling_product_id } - } - SsoProductDeviceChatOperation::VerifyPeerDevice { - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - } => ProductDeviceChatAuthorityRequest::VerifyPeerDevice { - calling_product_id, - peer_identity_account_id, - peer_chat_public_key, - peer_device_account_id, - proof, - }, - }; - self.signing_host - .product_device_chat(&cx.call, &cx.session, authority_request) + ) .await - .map_err(|error| match error { - ProductDeviceChatAuthorityError::Disconnected => { - v01::HostProductDeviceChatError::NotConnected - } - ProductDeviceChatAuthorityError::Rejected => { - v01::HostProductDeviceChatError::Rejected - } - ProductDeviceChatAuthorityError::InvalidPeerKey => { - v01::HostProductDeviceChatError::InvalidPeerKey - } - ProductDeviceChatAuthorityError::InvalidCiphertext => { - v01::HostProductDeviceChatError::InvalidCiphertext - } - ProductDeviceChatAuthorityError::Unavailable(reason) => { - v01::HostProductDeviceChatError::Unknown { reason } - } - }) + .map(WireResponse::V1) + .map_err(|error| WireError::V1(error.into())) } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 08adbf493..e73c73d51 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -76,6 +76,9 @@ pub enum StatementAllowanceError { /// Bulletin allowance polling timed out. #[error("timed out waiting for Bulletin authorization")] BulletinAuthorizationTimeout, + /// The authority session changed while a long-term-storage claim was pending. + #[error("allowance claim session is no longer active")] + SessionInvalidated, } /// Error while decoding generic chain state used by allowance registration. @@ -1031,8 +1034,12 @@ pub async fn register_statement_account_pooled( /// Claim long-term Bulletin storage authorization for `target`, proving /// membership in the already-located `ring`, at People-chain `period`. +/// +/// Recheck the authority session after chain reads and immediately before proof +/// construction and submission, including every duplicate-counter retry. pub async fn claim_long_term_storage( params: LongTermStorageClaim<'_>, + session_is_current: impl Fn() -> bool, ) -> Result { let LongTermStorageClaim { rpc, @@ -1044,6 +1051,14 @@ pub async fn claim_long_term_storage( period, ring, } = params; + let require_current_session = || { + if session_is_current() { + Ok(()) + } else { + Err(StatementAllowanceError::SessionInvalidated) + } + }; + require_current_session()?; let revision = ring::read_ring_revision( rpc, metadata, @@ -1054,6 +1069,7 @@ pub async fn claim_long_term_storage( .await?; let mut skipped_duplicate_counters = Vec::new(); loop { + require_current_session()?; let counter = slot::scan_long_term_storage_counter_excluding( rpc, metadata, @@ -1069,6 +1085,7 @@ pub async fn claim_long_term_storage( extrinsic::build_claim_long_term_storage_call(metadata, period, counter, target)?; let message = extension::build_proof_message(metadata, &call, chain_state)?; let domain = proof::domain_for_ring_exponent(ring.exponent)?; + require_current_session()?; let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; let as_resources_extra = extrinsic::build_long_term_storage_extra( metadata, @@ -1087,6 +1104,7 @@ pub async fn claim_long_term_storage( "submitting Bulletin long-term-storage claim" ); + require_current_session()?; match rpc.submit_and_watch(&extrinsic).await { Ok(block_hash) => { return Ok(LongTermStorageOutcome::Claimed { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 599f75981..e815b1e65 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -207,10 +207,10 @@ pub struct Metadata { } #[derive(Debug, Clone, Copy)] -pub(super) struct ViewFunctionDef { - pub(super) id: [u8; 32], - pub(super) inputs: usize, - pub(super) output_type: u32, +pub(crate) struct ViewFunctionDef { + pub(crate) id: [u8; 32], + pub(crate) inputs: usize, + pub(crate) output_type: u32, } /// The transaction-extension version to encode with: the highest the runtime @@ -435,7 +435,7 @@ impl Metadata { .map(Vec::as_slice) } - pub(super) fn view_function(&self, pallet: &str, function: &str) -> Option { + pub(crate) fn view_function(&self, pallet: &str, function: &str) -> Option { self.view_functions .get(&(pallet.to_string(), function.to_string())) .copied() diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs index 071dff062..bec2188de 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs @@ -197,7 +197,7 @@ async fn execute_no_args( decode_response(pallet, function, response) } -fn decode_response( +pub(crate) fn decode_response( pallet: &'static str, function: &'static str, response: Value, diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 4baa005ce..2cc3b787f 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -496,7 +496,7 @@ mod tests { [0xcc; 32], test_spawner(), ); - let signing_host = SigningHostRole::new(services.clone(), "paseo".to_string()); + let signing_host = SigningHostRole::new(services.clone(), "paseo".to_string(), None); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index e010913a3..9d70d93ac 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -94,8 +94,11 @@ impl StatementStoreRpc { &self, statement: Vec, label: &'static str, - ) -> Result<(), String> { - let rpc_client = self.client(label).await.map_err(|err| err.to_string())?; + ) -> Result<(), StatementSubmitError> { + let rpc_client = self + .client(label) + .await + .map_err(|err| StatementSubmitError::Rpc(err.to_string()))?; submit(&rpc_client, statement).await } @@ -161,23 +164,57 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } +#[derive(Debug, Error)] +pub(super) enum StatementSubmitError { + #[error("{0}")] + Rpc(String), + #[error("statement_submit not accepted: {0}")] + Rejected(Value), +} + +impl StatementSubmitError { + pub(super) fn is_no_allowance(&self) -> bool { + matches!(self, Self::Rejected(result) + if result["status"] == "rejected" && result["reason"] == "noAllowance") + } + + /// The store's inclusive priority floor; advance strictly past it. + pub(super) fn replacement_expiry(&self) -> Option { + let Self::Rejected(result) = self else { + return None; + }; + if result["status"] != "rejected" + || !matches!( + result["reason"].as_str(), + Some("accountFull" | "channelPriorityTooLow") + ) + { + return None; + } + result["min_expiry"].as_u64()?.checked_add(1) + } +} + /// Submit a SCALE-encoded statement and confirm the store accepted it. /// /// `statement_submit` returns an RPC error only for internal failures; a /// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as /// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof /// rejections surface instead of being silently dropped. -pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { +pub(super) async fn submit( + rpc_client: &RpcClient, + statement: Vec, +) -> Result<(), StatementSubmitError> { let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map_err(rpc_error_message)?; + .map_err(|error| StatementSubmitError::Rpc(rpc_error_message(error)))?; match result.get("status").and_then(Value::as_str) { Some("new") | Some("known") => Ok(()), - _ => Err(format!("statement_submit not accepted: {result}")), + _ => Err(StatementSubmitError::Rejected(result)), } } @@ -190,8 +227,7 @@ pub(super) async fn submit_sso( match submit(rpc_client, statement.clone()).await { Ok(()) => return Ok(()), Err(reason) - if is_transient_no_allowance(&reason) - && attempt < SSO_NO_ALLOWANCE_RETRY_ATTEMPTS => + if reason.is_no_allowance() && attempt < SSO_NO_ALLOWANCE_RETRY_ATTEMPTS => { warn!( label, @@ -201,16 +237,12 @@ pub(super) async fn submit_sso( ); futures_timer::Delay::new(SSO_NO_ALLOWANCE_RETRY_DELAY).await; } - Err(reason) => return Err(reason), + Err(reason) => return Err(reason.to_string()), } } unreachable!("the bounded SSO submit loop always returns") } -fn is_transient_no_allowance(reason: &str) -> bool { - reason.contains("noAllowance") -} - /// Statement-store topic filter encoded as JSON-RPC params. pub(super) fn filter(kind: TopicFilterKind, topics: &[[u8; 32]]) -> Value { let topics = topics.iter().map(hex_topic).collect::>(); @@ -231,15 +263,32 @@ pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { #[cfg(test)] mod tests { - use super::is_transient_no_allowance; + use super::*; #[test] - fn identifies_no_allowance_submit_rejections_for_retry() { - assert!(is_transient_no_allowance( - r#"statement_submit not accepted: {"reason":"noAllowance","status":"rejected"}"# - )); - assert!(!is_transient_no_allowance( - r#"statement_submit not accepted: {"reason":"badProof","status":"rejected"}"# - )); + fn capacity_rejection_retains_exact_priority_and_cannot_mask_other_failures() { + let response = serde_json::from_str( + r#"{"status":"rejected","reason":"accountFull","min_expiry":7688541413222383616}"#, + ) + .unwrap(); + let rejection = StatementSubmitError::Rejected(response); + assert_eq!(rejection.replacement_expiry(), Some(7688541413222383617)); + assert!(!rejection.is_no_allowance()); + let allowance = StatementSubmitError::Rejected( + json!({"status":"rejected","reason":"noAllowance","min_expiry":1}), + ); + assert!(allowance.is_no_allowance()); + assert_eq!(allowance.replacement_expiry(), None); + for response in [ + json!({"status":"invalid","reason":"accountFull","min_expiry":1}), + json!({"status":"rejected","reason":"accountFull"}), + json!({"status":"rejected","reason":"accountFull","min_expiry":u64::MAX}), + json!({"status":"rejected","reason":"badProof","min_expiry":1}), + ] { + assert_eq!( + StatementSubmitError::Rejected(response).replacement_expiry(), + None + ); + } } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 086218384..aeb88c0b3 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -27,12 +27,14 @@ use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest use truapi_platform::{ AccountAccessReview, AuthPresenter, AuthState, ChainProvider, ChatAuthorityReview, CoreStorage as PlatformCoreStorage, CoreStorageKey, CreateTransactionReview, - Features as PlatformFeatures, HostInfo, JsonRpcConnection, LocaleHost, - Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, - Permissions as PlatformPermissions, PlatformInfo, PreimageHost, ProductContext, - ProductStorage as PlatformProductStorage, ProductSubtreeReview, ResourceAllocationReview, - SignPayloadReview, SignRawReview, SignVrfReview, StatementStoreProductSignReview, ThemeHost, - UserConfirmation, UserConfirmationReview, + Features as PlatformFeatures, HopProvider, HostInfo, JsonRpcConnection, LocaleHost, + MainPurseChatPaymentReview, NativeChatFileExportRequest, NativeChatFilePickRequest, + NativeChatFilesHost, NativeChatPickedFile, Navigation as PlatformNavigation, + Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage as PlatformProductStorage, + ProductSubtreeReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, + SignVrfReview, StatementStoreProductSignReview, ThemeHost, UserConfirmation, + UserConfirmationReview, }; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey}; @@ -75,6 +77,7 @@ pub type StorageWriteHook = Arc; /// can exercise its delegation paths without pulling in a real backend. #[derive(Default)] pub(crate) struct StubPlatform { + pub(crate) native_chat_files: Option>, pub(crate) remote_permission_denied: bool, /// Every `remote_permission` request, in order, so a test can assert which /// domains reached the prompt and that a stored grant suppresses a re-ask. @@ -99,6 +102,11 @@ pub(crate) struct StubPlatform { pub(crate) chat_authority_confirmed: bool, pub(crate) chat_authority_error: Option<&'static str>, pub(crate) chat_authority_reviews: Arc>>, + /// One-shot payment decisions are independent of every reusable permission. + /// The derived default denies spending. + pub(crate) main_purse_chat_payment_confirmed: bool, + pub(crate) main_purse_chat_payment_error: Option<&'static str>, + pub(crate) main_purse_chat_payment_reviews: Arc>>, pub(crate) sign_payload_confirmed: bool, /// Every `SignPayload` review passed to `confirm_user_action`, in order. /// Empty proves an AutoSigning grant suppressed the prompt. @@ -167,6 +175,8 @@ pub(crate) struct StubPlatform { /// hashes a host is configured with are same-typed `[u8; 32]` passed /// positionally, so a transposed pair still connects and still answers. pub(crate) chain_connects: Arc>>, + /// Host-private, no-network HOP script. Unconfigured fixtures fail closed. + pub(crate) hop_provider: Option>, /// When set, `connect` fails with this reason. pub(crate) chain_connect_error: Option<&'static str>, /// When true, the connection's response stream ends instead of staying @@ -182,6 +192,10 @@ pub(crate) struct StubPlatform { /// forged value to exercise the in-core integrity check. pub(crate) preimage_lookup_value: Option>, pub(crate) local_storage: Arc>>>, + /// Mutable per-slot faults let lifecycle tests recover without replacing + /// the backing platform (and thereby bypassing process-local ownership). + pub(crate) core_read_failures: parking_lot::Mutex>, + pub(crate) core_write_failures: parking_lot::Mutex>, /// When set, product/core storage reads fail with this reason. pub(crate) local_storage_error: Option<&'static str>, /// When set, only `PermissionAuthorization` reads fail. Narrower than @@ -879,6 +893,15 @@ impl PlatformCoreStorage for StubPlatform { &self, key: CoreStorageKey, ) -> Result>, v01::GenericError> { + if self + .core_read_failures + .lock() + .contains(&core_storage_test_key(key.clone())) + { + return Err(v01::GenericError { + reason: "injected core read failure".into(), + }); + } if let CoreStorageKey::AuthSession = key { if let Some(reason) = self.session_error { return Err(v01::GenericError { @@ -912,6 +935,15 @@ impl PlatformCoreStorage for StubPlatform { key: CoreStorageKey, value: Vec, ) -> Result<(), v01::GenericError> { + if self + .core_write_failures + .lock() + .contains(&core_storage_test_key(key.clone())) + { + return Err(v01::GenericError { + reason: "injected core write failure".into(), + }); + } if let CoreStorageKey::AuthSession = key { self.session_writes .lock() @@ -1556,6 +1588,106 @@ impl ChainProvider for StubPlatform { } } +#[truapi_platform::async_trait] +impl HopProvider for StubPlatform { + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + match &self.hop_provider { + Some(provider) => provider.allowed_hop_endpoints(bulletin_genesis_hash).await, + None => Ok(Vec::new()), + } + } + + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, v01::GenericError> { + let provider = self + .hop_provider + .as_ref() + .ok_or_else(|| v01::GenericError { + reason: "HOP provider unavailable in this fixture".to_string(), + })?; + let allowed = provider + .allowed_hop_endpoints(bulletin_genesis_hash) + .await?; + truapi_platform::ensure_allowed_hop_endpoint(&endpoint, &allowed)?; + provider.connect_hop(bulletin_genesis_hash, endpoint).await + } +} + +#[truapi_platform::async_trait] +impl NativeChatFilesHost for StubPlatform { + async fn pick_chat_files( + &self, + request: NativeChatFilePickRequest, + ) -> Result, v01::GenericError> { + self.chat_files_backend()?.pick_chat_files(request).await + } + + async fn read_chat_file( + &self, + source_id: String, + offset: u64, + length: u32, + ) -> Result, v01::GenericError> { + self.chat_files_backend()? + .read_chat_file(source_id, offset, length) + .await + } + + async fn release_chat_file(&self, source_id: String) -> Result<(), v01::GenericError> { + self.chat_files_backend()? + .release_chat_file(source_id) + .await + } + + async fn begin_chat_file_export( + &self, + request: NativeChatFileExportRequest, + ) -> Result, v01::GenericError> { + self.chat_files_backend()? + .begin_chat_file_export(request) + .await + } + + async fn write_chat_file_export( + &self, + export_id: String, + offset: u64, + data: Vec, + ) -> Result<(), v01::GenericError> { + self.chat_files_backend()? + .write_chat_file_export(export_id, offset, data) + .await + } + + async fn finish_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + self.chat_files_backend()? + .finish_chat_file_export(export_id) + .await + } + + async fn cancel_chat_file_export(&self, export_id: String) -> Result<(), v01::GenericError> { + self.chat_files_backend()? + .cancel_chat_file_export(export_id) + .await + } +} + +impl StubPlatform { + fn chat_files_backend(&self) -> Result<&dyn NativeChatFilesHost, v01::GenericError> { + self.native_chat_files + .as_deref() + .ok_or_else(|| v01::GenericError { + reason: "native Chat files unavailable in this fixture".into(), + }) + } +} + impl AuthPresenter for StubPlatform { fn auth_state_changed(&self, state: AuthState) { self.auth_states @@ -1643,6 +1775,16 @@ impl UserConfirmation for StubPlatform { self.chat_authority_reviews.lock().push(review); (self.chat_authority_error, self.chat_authority_confirmed) } + UserConfirmationReview::MainPurseChatPayment(review) => { + self.main_purse_chat_payment_reviews + .lock() + .expect("main purse payment review list mutex poisoned") + .push(review); + ( + self.main_purse_chat_payment_error, + self.main_purse_chat_payment_confirmed, + ) + } UserConfirmationReview::ResourceAllocation(review) => { self.resource_allocation_reviews .lock() @@ -1721,3 +1863,59 @@ impl PreimageHost for StubPlatform { Box::pin(stream::once(async move { Ok(value) })) } } + +#[cfg(test)] +mod payment_review_tests { + use super::*; + + fn payment_review() -> UserConfirmationReview { + UserConfirmationReview::MainPurseChatPayment(MainPurseChatPaymentReview { + calling_product_id: "chat.paseo".to_string(), + recipient_identity: [1; 32], + recipient_username: Some("recipient.paseo".to_string()), + amount_cents: 125, + max_debit_cents: 130, + genesis_hash: [2; 32], + coinage_instance_id: None, + operation_id: [3; 32], + }) + } + + #[test] + fn reusable_approvals_never_authorize_main_purse_payments() { + let platform = StubPlatform { + chat_authority_confirmed: true, + sign_payload_confirmed: true, + sign_raw_confirmed: true, + create_transaction_confirmed: true, + resource_allocation_confirmed: true, + ..Default::default() + }; + assert_eq!( + futures::executor::block_on(platform.confirm_user_action(payment_review())), + Ok(false) + ); + } + + #[test] + fn payment_approval_is_explicit_and_prompt_errors_fail_closed() { + let mut platform = StubPlatform { + main_purse_chat_payment_confirmed: true, + ..Default::default() + }; + assert_eq!( + futures::executor::block_on(platform.confirm_user_action(payment_review())), + Ok(true) + ); + platform.main_purse_chat_payment_error = Some("prompt unavailable"); + assert!( + futures::executor::block_on(platform.confirm_user_action(payment_review())).is_err() + ); + platform.main_purse_chat_payment_error = None; + platform.main_purse_chat_payment_confirmed = false; + assert_eq!( + futures::executor::block_on(platform.confirm_user_action(payment_review())), + Ok(false) + ); + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index a4d7924d1..74b343ef3 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -24,13 +24,13 @@ use parity_scale_codec::{Decode, Encode}; use send_wrapper::SendWrapper; use truapi::latest::HostPlatform; use truapi::v01; -#[cfg(feature = "wasm-signing-host")] -use truapi_platform::SigningHostConfig; use truapi_platform::{ - ChainProvider, ChatPlatform, HostInfo, JsonRpcConnection, PairingHostConfig, + ChainProvider, ChatPlatform, HopProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PermissionStatusHost, PlatformInfo, PocketPlatform, ProductContext, ProductExecutionKind, RuntimeConfigValidationError, }; +#[cfg(feature = "wasm-signing-host")] +use truapi_platform::{IdentityBackendHost, SigningHostConfig}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -134,57 +134,121 @@ impl ChainProvider for WasmPlatform { &self, genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { - let chain_connect = self.bridge.chain_connect.clone(); - let chain_connect = SendWrapper::new(chain_connect); - SendWrapper::new(async move { - let (response_tx, response_rx) = mpsc::unbounded::(); - let on_response = Closure::wrap(Box::new(move |json: JsValue| { - // The host must hand back JSON-RPC frames as strings. Drop (and - // log) non-string values rather than forwarding an empty frame - // that would desync request/response correlation. - match json.as_string() { - Some(s) => { - let _ = response_tx.unbounded_send(s); - } - None => web_sys::console::error_1(&JsValue::from_str( - "chainConnect onResponse expected a JSON string; dropping non-string value", - )), - } - }) as Box); - - let genesis_arg = JsValue::from_str(&format!("0x{}", hex::encode(genesis_hash))); - let returned = chain_connect - .call2( - &JsValue::NULL, - &genesis_arg, - on_response.as_ref().unchecked_ref(), - ) - .map_err(|err| generic(js_to_string(err)))?; - let resolved = await_optional_promise(returned).await.map_err(generic)?; - if resolved.is_null() || resolved.is_undefined() { - return Err(generic("chainConnect returned no connection".into())); - } - let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) - .map_err(|_| generic("chainConnect must return { send, close }".into()))? - .dyn_into::() - .map_err(|_| generic("chainConnect.send must be a function".into()))?; - let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) - .map_err(|_| generic("chainConnect.close must be a function".into()))? - .dyn_into::() - .map_err(|_| generic("chainConnect.close must be a function".into()))?; - - Ok(Box::new(JsCallbackJsonRpcConnection { - send_fn: SendWrapper::new(send_fn), - close_fn: SendWrapper::new(close_fn), - closed: AtomicBool::new(false), - _on_response: SendWrapper::new(on_response), - response_rx: std::sync::Mutex::new(Some(response_rx)), - }) as Box) - }) + connect_js_rpc(self.bridge.chain_connect.clone(), genesis_hash, None).await + } +} + +#[truapi_platform::async_trait] +impl HopProvider for WasmPlatform { + async fn allowed_hop_endpoints( + &self, + bulletin_genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let encoded = invoke_bytes_return( + &self.bridge.allowed_hop_endpoints, + vec![Uint8Array::from(bulletin_genesis_hash.as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes(encoded, "encoded HOP endpoint list did not decode").map_err(generic) + } + + async fn connect_hop( + &self, + bulletin_genesis_hash: [u8; 32], + endpoint: String, + ) -> Result, v01::GenericError> { + let allowed = self.allowed_hop_endpoints(bulletin_genesis_hash).await?; + truapi_platform::ensure_allowed_hop_endpoint(&endpoint, &allowed)?; + connect_js_rpc( + self.bridge.hop_connect.clone(), + bulletin_genesis_hash, + Some(endpoint), + ) .await } } +/// Keep callback closures alive until an asynchronous host open settles, even +/// when its Rust caller is cancelled. A late connection is closed, not leaked. +fn connect_js_rpc( + connect: Function, + genesis_hash: [u8; 32], + endpoint: Option, +) -> impl Future, v01::GenericError>> + Send { + SendWrapper::new(async move { + let (result_tx, result_rx) = futures::channel::oneshot::channel(); + wasm_bindgen_futures::spawn_local(async move { + let result = open_js_rpc(connect, genesis_hash, endpoint).await; + if let Err(Ok(connection)) = result_tx.send(result) { + connection.close(); + } + }); + result_rx + .await + .map_err(|_| generic("JSON-RPC connection open cancelled".into()))? + }) +} + +async fn open_js_rpc( + connect: Function, + genesis_hash: [u8; 32], + endpoint: Option, +) -> Result, v01::GenericError> { + let private_rpc = endpoint.is_some(); + let (response_tx, response_rx) = mpsc::unbounded::(); + let on_response_tx = response_tx.clone(); + let on_response = Closure::wrap(Box::new(move |json: JsValue| match json.as_string() { + Some(json) => { + let _ = on_response_tx.unbounded_send(json); + } + None => web_sys::console::error_1(&JsValue::from_str( + "JSON-RPC onResponse expected a JSON string; dropping non-string value", + )), + }) as Box); + let on_closed_tx = response_tx.clone(); + let on_closed = Closure::wrap(Box::new(move || { + on_closed_tx.close_channel(); + }) as Box); + let mut args = vec![JsValue::from_str(&format!( + "0x{}", + hex::encode(genesis_hash) + ))]; + if let Some(endpoint) = endpoint { + args.push(JsValue::from_str(&endpoint)); + } + args.push(on_response.as_ref().clone()); + args.push(on_closed.as_ref().clone()); + let returned = call_js_function(&connect, &args).map_err(generic)?; + let resolved = await_optional_promise(returned).await.map_err(generic)?; + if resolved.is_null() || resolved.is_undefined() { + return Err(generic("JSON-RPC provider returned no connection".into())); + } + let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) + .map_err(|_| generic("JSON-RPC connection must return { send, close }".into()))? + .dyn_into::() + .map_err(|_| generic("JSON-RPC connection.close must be a function".into()))?; + let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) + .ok() + .and_then(|send| send.dyn_into::().ok()); + let Some(send_fn) = send_fn else { + let _ = close_fn.call0(&JsValue::NULL); + return Err(generic( + "JSON-RPC connection.send must be a function".into(), + )); + }; + Ok(Box::new(JsCallbackJsonRpcConnection { + send_fn: SendWrapper::new(send_fn), + close_fn: SendWrapper::new(close_fn), + closed: AtomicBool::new(false), + private_rpc, + _on_response: SendWrapper::new(on_response), + _on_closed: SendWrapper::new(on_closed), + response_tx, + response_rx: std::sync::Mutex::new(Some(response_rx)), + })) +} + // Account, signing, and statement-store flows live in the Rust core itself. // The JS bridge only carries callbacks for platform capabilities the core // cannot satisfy alone; account authority is selected by the runtime. @@ -276,17 +340,28 @@ struct JsCallbackJsonRpcConnection { send_fn: SendWrapper, close_fn: SendWrapper, closed: AtomicBool, + private_rpc: bool, /// Closure must outlive the connection so JS keeps a live ref to the /// response sink. Dropped together with the rest of the struct. _on_response: SendWrapper>, + _on_closed: SendWrapper>, + response_tx: mpsc::UnboundedSender, response_rx: std::sync::Mutex>>, } impl JsonRpcConnection for JsCallbackJsonRpcConnection { fn send(&self, request: String) { + if self.closed.load(Ordering::Acquire) || self.response_tx.is_closed() { + return; + } let arg = JsValue::from_str(&request); if let Err(err) = self.send_fn.call1(&JsValue::NULL, &arg) { - web_sys::console::error_1(&err); + if self.private_rpc { + web_sys::console::error_1(&JsValue::from_str("HOP send failed")); + } else { + web_sys::console::error_1(&err); + } + self.close(); } } @@ -310,6 +385,7 @@ impl JsonRpcConnection for JsCallbackJsonRpcConnection { if self.closed.swap(true, Ordering::AcqRel) { return; } + self.response_tx.close_channel(); let _ = self.close_fn.call0(&JsValue::NULL); } } @@ -420,6 +496,24 @@ fn invoke_optional_bytes_return( }) } +fn invoke_optional_string_return( + fn_: &Function, + args: Vec, +) -> impl Future, String>> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + if resolved.is_null() || resolved.is_undefined() { + return Ok(None); + } + resolved + .as_string() + .map(Some) + .ok_or_else(|| "callback must resolve to string, null or undefined".to_string()) + }) +} + fn decode_bytes(bytes: Vec, message: &str) -> Result { T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } @@ -586,7 +680,7 @@ fn signing_host_config_from_js(value: &JsValue) -> Result Result Result { @@ -734,6 +834,21 @@ fn get_optional_string_at( .ok_or_else(|| JsValue::from_str(&format!("{path} must be a string"))) } +#[cfg(feature = "wasm-signing-host")] +fn get_optional_u32_at(value: &JsValue, name: &str, path: &str) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + property + .as_f64() + .filter(|number| { + number.is_finite() && number.fract() == 0.0 && (0.0..=u32::MAX as f64).contains(number) + }) + .map(|number| Some(number as u32)) + .ok_or_else(|| JsValue::from_str(&format!("{path} must be an unsigned 32-bit integer"))) +} + fn get_required_string_at(value: &JsValue, name: &str, path: &str) -> Result { get_optional_string_at(value, name, path)? .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) @@ -844,6 +959,8 @@ struct WasmPlatformAdapters { chat_platform: Option>, status_host: Option>, pocket_platform: Option>, + #[cfg(feature = "wasm-signing-host")] + identity_backend_host: Option>, } /// Build the platform and the optional capability adapters supplied by the host. @@ -851,18 +968,49 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { let has_chat = bridge.has_chat(); let has_permission_status = bridge.has_permission_status(); let has_pocket = bridge.has_pocket(); + #[cfg(feature = "wasm-signing-host")] + let has_identity_backend = bridge.has_identity_backend(); let platform = Arc::new(WasmPlatform::new(bridge)); let chat = has_chat.then(|| platform.clone() as Arc); let status = has_permission_status.then(|| platform.clone() as Arc); let pocket = has_pocket.then(|| platform.clone() as Arc); + #[cfg(feature = "wasm-signing-host")] + let identity_backend = + has_identity_backend.then(|| platform.clone() as Arc); WasmPlatformAdapters { platform, chat_platform: chat, status_host: status, pocket_platform: pocket, + #[cfg(feature = "wasm-signing-host")] + identity_backend_host: identity_backend, } } +fn connection_adapters_from_js( + callbacks: Option<&JsValue>, +) -> Result, JsValue> { + let Some(callbacks) = callbacks.filter(|value| !value.is_null() && !value.is_undefined()) + else { + return Ok(None); + }; + let WasmPlatformAdapters { + platform, + chat_platform, + status_host, + pocket_platform, + .. + } = wasm_platform(Arc::new(JsBridge::from_js(callbacks)?)); + Ok(Some(crate::host_core::ConnectionAdapters { + platform, + chat_platform, + permission_status: status_host, + pocket_platform, + chat: Arc::new(crate::runtime::ActionChannel::chat()), + renderer: Arc::new(crate::runtime::ActionChannel::renderer()), + })) +} + /// Reports every worker demand transition to the host's /// `workerDemandChanged(productId, transition)` callback. struct WasmWorkerDemand { @@ -922,6 +1070,7 @@ impl WasmPairingHostRuntime { chat_platform, status_host, pocket_platform, + .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); @@ -942,11 +1091,13 @@ impl WasmPairingHostRuntime { } /// Build one product-scoped runtime from this pairing host runtime. + /// Optional platform callbacks are execution-local; shared authority stays here. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; @@ -955,7 +1106,10 @@ impl WasmPairingHostRuntime { let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; if let Some(debug_emit) = debug_emit { runtime.set_debug_sink( ChannelId(channel_id), @@ -1203,6 +1357,7 @@ impl WasmSigningHostRuntime { chat_platform, status_host, pocket_platform, + identity_backend_host, } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); @@ -1210,6 +1365,9 @@ impl WasmSigningHostRuntime { let host_config = signing_host_config_from_js(&host_config)?; let runtime = SigningHostRuntime::with_chat_platform(platform, host_config, spawner, chat_platform); + if let Some(identity_backend_host) = identity_backend_host { + runtime.set_identity_backend_host(identity_backend_host); + } if let Some(status_host) = status_host { runtime.set_permission_status_host(status_host); } @@ -1223,18 +1381,23 @@ impl WasmSigningHostRuntime { } /// Build one product-scoped runtime from this signing host. + /// Optional platform callbacks are execution-local; custody stays on this host. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) } @@ -1515,6 +1678,7 @@ impl WasmProductRuntime { chat_platform, status_host, pocket_platform, + .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 86faa1e43..0263282cb 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -8,9 +8,10 @@ use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, LocaleHost, Navigation, Notifications, PairingHostConfig, Permissions, - PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, - UserConfirmationReview, + JsonRpcConnection, LocaleHost, NativeChatFileExportRequest, NativeChatFilePickRequest, + NativeChatFilesHost, NativeChatPickedFile, Navigation, Notifications, PairingHostConfig, + Permissions, PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, + UserConfirmation, UserConfirmationReview, }; use truapi_server::frame::ProtocolMessage; use truapi_server::transport::Transport; @@ -186,6 +187,9 @@ impl ChainProvider for WireShapePlatform { } } +#[truapi_platform::async_trait] +impl truapi_platform::HopProvider for WireShapePlatform {} + impl AuthPresenter for WireShapePlatform {} #[truapi_platform::async_trait] @@ -242,3 +246,50 @@ impl PreimageHost for WireShapePlatform { Box::pin(stream::empty()) } } + +fn unavailable_chat_files() -> Result { + Err(v01::GenericError { + reason: "native Chat files unavailable in this fixture".into(), + }) +} + +#[truapi_platform::async_trait] +impl NativeChatFilesHost for WireShapePlatform { + async fn pick_chat_files( + &self, + _: NativeChatFilePickRequest, + ) -> Result, v01::GenericError> { + unavailable_chat_files() + } + async fn read_chat_file( + &self, + _: String, + _: u64, + _: u32, + ) -> Result, v01::GenericError> { + unavailable_chat_files() + } + async fn release_chat_file(&self, _: String) -> Result<(), v01::GenericError> { + unavailable_chat_files() + } + async fn begin_chat_file_export( + &self, + _: NativeChatFileExportRequest, + ) -> Result, v01::GenericError> { + unavailable_chat_files() + } + async fn write_chat_file_export( + &self, + _: String, + _: u64, + _: Vec, + ) -> Result<(), v01::GenericError> { + unavailable_chat_files() + } + async fn finish_chat_file_export(&self, _: String) -> Result<(), v01::GenericError> { + unavailable_chat_files() + } + async fn cancel_chat_file_export(&self, _: String) -> Result<(), v01::GenericError> { + unavailable_chat_files() + } +} diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 07e4e9fb4..3b5e10b71 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -463,28 +463,73 @@ fn malformed_result_subscription_start_interrupts_with_malformed_frame() { } #[test] -fn product_device_chat_reaches_the_account_authority() { +fn product_device_chat_initialize_reaches_authorization_at_method_twelve() { let core = make_core(); - let request = - account::HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { - product_account_id: v01::ProductAccountId { - dot_ns_identifier: "dotli.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), + let request = account::HostProductDeviceChatRequest::V1( + truapi::v02::HostProductDeviceChatRequest::Initialize, + ); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:product-device-chat".into(), + payload: Payload { + trait_id: 2, + method_id: 12, + message_type: MESSAGE_TYPE_REQUEST, + value: request.encode(), }, - peer_identity_account_id: [0x55; 32], - peer_chat_public_key: [ - 0x0f, 0xaa, 0x68, 0x4e, 0xd2, 0x88, 0x67, 0xb9, 0x7f, 0x4a, 0x6a, 0x2d, 0xee, 0x5d, - 0xf8, 0xce, 0x97, 0x4e, 0x76, 0xb7, 0x01, 0x8e, 0x3f, 0x22, 0xa1, 0xc4, 0xcf, 0x26, - 0x78, 0x57, 0x0f, 0x20, - ], - }); + }, + ); + assert_eq!(response.request_id, "p:product-device-chat"); + assert_eq!(response.payload.trait_id, 2); + assert_eq!(response.payload.method_id, 12); + assert_eq!(response.payload.message_type, MESSAGE_TYPE_RESPONSE); + assert_eq!( + response.payload.value, + versioned_result_err_payload(account::HostProductDeviceChatError::V1( + truapi::v02::HostProductDeviceChatError::NotConnected, + )), + ); +} - assert_request_returns_domain_error( +#[test] +fn retired_raw_chat_method_cannot_dispatch() { + let core = make_core(); + // The retired V1 Identity request was [V1, Identity, ProductAccountId]. + // Keep a valid historical payload so rejection cannot be a decode failure. + let mut raw_identity = vec![0, 4]; + v01::ProductAccountId { + dot_ns_identifier: "chat.dot".into(), + derivation_index: v01::DerivationIndex::Index(0), + } + .encode_to(&mut raw_identity); + let response = dispatch( &core, - "p:product-device-chat", - "account_product_device_chat", - request.encode(), - account::HostProductDeviceChatError::V1(v01::HostProductDeviceChatError::NotConnected), + ProtocolMessage { + request_id: "p:retired-chat".into(), + payload: Payload { + trait_id: 2, + method_id: 11, + message_type: MESSAGE_TYPE_REQUEST, + value: raw_identity, + }, + }, + ); + assert_eq!( + response, + ProtocolMessage { + request_id: "p:retired-chat".into(), + payload: Payload { + trait_id: PROTOCOL_ERROR_TRAIT_ID, + method_id: PROTOCOL_ERROR_METHOD_ID, + message_type: MESSAGE_TYPE_RESPONSE, + value: VersionedProtocolError::V1(ProtocolErrorV1::UnsupportedMessage { + trait_id: 2, + method_id: 11, + }) + .encode(), + }, + }, ); } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index fc0ddca62..e4e61c474 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -288,30 +288,17 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } - /// Bind a product account as a Chat v2 device, or seal/open identity-route - /// payloads without exposing the wallet Chat identity secret. + /// Operate a Host-owned native Chat device and propose one-shot main-purse + /// payments. Transport private keys and spendable memos never leave the Host. /// - /// ```ts - /// const productContext = await truapi.system.getProductContext(); - /// assert(productContext.isOk(), "getProductContext failed:", productContext); + /// Method 11 (the former raw-crypto interface) is retired, not forwarded. /// - /// const result = await truapi.account.deviceChat({ - /// tag: "Bind", - /// value: { - /// productAccountId: { - /// dotNsIdentifier: productContext.value.productId, - /// derivationIndex: { tag: "Index", value: 0 }, - /// }, - /// peerIdentityAccountId: - /// "0x5555555555555555555555555555555555555555555555555555555555555555", - /// peerChatPublicKey: - /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", - /// }, - /// }); + /// ```ts + /// const result = await truapi.account.deviceChat({ tag: "Initialize" }); /// assert(result.isOk(), "deviceChat failed:", result); - /// console.log("Chat identity binding:", result.value); + /// console.log("Host-owned Chat device:", result.value.device); /// ``` - #[wire(id = 11)] + #[wire(id = 12)] async fn product_device_chat( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 9607cceb8..57a654059 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -87,6 +87,14 @@ pub mod latest { ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; + pub use crate::v02::{ + HostNativeChatAcknowledgment, HostNativeChatAttachment, HostNativeChatAttachmentKind, + HostNativeChatAttachmentMetadata, HostNativeChatAttachmentState, HostNativeChatDevice, + HostNativeChatInvitation, HostNativeChatMessages, HostNativeChatPayment, + HostNativeChatPaymentDirection, HostNativeChatPaymentFailure, HostNativeChatPaymentState, + HostNativeChatPeer, HostNativeChatPeerDevice, HostNativeChatRichMessage, + HostNativeChatRichMessageKind, + }; /// Latest payload type of a versioned envelope. pub type LatestOf = ::Latest; @@ -185,12 +193,14 @@ pub mod latest { /// Per-resource allocation outcomes. pub type HostRequestResourceAllocationResponse = LatestOf; - /// Product-device Chat v2 identity request. + /// Host-owned native Chat request. pub type HostProductDeviceChatRequest = LatestOf; - /// Product-device Chat v2 identity result. + /// Safe public view of Host-owned Chat state. pub type HostProductDeviceChatResponse = LatestOf; + /// Host-owned native Chat operation error. + pub type HostProductDeviceChatError = LatestOf; /// Extrinsic payload signing request for a product account. pub type HostSignPayloadRequest = LatestOf; /// Signing operation result. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 143f27366..171e76bd3 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -413,153 +413,3 @@ pub enum HostAccountSignVrfError { reason: String, }, } - -/// Cipher suite used by product-device Chat identity-route operations. -/// -/// Legacy v2 preserves current mobile interoperability. Context-bound v1 -/// authenticates the product/network, both account roles, route, and direction. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostProductDeviceChatCipherSuite { - /// Existing Chat v2 CryptoKit-compatible empty-context HKDF and AEAD. - LegacyV2, - /// Domain-separated encryption for peers that explicitly support it. - ContextBoundV1 { - /// Peer account corresponding to `peer_chat_public_key`. - peer_account_id: [u8; 32], - /// Statement channel carrying the ciphertext. - channel_id: [u8; 32], - }, -} - -/// Product-device Chat v2 identity operation. -/// -/// The wallet Chat identity secret and derived shared key remain host-private. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostProductDeviceChatRequest { - /// Resolve the product account as a Chat device and bind it to the wallet identity. - Bind { - /// Product account becoming a Chat device. - product_account_id: ProductAccountId, - /// Peer wallet identity account used for directional routing. - peer_identity_account_id: [u8; 32], - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - }, - /// Seal identity-route plaintext for the peer with a host-generated nonce. - Seal { - /// Product account requesting the operation. - product_account_id: ProductAccountId, - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - /// Explicit cipher suite; secure callers must never silently downgrade. - cipher_suite: HostProductDeviceChatCipherSuite, - /// Identity-route plaintext. - plaintext: Vec, - }, - /// Open an identity-route combined nonce/ciphertext/tag value. - Open { - /// Product account requesting the operation. - product_account_id: ProductAccountId, - /// Peer's X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - /// Explicit cipher suite; must match the sender's selected suite. - cipher_suite: HostProductDeviceChatCipherSuite, - /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. - combined_ciphertext: Vec, - }, - /// Sign the canonical Chat first-contact proof payload without wallet-message framing. - SignRequestProof { - /// Product account proving ownership of the Chat device. - product_account_id: ProductAccountId, - /// Canonical SCALE-encoded Chat request proof payload. - payload: Vec, - }, - /// Read the authorized wallet's public Chat identity for incoming requests. - Identity { - /// Product account requesting the operation. - product_account_id: ProductAccountId, - }, - /// Verify a peer's identity-to-device binding without exposing shared keys. - VerifyPeerDevice { - /// Product account requesting the operation. - product_account_id: ProductAccountId, - /// Peer identity whose binding is being checked. - peer_identity_account_id: [u8; 32], - /// Peer's independently resolved X25519 Chat identity public key. - peer_chat_public_key: [u8; 32], - /// Device account authenticated by the signed contact request. - peer_device_account_id: [u8; 32], - /// Keyed identity binding carried by that request. - proof: [u8; 32], - }, -} - -/// Result of a product-device Chat v2 identity operation. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostProductDeviceChatResponse { - /// Wallet identity binding and deterministic peer routes. - IdentityBinding { - /// Wallet's canonical identity account. - identity_account_id: [u8; 32], - /// Keyed proof binding the wallet identity to the product device. - proof: [u8; 32], - /// Wallet-to-peer session identifier. - wallet_own_session_id: [u8; 32], - /// Peer-to-wallet session identifier. - peer_own_session_id: [u8; 32], - /// Wallet-to-peer contact-request channel. - wallet_outgoing_channel_id: [u8; 32], - /// Peer-to-wallet contact-request channel. - wallet_incoming_channel_id: [u8; 32], - }, - /// Sealed identity-route payload. - Sealed { - /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. - combined_ciphertext: Vec, - }, - /// Opened identity-route payload. - Opened { - /// Authenticated plaintext. - plaintext: Vec, - }, - /// Raw sr25519 signature over a canonical Chat request proof payload. - RequestProofSigned { - /// Unframed 64-byte sr25519 signature. - signature: [u8; 64], - }, - /// Public Chat identity of the authorized wallet. - Identity { - /// Canonical wallet identity account. - identity_account_id: [u8; 32], - /// X25519 Chat identity public key; never private key material. - chat_public_key: [u8; 32], - }, - /// Result of verifying a peer identity-to-device binding. - PeerDeviceVerified { - /// Whether the supplied binding matches the authenticated shared key. - valid: bool, - }, -} - -/// Product-device Chat v2 identity failure. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] -pub enum HostProductDeviceChatError { - /// No account-authority session is connected. - #[display("not connected")] - NotConnected, - /// The user or Host rejected the operation. - #[display("rejected")] - Rejected, - /// The peer X25519 public key is invalid. - #[display("invalid peer key")] - InvalidPeerKey, - /// The ciphertext failed structural or authentication checks. - #[display("invalid ciphertext")] - InvalidCiphertext, - /// The Host could not complete the operation. - #[display("unknown: {reason}")] - Unknown { - /// Human-readable failure reason. - reason: String, - }, -} diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs index d021e70af..ea782764b 100644 --- a/rust/crates/truapi/src/v02.rs +++ b/rust/crates/truapi/src/v02.rs @@ -4,6 +4,8 @@ //! new version does not redefine keeps its [`crate::v01`] type in the versioned //! envelope, so this module stays a delta rather than a copy of the protocol. +mod account; mod local_storage; +pub use account::*; pub use local_storage::*; diff --git a/rust/crates/truapi/src/v02/account.rs b/rust/crates/truapi/src/v02/account.rs new file mode 100644 index 000000000..8a9bf156c --- /dev/null +++ b/rust/crates/truapi/src/v02/account.rs @@ -0,0 +1,408 @@ +//! Host-owned native Chat transport and one-shot main-purse payments. +//! +//! Products exchange public views and ordinary messages, never transport private +//! keys, decrypted payment memos, source coins, or arbitrary identity ciphertext. + +use alloc::{string::String, vec::Vec}; +use parity_scale_codec::{Decode, Encode}; + +use crate::v01::{ProductAccountId, SignedStatement}; + +/// An operation on the calling product's Host-owned native Chat device. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatRequest { + /// Restore the installation's public device and durable conversation state. + Initialize, + /// Resolve a username in the configured network and send a fresh invitation. + Invite { + /// Recipient username; the Host resolves the identity and encryption key. + username: String, + /// Initial ordinary text shown to the recipient. + text: String, + }, + /// Authenticate, decrypt and durably process a native statement. + Receive { + /// The complete statement, including its native signature proof. + statement: SignedStatement, + }, + /// Accept an invitation previously authenticated and retained by the Host. + AcceptInvitation { + /// Opaque identifier from the Host's invitation view. + invitation_id: [u8; 32], + }, + /// Reject an invitation previously authenticated and retained by the Host. + RejectInvitation { + /// Opaque identifier from the Host's invitation view. + invitation_id: [u8; 32], + }, + /// Send ordinary messages to an established, Host-authenticated peer roster. + Send { + /// Recipient identity, not a guest-supplied device or encryption key. + peer_identity: [u8; 32], + /// Stable caller id; reuse with different contents is rejected. + request_id: String, + /// Native message encodings. Payment and device-control variants are forbidden. + messages: Vec>, + }, + /// Propose one main-purse payment; this operation always requires Host review. + SendPayment { + /// Established recipient identity, resolved and displayed by the Host. + peer_identity: [u8; 32], + /// Stable caller id; retry resumes the same durable payment operation. + request_id: String, + /// Amount in the native Coinage cent denomination. + amount_cents: u64, + }, + /// Read a payment's durable status without authorizing another spend. + PaymentStatus { + /// Identifier returned by this product's original payment operation. + operation_id: [u8; 32], + }, + /// Resume durable transport work and return newly available public views. + Reconcile, + /// Select immutable files in trusted Host UI and send native rich content. + SendAttachments { + /// Established recipient identity authenticated by the Host. + peer_identity: [u8; 32], + /// Stable intent id; retries retain the original files, recipient and text. + request_id: String, + /// Optional ordinary caption. + text: Option, + }, + /// Resume a private download and present or export through trusted Host UI. + OpenAttachment { + /// Product-scoped opaque handle, never a ticket, path or network address. + attachment_id: [u8; 32], + }, +} + +/// The Host-owned device's public identity and statement-signing account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatDevice { + /// Wallet identity on the configured People chain. + pub identity_account_id: [u8; 32], + /// Wallet identity's public X25519 key. + pub identity_chat_public_key: [u8; 32], + /// Product account used to obtain a statement-store allowance for this device. + pub product_account: ProductAccountId, + /// Public statement signer for the Host-owned device. + pub account_id: [u8; 32], + /// Public X25519 key; its secret never leaves the Host. + pub chat_public_key: [u8; 32], +} + +/// Public metadata for one authenticated remote device. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatPeerDevice { + /// Remote device's statement signer. + pub account_id: [u8; 32], + /// Remote device's authenticated public X25519 key. + pub chat_public_key: [u8; 32], +} + +/// Public conversation state; products cannot write this roster back to the Host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatPeer { + /// Recipient wallet identity. + pub identity_account_id: [u8; 32], + /// Username resolved by the Host, if currently available. + pub username: Option, + /// Devices admitted by authenticated native invitation/control messages. + pub devices: Vec, + /// Native session topics for subscriptions, not request/response channel hashes. + pub incoming_channels: Vec<[u8; 32]>, + /// Whether establishment and legacy-device revocation have been acknowledged. + pub ready_for_payments: bool, +} + +/// An authenticated invitation awaiting the user's Chat decision. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatInvitation { + /// Host-generated stable invitation identifier. + pub invitation_id: [u8; 32], + /// Authenticated sender identity. + pub peer_identity: [u8; 32], + /// Host-resolved sender username, when available. + pub username: Option, + /// Authenticated native invitation timestamp in milliseconds. + pub timestamp: u64, + /// Ordinary initial text, never an embedded payment or control message. + pub text: String, +} + +/// Safe ordinary messages from one authenticated native request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatMessages { + /// Authenticated counterparty identity. + pub peer_identity: [u8; 32], + /// Whether the peer sent these messages; false also covers our welcome text. + pub incoming: bool, + /// Native request identifier, retained for message-delivery correlation. + pub request_id: String, + /// Native message encodings after custody-sensitive content is removed. + pub messages: Vec>, +} + +/// A peer's native delivery acknowledgment, not a payment-clearing receipt. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatAcknowledgment { + /// Authenticated acknowledging identity. + pub peer_identity: [u8; 32], + /// Acknowledged native request identifier. + pub request_id: String, + /// Native response code; zero denotes successful delivery processing. + pub response_code: u8, +} + +/// Payment direction relative to the current wallet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum HostNativeChatPaymentDirection { + /// An explicitly approved debit from the user's main purse. + Outgoing, + /// A received memo being claimed into the user's main purse. + Incoming, +} + +/// Durable payment state. Delivery and on-chain clearing are deliberately distinct. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostNativeChatPaymentState { + /// Approved inputs are reserved; required split/unload work is in progress. + Preparing, + /// The encrypted memo is durable and transport delivery is being retried. + Delivering, + /// The peer acknowledged the memo; settlement has not yet been established. + Delivered, + /// Received secrets are durably held while their claim is in progress. + Claiming, + /// Some, but not all, of the payment has been observed clearing on chain. + PartiallyCleared { + /// Confirmed amount in cents. + cleared_cents: u64, + }, + /// The complete payment has been verified at chain finality. + Cleared, + /// An ambiguous effect is retained for reconciliation; inputs remain reserved. + Recovering, + /// A definitive failure; the Host has reconciled any possible prior effects. + Failed { + /// A public failure category, never a raw secret-bearing backend error. + reason: HostNativeChatPaymentFailure, + }, +} + +/// Public, non-secret payment failure categories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum HostNativeChatPaymentFailure { + /// No transaction or memo was accepted and the operation was cancelled. + Cancelled, + /// The wallet could not fund the approved amount and maximum debit. + InsufficientBalance, + /// Received funds were already spent somewhere other than this claim. + AlreadySpent, + /// The received memo was invalid for the native Coinage protocol. + InvalidMemo, + /// A finalized transaction failed without completing the intended payment. + ChainRejected, +} + +/// A product-visible payment card; it contains no spendable memo material. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatPayment { + /// Durable, product-scoped operation identifier. + pub operation_id: [u8; 32], + /// Caller request id for outgoing payments; native request id for incoming ones. + pub request_id: String, + /// Native message id used to place the payment in conversation history. + pub message_id: String, + /// Native message timestamp in milliseconds. + pub timestamp: u64, + /// Counterparty identity authenticated by the Host. + pub peer_identity: [u8; 32], + /// Incoming or outgoing relative to the current wallet. + pub direction: HostNativeChatPaymentDirection, + /// Exact requested/received value in cents. + pub amount_cents: u64, + /// Durable transport/clearing state. + pub state: HostNativeChatPaymentState, +} + +/// Public native media metadata; thumbnails are BlurHash text, not executable images. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostNativeChatAttachmentKind { + /// A general document or other opaque file. + File, + /// An image with native dimensions and an optional UTF-8 BlurHash. + Image { + /// Pixel width. + width: u32, + /// Pixel height. + height: u32, + /// Native UTF-8 BlurHash bytes, never a URL or file payload. + thumbnail: Option>, + }, + /// A video with native duration and an optional UTF-8 BlurHash. + Video { + /// Duration in whole seconds. + duration_seconds: u32, + /// Native UTF-8 BlurHash bytes, never a URL or file payload. + thumbnail: Option>, + }, +} + +/// Safe attachment description, independent of private transfer credentials. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostNativeChatAttachmentMetadata { + /// Validated media type; it does not authorize execution or network loading. + pub mime_type: String, + /// Exact native file size, verified against the downloaded root and chunks. + pub size_bytes: u32, + /// General file, image or video metadata. + pub kind: HostNativeChatAttachmentKind, +} + +/// Durable transfer progress, distinct from message delivery acknowledgment. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostNativeChatAttachmentState { + /// The Host is securing an immutable selected source. + Preparing, + /// Native HOP entries are being uploaded. + Uploading { + /// File bytes whose prepared entry was accepted. + uploaded_bytes: u32, + }, + /// Verified file bytes are being committed to private local storage. + Downloading { + /// Verified bytes durably held by the Host. + downloaded_bytes: u32, + }, + /// Complete verified bytes are available through trusted Host presentation. + Ready, + /// An interrupted transfer retains its exact credentials and progress for retry. + Recovering, +} + +/// Public attachment handle; only the Host can resolve its private backing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatAttachment { + /// Opaque handle scoped to the current wallet, network and calling product. + pub attachment_id: [u8; 32], + /// Non-secret native metadata. + pub metadata: HostNativeChatAttachmentMetadata, + /// Current durable transfer progress. + pub state: HostNativeChatAttachmentState, +} + +/// Native rich-content timeline operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostNativeChatRichMessageKind { + /// A new ordinary rich message. + Message, + /// A new rich message replying to an earlier message. + Reply { + /// Referenced native message id. + message_id: String, + }, + /// A replacement of the same author's earlier rich content. + Edited { + /// Native id of the message being edited. + message_id: String, + }, +} + +/// Authenticated rich content after private file capabilities have been removed. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNativeChatRichMessage { + /// Authenticated conversation identity. + pub peer_identity: [u8; 32], + /// Whether the remote peer authored this content. + pub incoming: bool, + /// Native request id used for delivery acknowledgment. + pub request_id: String, + /// Native id of this message or edit event. + pub message_id: String, + /// Native timestamp in milliseconds. + pub timestamp: u64, + /// New message, reply or edit; authorship checks still apply to edits. + pub kind: HostNativeChatRichMessageKind, + /// Ordinary optional text. + pub text: Option, + /// Opaque file handles and safe metadata, never native file references. + pub attachments: Vec, +} + +/// Public updates from a Host-owned Chat operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostProductDeviceChatResponse { + /// Public local device metadata, including its allowance account. + pub device: HostNativeChatDevice, + /// Current authenticated peers and subscription channels. + pub peers: Vec, + /// Invitations still awaiting a user decision. + pub invitations: Vec, + /// Newly processed safe ordinary messages. + pub messages: Vec, + /// Newly processed native delivery acknowledgments. + pub acknowledgments: Vec, + /// Current public payment statuses belonging to the calling product. + pub payments: Vec, + /// Safe rich-content views and their current private-transfer progress. + pub rich_messages: Vec, +} + +/// Failure of a Host-owned Chat operation before a public update is available. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatError { + /// There is no current authenticated wallet session. + NotConnected, + /// The calling product lacks the required Chat or transport capability. + AccessNotGranted, + /// The user declined a Host-mediated review, selection or export. + UserRejected, + /// The device still needs its statement-store allowance. + AllowanceRequired, + /// The recipient has not completed authenticated establishment/revocation. + PeerNotReady, + /// An id was reused with different immutable operation parameters. + OperationConflict, + /// The proposed request violates native bounds or message policy. + InvalidRequest, + /// An incoming statement failed native authentication or decryption. + InvalidStatement, + /// The configured network could not resolve the requested identity. + RecipientNotFound, + /// The main purse cannot fund the proposed payment. + InsufficientBalance, + /// Durable storage could not safely commit the operation. + StorageUnavailable, + /// The configured chain or statement-store service is unavailable. + NetworkUnavailable, + /// The requested operation does not belong to the calling product. + OperationNotFound, + /// This Host cannot select, recover or present the requested private file. + AttachmentsUnavailable, +} + +impl core::fmt::Display for HostProductDeviceChatError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + Self::NotConnected => "The wallet session is no longer connected", + Self::AccessNotGranted => "Chat or statement delivery permission is not granted", + Self::UserRejected => "The request was declined", + Self::AllowanceRequired => "The Host Chat device needs a statement-store allowance", + Self::PeerNotReady => "The peer has not completed secure device establishment", + Self::OperationConflict => "The request id was already used for different contents", + Self::InvalidRequest => "The Chat request is invalid", + Self::InvalidStatement => "The native Chat statement could not be authenticated", + Self::RecipientNotFound => { + "The recipient could not be resolved on the configured network" + } + Self::InsufficientBalance => "The main purse cannot fund this payment", + Self::StorageUnavailable => "Durable wallet storage is unavailable", + Self::NetworkUnavailable => "The configured network is unavailable", + Self::OperationNotFound => "The payment operation does not belong to this product", + Self::AttachmentsUnavailable => "The attachment is unavailable on this Host", + }) + } +} diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 06f410491..865db9832 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -1,7 +1,7 @@ //! Versioned wrappers for [`Account`](crate::api::Account) methods. use alloc::vec::Vec; -use crate::v01; +use crate::{v01, v02}; truapi_macros::versioned_type! { pub enum HostAccountGetRequest { V1 => v01::HostAccountGetRequest } @@ -37,7 +37,15 @@ truapi_macros::versioned_type! { pub enum HostGetUserIdRequest { V1 } pub enum HostGetUserIdResponse { V1 => v01::HostGetUserIdResponse } pub enum HostGetUserIdError { V1 => v01::HostGetUserIdError } - pub enum HostProductDeviceChatRequest { V1 => v01::HostProductDeviceChatRequest } - pub enum HostProductDeviceChatResponse { V1 => v01::HostProductDeviceChatResponse } - pub enum HostProductDeviceChatError { V1 => v01::HostProductDeviceChatError } + pub enum HostProductDeviceChatRequest { V1 => v02::HostProductDeviceChatRequest } + pub enum HostProductDeviceChatResponse { V1 => v02::HostProductDeviceChatResponse } + pub enum HostProductDeviceChatError { V1 => v02::HostProductDeviceChatError } +} + +impl core::fmt::Display for HostProductDeviceChatError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::V1(error) => core::fmt::Display::fmt(error, f), + } + } } diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 5a1425870..58d7ce11b 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -13,10 +13,11 @@ # --platform-ts-output js/packages/truapi-host/src/generated # --platform-wasm-adapter-output js/packages/truapi-host/src/generated # --platform-rust-output rust/crates/truapi-server/src/wasm -# --codec-version 2 # # The client surface defaults to the latest wire version any versioned # wrapper exposes; pass `--client-version V` to pin to an older one. +# The codec version defaults to truapi::WIRE_CODEC_VERSION in the generator; +# do not pin normal generation separately from the Host's handshake constant. # # Run from the repo root. @@ -46,8 +47,7 @@ cargo run -p truapi-codegen -- \ --platform-ts-output js/packages/truapi-host/src/generated \ --platform-wasm-adapter-output js/packages/truapi-host/src/generated \ --platform-rust-output rust/crates/truapi-server/src/wasm \ - --explorer-output js/packages/truapi/src/explorer \ - --codec-version 2 + --explorer-output js/packages/truapi/src/explorer rustfmt +"$NIGHTLY_TOOLCHAIN" --edition 2024 \ rust/crates/truapi-client/src/generated.rs \ From 4a8b867b1e50a61d1a66534ee8f0575c3fe95ad2 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 21 Sep 2026 20:16:32 -0400 Subject: [PATCH 24/67] fix(chat): include complete crypto sources in the shared Host workspace --- .changeset/chat-product-authority.md | 4 + Cargo.lock | 30 +- README.md | 6 +- js/packages/truapi-host/NOTICE | 7 +- rust/crates/truapi-chat-v2/Cargo.toml | 32 + rust/crates/truapi-chat-v2/LICENSE | 661 +++ rust/crates/truapi-chat-v2/NOTICE | 12 + .../crates/truapi-chat-v2/src/call_payload.rs | 893 ++++ rust/crates/truapi-chat-v2/src/lib.rs | 3796 +++++++++++++++++ rust/crates/truapi-host-cli/NOTICE | 7 +- rust/crates/truapi-server/Cargo.toml | 2 +- rust/crates/truapi-server/NOTICE | 7 +- .../truapi-server/src/runtime/chat_device.rs | 2 +- .../src/runtime/native_chat/actor.rs | 2 +- 14 files changed, 5432 insertions(+), 29 deletions(-) create mode 100644 rust/crates/truapi-chat-v2/Cargo.toml create mode 100644 rust/crates/truapi-chat-v2/LICENSE create mode 100644 rust/crates/truapi-chat-v2/NOTICE create mode 100644 rust/crates/truapi-chat-v2/src/call_payload.rs create mode 100644 rust/crates/truapi-chat-v2/src/lib.rs diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md index 25a6973d1..17f50a6b9 100644 --- a/.changeset/chat-product-authority.md +++ b/.changeset/chat-product-authority.md @@ -16,6 +16,10 @@ selection/export, metadata-only guest views and live Bulletin endpoint/session fences. The combined signing runtime includes AGPL-3.0-only code; retain the included provenance, licenses and exact Corresponding Source. +Include the complete native Chat wire, attachment and cryptography implementation +as the source-owned `truapi-chat-v2` crate, with upstream provenance and licensing. +Remove the release dependency on unpublished local Cargo overrides. + Align Coinage keys with current iOS MAIN_PURSE/page-0 derivations, including the soft coin item junction. Keep complete exported coin secrets stable for durable payment replay. Authenticate the new purse layout through snapshot version 3; diff --git a/Cargo.lock b/Cargo.lock index d428b78f4..71d64d8c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6082,6 +6082,21 @@ dependencies = [ "uniffi", ] +[[package]] +name = "truapi-chat-v2" +version = "0.1.0" +dependencies = [ + "blake2", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "hkdf", + "sha2 0.10.9", + "thiserror 2.0.19", + "x25519-dalek", + "zeroize", +] + [[package]] name = "truapi-client" version = "0.16.0" @@ -6286,13 +6301,13 @@ dependencies = [ "tracing", "tracing-subscriber", "truapi", + "truapi-chat-v2", "truapi-coinage", "truapi-macros", "truapi-platform", "unicode-normalization", "uniffi", "url", - "useragent-chat-v2", "verifiable", "wasm-bindgen", "wasm-bindgen-futures", @@ -6611,19 +6626,6 @@ dependencies = [ "serde", ] -[[package]] -name = "useragent-chat-v2" -version = "0.6.30" -dependencies = [ - "blake2", - "chacha20poly1305", - "hkdf", - "sha2 0.10.9", - "thiserror 2.0.19", - "x25519-dalek", - "zeroize", -] - [[package]] name = "utf-8" version = "0.7.6" diff --git a/README.md b/README.md index 58fe4cf74..1200266f0 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,9 @@ Protocol/storage fixtures cover acceptance loss, restart and download after pool deletion. This is not evidence of a funded native-device round trip. Attachment-bearing first-contact welcomes and call signaling remain rejected. -The local integration uses matching, unpublished `useragent-chat-v2` attachment -codec changes. Its published Git pin must be advanced together with the guest -SDK before release; the local Cargo override is not a portable release dependency. +Native Chat wire, cryptography, attachment codecs, and secret-zeroization changes +are included in the workspace's `truapi-chat-v2` crate. Building the Host requires +neither a local Cargo override nor an unpublished guest SDK checkout. Distributing the runtime also requires the exact modified Corresponding Source, not only the base repository URLs in the notices. diff --git a/js/packages/truapi-host/NOTICE b/js/packages/truapi-host/NOTICE index 857d6a14a..9e88567ea 100644 --- a/js/packages/truapi-host/NOTICE +++ b/js/packages/truapi-host/NOTICE @@ -7,9 +7,10 @@ revision d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. Detailed provenance is in rust/crates/truapi-coinage/NOTICE in the source tree. Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. -Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, -based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, -including local native-attachment codec and secret-zeroization modifications. +Native Chat wire/crypto is included in rust/crates/truapi-chat-v2, derived from +paritytech/polkavm-app-kit useragent-chat-v2 at revision +57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, with native +attachment codec and secret-zeroization modifications included in this source. Corresponding Source must include the exact Host source revision, all local modifications, the Coinage provenance/license notices and build instructions. diff --git a/rust/crates/truapi-chat-v2/Cargo.toml b/rust/crates/truapi-chat-v2/Cargo.toml new file mode 100644 index 000000000..4dbea4aaf --- /dev/null +++ b/rust/crates/truapi-chat-v2/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "truapi-chat-v2" +version = "0.1.0" +edition = "2024" +publish = false +license = "AGPL-3.0-only" +description = "Host-owned Chat v2 wire and cryptographic primitives" +repository = "https://github.com/paritytech/host-rust-core" + +[package.metadata.provenance] +source = "https://github.com/paritytech/polkavm-app-kit" +revision = "57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17" +paths = ["crates/useragent-chat-v2"] +license-notice = "NOTICE" + +[dependencies] +blake2 = { version = "0.10", default-features = false } +chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } +getrandom = { version = "0.2", features = ["js"], optional = true } +hkdf = { version = "0.12", default-features = false } +sha2 = { version = "0.10", default-features = false } +thiserror = { version = "2", default-features = false } +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets"] } +zeroize = { version = "1", default-features = false, features = ["alloc"] } + +[dev-dependencies] +hex = "0.4" + +[features] +default = ["std"] +std = ["dep:getrandom"] +wasm = ["std"] diff --git a/rust/crates/truapi-chat-v2/LICENSE b/rust/crates/truapi-chat-v2/LICENSE new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-chat-v2/LICENSE @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-chat-v2/NOTICE b/rust/crates/truapi-chat-v2/NOTICE new file mode 100644 index 000000000..5bc29740c --- /dev/null +++ b/rust/crates/truapi-chat-v2/NOTICE @@ -0,0 +1,12 @@ +TrUAPI native Chat v2 wire and cryptographic primitives + +Derived from paritytech/polkavm-app-kit crates/useragent-chat-v2 at revision +57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, which mirrors the +paritytech/useragent-kit Chat v2 implementation, under AGPL-3.0-only. + +This source includes native attachment wire codecs, bounded attachment +validation, and secret-zeroization modifications. The complete modified +implementation is maintained in this crate; no local Cargo override or +unpublished external source is needed to build the Host. + +See LICENSE for the GNU Affero General Public License, version 3. diff --git a/rust/crates/truapi-chat-v2/src/call_payload.rs b/rust/crates/truapi-chat-v2/src/call_payload.rs new file mode 100644 index 000000000..d44801acb --- /dev/null +++ b/rust/crates/truapi-chat-v2/src/call_payload.rs @@ -0,0 +1,893 @@ +//! Native v2 call payload codec shared by the iOS and Android v2 apps. +//! +//! `DataChannelOffer`, `DataChannelAnswer`, and `DataChannelCandidates` carry +//! these bytes inside the chat-v2 message envelope. The native apps do not put +//! raw SDP strings directly in those fields. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use crate::{ + ChatError, Cursor, V2DataChannelPurpose, decode_compact_u32, encode_bytes, encode_compact_u32, + encode_compact_u128, encode_string, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum V2CallSdpType { + Offer, + Answer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum V2CallTransportType { + Tcp, + Udp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum V2CallCandidateType { + Host, + Srflx, + Relay, + Prflx, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2CallIpAddress { + Ipv4([u8; 4]), + Ipv6([u16; 8]), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2CallMinimalCandidate { + pub foundation: String, + pub priority: u32, + pub transport_type: V2CallTransportType, + pub address: V2CallIpAddress, + pub port: u16, + pub candidate_type: V2CallCandidateType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2CallSetup { + pub sdp_type: V2CallSdpType, + pub session_id: u128, + pub session_version: u128, + pub ice_ufrag: String, + pub ice_pwd: String, + pub fingerprint: Vec, + pub candidates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2CallIceCandidate { + pub sdp: String, + pub sdp_m_line_index: u32, + pub sdp_mid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2CallDataChannelMessage { + pub id: String, + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2CallPeerConnectionSignal { + Offer(String), + Answer(String), + Candidates(Vec), + Closed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2CallDecodedSetup { + pub setup_sdp: String, + pub candidates: Vec, +} + +/// Use-case id used by the iOS and Android v2 apps for media renegotiation +/// messages sent through the bootstrapped WebRTC data channel. +pub const V2_CALL_RENEGOTIATION_USE_CASE_ID: &str = "webrtc_renegotiation_internal_use_case"; + +/// Encode a native v2 data-channel setup payload. +pub fn encode_v2_call_setup_payload(setup: &V2CallSetup) -> Result, ChatError> { + let mut out = Vec::new(); + out.push(sdp_type_index(setup.sdp_type)); + out.extend_from_slice(&encode_compact_u128(setup.session_id)); + out.extend_from_slice(&encode_compact_u128(setup.session_version)); + encode_string(&mut out, &setup.ice_ufrag)?; + encode_string(&mut out, &setup.ice_pwd)?; + encode_bytes(&mut out, &setup.fingerprint)?; + encode_v2_call_minimal_candidates(&mut out, &setup.candidates)?; + Ok(out) +} + +/// Decode a native v2 data-channel setup payload. +pub fn decode_v2_call_setup_payload(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let setup = V2CallSetup { + sdp_type: decode_sdp_type(cursor.read_u8("call_setup.sdp_type")?)?, + session_id: cursor.read_compact_u128("call_setup.session_id")?, + session_version: cursor.read_compact_u128("call_setup.session_version")?, + ice_ufrag: cursor.read_string("call_setup.ice_ufrag")?, + ice_pwd: cursor.read_string("call_setup.ice_pwd")?, + fingerprint: cursor.read_bytes("call_setup.fingerprint")?, + candidates: decode_v2_call_minimal_candidates(&mut cursor, "call_setup.candidates")?, + }; + cursor.finish()?; + Ok(setup) +} + +/// Parse full SDP plus WebRTC candidates and encode the native v2 setup bytes. +pub fn encode_v2_call_setup_from_sdp( + setup_sdp: &str, + candidates: &[V2CallIceCandidate], +) -> Result, ChatError> { + let mut setup = parse_v2_call_setup_sdp(setup_sdp)?; + setup.candidates = candidates + .iter() + .map(parse_v2_call_ice_candidate) + .collect::, _>>()?; + encode_v2_call_setup_payload(&setup) +} + +/// Decode native v2 setup bytes back into the SDP shape accepted by WebRTC. +pub fn decode_v2_call_setup_to_sdp(data: &[u8]) -> Result { + let setup = decode_v2_call_setup_payload(data)?; + Ok(V2CallDecodedSetup { + setup_sdp: reconstruct_v2_call_setup_sdp(&setup), + candidates: setup + .candidates + .iter() + .map(reconstruct_v2_call_ice_candidate) + .collect(), + }) +} + +/// Encode native v2 trickled ICE candidate payload bytes. +pub fn encode_v2_call_candidates_payload( + candidates: &[V2CallMinimalCandidate], +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_v2_call_minimal_candidates(&mut out, candidates)?; + Ok(out) +} + +/// Decode native v2 trickled ICE candidate payload bytes. +pub fn decode_v2_call_candidates_payload( + data: &[u8], +) -> Result, ChatError> { + let mut cursor = Cursor::new(data); + let candidates = decode_v2_call_minimal_candidates(&mut cursor, "call_candidates")?; + cursor.finish()?; + Ok(candidates) +} + +/// Parse WebRTC candidates and encode native v2 trickled ICE candidate bytes. +pub fn encode_v2_call_candidates_from_sdp( + candidates: &[V2CallIceCandidate], +) -> Result, ChatError> { + let candidates = candidates + .iter() + .map(parse_v2_call_ice_candidate) + .collect::, _>>()?; + encode_v2_call_candidates_payload(&candidates) +} + +/// Decode native v2 trickled ICE candidate bytes back into WebRTC candidates. +pub fn decode_v2_call_candidates_to_sdp(data: &[u8]) -> Result, ChatError> { + Ok(decode_v2_call_candidates_payload(data)? + .iter() + .map(reconstruct_v2_call_ice_candidate) + .collect()) +} + +/// Encode the v2 apps' generic WebRTC data-channel message wrapper. +pub fn encode_v2_call_data_channel_message( + message: &V2CallDataChannelMessage, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, &message.id)?; + encode_bytes(&mut out, &message.data)?; + Ok(out) +} + +/// Decode the v2 apps' generic WebRTC data-channel message wrapper. +pub fn decode_v2_call_data_channel_message( + data: &[u8], +) -> Result { + let mut cursor = Cursor::new(data); + let message = V2CallDataChannelMessage { + id: cursor.read_string("call_data_channel_message.id")?, + data: cursor.read_bytes("call_data_channel_message.data")?, + }; + cursor.finish()?; + Ok(message) +} + +/// Encode the v2 apps' media renegotiation signal carried inside +/// `V2_CALL_RENEGOTIATION_USE_CASE_ID` data-channel messages. +pub fn encode_v2_call_peer_connection_signal( + signal: &V2CallPeerConnectionSignal, +) -> Result, ChatError> { + let mut out = Vec::new(); + match signal { + V2CallPeerConnectionSignal::Offer(sdp) => { + out.push(0); + encode_string(&mut out, sdp)?; + } + V2CallPeerConnectionSignal::Answer(sdp) => { + out.push(1); + encode_string(&mut out, sdp)?; + } + V2CallPeerConnectionSignal::Candidates(candidates) => { + out.push(2); + let len = u32::try_from(candidates.len()).map_err(|_| { + ChatError::InvalidEncoding("call peer candidate vector is too large".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + for candidate in candidates { + encode_string(&mut out, &candidate.sdp)?; + out.extend_from_slice(&candidate.sdp_m_line_index.to_le_bytes()); + match &candidate.sdp_mid { + Some(sdp_mid) => { + out.push(1); + encode_string(&mut out, sdp_mid)?; + } + None => out.push(0), + } + } + } + V2CallPeerConnectionSignal::Closed => out.push(3), + } + Ok(out) +} + +/// Decode the v2 apps' media renegotiation signal carried inside a data channel. +pub fn decode_v2_call_peer_connection_signal( + data: &[u8], +) -> Result { + let mut cursor = Cursor::new(data); + let signal = match cursor.read_u8("call_peer_connection_signal.index")? { + 0 => V2CallPeerConnectionSignal::Offer( + cursor.read_string("call_peer_connection_signal.offer")?, + ), + 1 => V2CallPeerConnectionSignal::Answer( + cursor.read_string("call_peer_connection_signal.answer")?, + ), + 2 => { + let (len, consumed) = + decode_compact_u32(&cursor.data[cursor.offset..]).map_err(|e| { + ChatError::InvalidEncoding(format!( + "call_peer_connection_signal.candidates: {e}" + )) + })?; + cursor.offset += consumed; + let len = len as usize; + if len > cursor.data.len().saturating_sub(cursor.offset) / 6 { + return Err(ChatError::InvalidEncoding( + "call_peer_connection_signal.candidates: item count exceeds remaining input" + .into(), + )); + } + let mut candidates = Vec::with_capacity(len); + for index in 0..len { + let field = format!("call_peer_connection_signal.candidates[{index}]"); + candidates.push(V2CallIceCandidate { + sdp: cursor.read_string(&format!("{field}.sdp"))?, + sdp_m_line_index: cursor.read_u32(&format!("{field}.sdp_m_line_index"))?, + sdp_mid: match cursor.read_u8(&format!("{field}.sdp_mid"))? { + 0 => None, + 1 => Some(cursor.read_string(&format!("{field}.sdp_mid.value"))?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "{field}.sdp_mid has invalid option index {value}" + ))); + } + }, + }); + } + V2CallPeerConnectionSignal::Candidates(candidates) + } + 3 => V2CallPeerConnectionSignal::Closed, + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported call peer connection signal index {value}" + ))); + } + }; + cursor.finish()?; + Ok(signal) +} + +/// Encode a media renegotiation signal in the data-channel envelope expected by +/// iOS v2 and Android v2. +pub fn encode_v2_call_renegotiation_message( + signal: &V2CallPeerConnectionSignal, +) -> Result, ChatError> { + encode_v2_call_data_channel_message(&V2CallDataChannelMessage { + id: V2_CALL_RENEGOTIATION_USE_CASE_ID.into(), + data: encode_v2_call_peer_connection_signal(signal)?, + }) +} + +/// Decode a media renegotiation signal from the data-channel envelope expected +/// by iOS v2 and Android v2. +pub fn decode_v2_call_renegotiation_message( + data: &[u8], +) -> Result { + let message = decode_v2_call_data_channel_message(data)?; + if message.id != V2_CALL_RENEGOTIATION_USE_CASE_ID { + return Err(ChatError::InvalidEncoding(format!( + "unexpected call data-channel message id {}", + message.id + ))); + } + decode_v2_call_peer_connection_signal(&message.data) +} + +pub fn v2_call_purpose_from_video(with_video: bool) -> V2DataChannelPurpose { + if with_video { + V2DataChannelPurpose::Video + } else { + V2DataChannelPurpose::Audio + } +} + +fn parse_v2_call_setup_sdp(setup_sdp: &str) -> Result { + let mut ice_ufrag = None; + let mut ice_pwd = None; + let mut fingerprint = None; + let mut sdp_type = V2CallSdpType::Offer; + let mut session_id = None; + let mut session_version = None; + + for line in setup_sdp.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("a=ice-ufrag:") { + ice_ufrag = Some(value.to_string()); + } else if let Some(value) = trimmed.strip_prefix("a=ice-pwd:") { + ice_pwd = Some(value.to_string()); + } else if let Some(value) = trimmed.strip_prefix("a=fingerprint:") { + fingerprint = Some(parse_fingerprint(value)?); + } else if let Some(value) = trimmed.strip_prefix("a=setup:") { + sdp_type = if value == "actpass" { + V2CallSdpType::Offer + } else { + V2CallSdpType::Answer + }; + } else if let Some(value) = trimmed.strip_prefix("o=") { + let parts = value.split_whitespace().collect::>(); + if parts.len() < 3 { + return Err(ChatError::InvalidEncoding( + "call setup SDP has invalid o= session line".into(), + )); + } + session_id = Some(parts[1].parse::().map_err(|_| { + ChatError::InvalidEncoding("call setup SDP has invalid session id".into()) + })?); + session_version = Some(parts[2].parse::().map_err(|_| { + ChatError::InvalidEncoding("call setup SDP has invalid session version".into()) + })?); + } + } + + Ok(V2CallSetup { + sdp_type, + session_id: session_id.ok_or_else(|| { + ChatError::InvalidEncoding("call setup SDP missing o= session line".into()) + })?, + session_version: session_version.ok_or_else(|| { + ChatError::InvalidEncoding("call setup SDP missing o= session line".into()) + })?, + ice_ufrag: ice_ufrag.ok_or_else(|| { + ChatError::InvalidEncoding("call setup SDP missing a=ice-ufrag".into()) + })?, + ice_pwd: ice_pwd + .ok_or_else(|| ChatError::InvalidEncoding("call setup SDP missing a=ice-pwd".into()))?, + fingerprint: fingerprint.ok_or_else(|| { + ChatError::InvalidEncoding("call setup SDP missing a=fingerprint".into()) + })?, + candidates: Vec::new(), + }) +} + +fn parse_v2_call_ice_candidate( + candidate: &V2CallIceCandidate, +) -> Result { + if candidate.sdp_m_line_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "call ICE candidate has unsupported sdpMLineIndex {}", + candidate.sdp_m_line_index + ))); + } + if !matches!(candidate.sdp_mid.as_deref(), None | Some("0")) { + return Err(ChatError::InvalidEncoding(format!( + "call ICE candidate has unsupported sdpMid {:?}", + candidate.sdp_mid + ))); + } + + let trimmed = candidate.sdp.trim(); + let content = trimmed.strip_prefix("candidate:").ok_or_else(|| { + ChatError::InvalidEncoding("call ICE candidate missing candidate: prefix".into()) + })?; + let parts = content.split_whitespace().collect::>(); + if parts.len() < 8 || parts[6] != "typ" { + return Err(ChatError::InvalidEncoding( + "call ICE candidate has invalid format".into(), + )); + } + + let component_id = parts[1].parse::().map_err(|_| { + ChatError::InvalidEncoding("call ICE candidate has invalid component id".into()) + })?; + if component_id != 1 { + return Err(ChatError::InvalidEncoding(format!( + "call ICE candidate has unsupported component id {component_id}" + ))); + } + + let priority = parts[3].parse::().map_err(|_| { + ChatError::InvalidEncoding("call ICE candidate has invalid priority".into()) + })?; + validate_call_priority(priority)?; + + Ok(V2CallMinimalCandidate { + foundation: parts[0].to_string(), + priority, + transport_type: parse_transport_type(parts[2])?, + address: parse_ip_address(parts[4])?, + port: parts[5].parse::().map_err(|_| { + ChatError::InvalidEncoding("call ICE candidate has invalid port".into()) + })?, + candidate_type: parse_candidate_type(parts[7])?, + }) +} + +/// ICE candidate priority must fit in a signed 32-bit integer to round-trip +/// through the Android v2 `Int` representation. Enforced on every path that +/// produces or consumes the binary candidate form so the SDP parser, the +/// binary encoder, and the binary decoder agree on the legal range. +fn validate_call_priority(priority: u32) -> Result<(), ChatError> { + if priority > i32::MAX as u32 { + return Err(ChatError::InvalidEncoding(format!( + "call ICE candidate priority {priority} exceeds Android v2 Int range" + ))); + } + Ok(()) +} + +fn reconstruct_v2_call_setup_sdp(setup: &V2CallSetup) -> String { + let setup_value = match setup.sdp_type { + V2CallSdpType::Offer => "actpass", + V2CallSdpType::Answer => "active", + }; + format!( + "v=0\n\ + o=- {} {} IN IP4 0.0.0.0\n\ + s=-\n\ + t=0 0\n\ + m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n\ + c=IN IP4 0.0.0.0\n\ + a=ice-ufrag:{}\n\ + a=ice-pwd:{}\n\ + a=fingerprint:{}\n\ + a=setup:{}\n\ + a=mid:0\n\ + a=sctp-port:5000\n", + setup.session_id, + setup.session_version, + setup.ice_ufrag, + setup.ice_pwd, + format_fingerprint(&setup.fingerprint), + setup_value + ) +} + +fn reconstruct_v2_call_ice_candidate(candidate: &V2CallMinimalCandidate) -> V2CallIceCandidate { + V2CallIceCandidate { + sdp: format!( + "candidate:{} 1 {} {} {} {} typ {}", + candidate.foundation, + transport_type_string(candidate.transport_type), + candidate.priority, + ip_address_string(&candidate.address), + candidate.port, + candidate_type_string(candidate.candidate_type) + ), + sdp_m_line_index: 0, + sdp_mid: Some("0".into()), + } +} + +fn encode_v2_call_minimal_candidates( + out: &mut Vec, + candidates: &[V2CallMinimalCandidate], +) -> Result<(), ChatError> { + let len = u32::try_from(candidates.len()) + .map_err(|_| ChatError::InvalidEncoding("call ICE candidate vector is too large".into()))?; + out.extend_from_slice(&encode_compact_u32(len)); + for candidate in candidates { + validate_call_priority(candidate.priority)?; + encode_string(out, &candidate.foundation)?; + out.extend_from_slice(&candidate.priority.to_le_bytes()); + out.push(transport_type_index(candidate.transport_type)); + encode_ip_address(out, &candidate.address); + out.extend_from_slice(&candidate.port.to_le_bytes()); + out.push(candidate_type_index(candidate.candidate_type)); + } + Ok(()) +} + +fn decode_v2_call_minimal_candidates( + cursor: &mut Cursor<'_>, + field: &str, +) -> Result, ChatError> { + let (len, consumed) = decode_compact_u32(&cursor.data[cursor.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + cursor.offset += consumed; + + // Cap the pre-allocation to the bytes that actually remain (each candidate + // needs at least one byte) so a malformed length prefix can't trigger a + // multi-gigabyte reservation and abort the process. + let remaining = cursor.data.len().saturating_sub(cursor.offset); + let mut out = Vec::with_capacity((len as usize).min(remaining)); + for index in 0..len { + let item_field = format!("{field}[{index}]"); + let foundation = cursor.read_string(&format!("{item_field}.foundation"))?; + let priority = cursor.read_u32(&format!("{item_field}.priority"))?; + validate_call_priority(priority)?; + let transport_type = + decode_transport_type(cursor.read_u8(&format!("{item_field}.transport_type"))?)?; + let address = decode_ip_address(cursor, &format!("{item_field}.address"))?; + let port = cursor.read_u16(&format!("{item_field}.port"))?; + let candidate_type = + decode_candidate_type(cursor.read_u8(&format!("{item_field}.candidate_type"))?)?; + out.push(V2CallMinimalCandidate { + foundation, + priority, + transport_type, + address, + port, + candidate_type, + }); + } + Ok(out) +} + +fn encode_ip_address(out: &mut Vec, address: &V2CallIpAddress) { + match address { + V2CallIpAddress::Ipv4(bytes) => { + out.push(0); + out.extend_from_slice(bytes); + } + V2CallIpAddress::Ipv6(segments) => { + out.push(1); + for segment in segments { + out.extend_from_slice(&segment.to_le_bytes()); + } + } + } +} + +fn decode_ip_address(cursor: &mut Cursor<'_>, field: &str) -> Result { + match cursor.read_u8(field)? { + 0 => Ok(V2CallIpAddress::Ipv4( + cursor + .read_exact(4, field)? + .try_into() + .map_err(|_| ChatError::InvalidEncoding(format!("{field}: invalid IPv4")))?, + )), + 1 => { + let mut segments = [0_u16; 8]; + for segment in &mut segments { + *segment = cursor.read_u16(field)?; + } + Ok(V2CallIpAddress::Ipv6(segments)) + } + value => Err(ChatError::InvalidEncoding(format!( + "{field}: unsupported IP address type {value}" + ))), + } +} + +fn parse_fingerprint(value: &str) -> Result, ChatError> { + // The wire format carries only the raw digest bytes with no algorithm tag, + // and reconstruction always emits `sha-256`. Reject any other algorithm at + // parse time rather than silently relabelling a non-sha-256 fingerprint. + let trimmed = value.trim(); + let hex = match trimmed.split_once(' ') { + Some((algorithm, rest)) => { + if !algorithm.eq_ignore_ascii_case("sha-256") { + return Err(ChatError::InvalidEncoding(format!( + "call SDP fingerprint uses unsupported algorithm {algorithm}; only sha-256 is supported" + ))); + } + rest + } + None => trimmed, + } + .replace(':', ""); + // `is_multiple_of` and the slicing below are byte-oriented, so a non-ASCII + // value could otherwise slice across a UTF-8 char boundary and panic. + if !hex.is_ascii() { + return Err(ChatError::InvalidEncoding( + "call SDP fingerprint is not hex".into(), + )); + } + if !hex.len().is_multiple_of(2) { + return Err(ChatError::InvalidEncoding( + "call SDP fingerprint hex has odd length".into(), + )); + } + (0..hex.len()) + .step_by(2) + .map(|index| { + u8::from_str_radix(&hex[index..index + 2], 16) + .map_err(|_| ChatError::InvalidEncoding("call SDP fingerprint is not hex".into())) + }) + .collect() +} + +fn format_fingerprint(data: &[u8]) -> String { + let mut out = String::from("sha-256 "); + for (index, byte) in data.iter().enumerate() { + if index > 0 { + out.push(':'); + } + out.push_str(&format!("{byte:02X}")); + } + out +} + +fn parse_ip_address(value: &str) -> Result { + match value.parse::().map_err(|_| { + ChatError::InvalidEncoding(format!("call ICE candidate has invalid IP address {value}")) + })? { + IpAddr::V4(address) => Ok(V2CallIpAddress::Ipv4(address.octets())), + IpAddr::V6(address) => Ok(V2CallIpAddress::Ipv6(address.segments())), + } +} + +fn ip_address_string(address: &V2CallIpAddress) -> String { + match address { + V2CallIpAddress::Ipv4(bytes) => { + Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]).to_string() + } + V2CallIpAddress::Ipv6(segments) => Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + ) + .to_string(), + } +} + +fn sdp_type_index(value: V2CallSdpType) -> u8 { + match value { + V2CallSdpType::Offer => 0, + V2CallSdpType::Answer => 1, + } +} + +fn decode_sdp_type(value: u8) -> Result { + match value { + 0 => Ok(V2CallSdpType::Offer), + 1 => Ok(V2CallSdpType::Answer), + value => Err(ChatError::InvalidEncoding(format!( + "unsupported call SDP type {value}" + ))), + } +} + +fn parse_transport_type(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "tcp" => Ok(V2CallTransportType::Tcp), + "udp" => Ok(V2CallTransportType::Udp), + _ => Err(ChatError::InvalidEncoding(format!( + "unsupported call ICE transport {value}" + ))), + } +} + +fn transport_type_index(value: V2CallTransportType) -> u8 { + match value { + V2CallTransportType::Tcp => 0, + V2CallTransportType::Udp => 1, + } +} + +fn decode_transport_type(value: u8) -> Result { + match value { + 0 => Ok(V2CallTransportType::Tcp), + 1 => Ok(V2CallTransportType::Udp), + value => Err(ChatError::InvalidEncoding(format!( + "unsupported call ICE transport type {value}" + ))), + } +} + +fn transport_type_string(value: V2CallTransportType) -> &'static str { + match value { + V2CallTransportType::Tcp => "TCP", + V2CallTransportType::Udp => "UDP", + } +} + +fn parse_candidate_type(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "host" => Ok(V2CallCandidateType::Host), + "srflx" => Ok(V2CallCandidateType::Srflx), + "relay" => Ok(V2CallCandidateType::Relay), + "prflx" => Ok(V2CallCandidateType::Prflx), + _ => Err(ChatError::InvalidEncoding(format!( + "unsupported call ICE candidate type {value}" + ))), + } +} + +fn candidate_type_index(value: V2CallCandidateType) -> u8 { + match value { + V2CallCandidateType::Host => 0, + V2CallCandidateType::Srflx => 1, + V2CallCandidateType::Relay => 2, + V2CallCandidateType::Prflx => 3, + } +} + +fn candidate_type_string(value: V2CallCandidateType) -> &'static str { + match value { + V2CallCandidateType::Host => "host", + V2CallCandidateType::Srflx => "srflx", + V2CallCandidateType::Relay => "relay", + V2CallCandidateType::Prflx => "prflx", + } +} + +fn decode_candidate_type(value: u8) -> Result { + match value { + 0 => Ok(V2CallCandidateType::Host), + 1 => Ok(V2CallCandidateType::Srflx), + 2 => Ok(V2CallCandidateType::Relay), + 3 => Ok(V2CallCandidateType::Prflx), + value => Err(ChatError::InvalidEncoding(format!( + "unsupported call ICE candidate type {value}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OFFER_SDP: &str = "v=0\n\ + o=- 12345 67890 IN IP4 0.0.0.0\n\ + s=-\nt=0 0\n\ + a=ice-ufrag:ufrag123\n\ + a=ice-pwd:pwd123\n\ + a=fingerprint:sha-256 A1:B2:C3:D4\n\ + a=setup:actpass\n"; + + fn ice(sdp: &str) -> V2CallIceCandidate { + V2CallIceCandidate { + sdp: sdp.into(), + sdp_m_line_index: 0, + sdp_mid: Some("0".into()), + } + } + + // Fix 1: a malformed length prefix must not trigger a huge allocation; it + // should fail cleanly as truncated input. + #[test] + fn decode_candidates_rejects_oversized_length_without_oom() { + let mut data = encode_compact_u32(u32::MAX).to_vec(); + data.extend_from_slice(&[0x04, 0x31]); // one tiny partial candidate, then EOF + let err = decode_v2_call_candidates_payload(&data).unwrap_err(); + assert!(matches!(err, ChatError::InvalidEncoding(_))); + } + + #[test] + fn decode_peer_signal_rejects_oversized_length_without_oom() { + let data = [2, 3, 0xff, 0xff, 0xff, 0xff]; + let err = decode_v2_call_peer_connection_signal(&data).unwrap_err(); + assert!(matches!(err, ChatError::InvalidEncoding(_))); + } + + // Fix 2: a non-ASCII fingerprint must error, not panic on a char boundary. + #[test] + fn parse_fingerprint_rejects_non_ascii_without_panic() { + let err = parse_fingerprint("sha-256 A£B").unwrap_err(); + assert!(matches!(err, ChatError::InvalidEncoding(_))); + } + + // Fix 3: the wire format is sha-256 only; other algorithms must be rejected + // rather than silently relabelled on reconstruction. + #[test] + fn parse_fingerprint_rejects_non_sha256_algorithm() { + let err = parse_fingerprint("sha-1 A1:B2:C3:D4").unwrap_err(); + match err { + ChatError::InvalidEncoding(msg) => assert!(msg.contains("unsupported algorithm")), + other => panic!("unexpected error: {other:?}"), + } + // bare hex (no algorithm token) is still accepted as sha-256. + assert_eq!( + parse_fingerprint("A1:B2:C3:D4").unwrap(), + vec![0xA1, 0xB2, 0xC3, 0xD4] + ); + } + + // Fix 4: priority > i32::MAX is rejected on both the binary encode path and + // the binary decode path, matching the SDP parser — so the codec can never + // emit a value it would refuse to read back. + #[test] + fn priority_bound_enforced_on_encode_and_decode() { + let candidate = V2CallMinimalCandidate { + foundation: "1".into(), + priority: i32::MAX as u32 + 1, + transport_type: V2CallTransportType::Udp, + address: V2CallIpAddress::Ipv4([192, 168, 1, 1]), + port: 1234, + candidate_type: V2CallCandidateType::Host, + }; + assert!(encode_v2_call_candidates_payload(std::slice::from_ref(&candidate)).is_err()); + + // Hand-assemble a one-candidate payload with an out-of-range priority. + let mut data = encode_compact_u32(1).to_vec(); + data.extend_from_slice(&[0x04, 0x31]); // foundation "1" + data.extend_from_slice(&u32::MAX.to_le_bytes()); // priority + data.push(transport_type_index(V2CallTransportType::Udp)); + encode_ip_address(&mut data, &V2CallIpAddress::Ipv4([192, 168, 1, 1])); + data.extend_from_slice(&1234_u16.to_le_bytes()); + data.push(candidate_type_index(V2CallCandidateType::Host)); + assert!(decode_v2_call_candidates_payload(&data).is_err()); + } + + // Fix 5: a non-canonical (zero-padded big-mode) compact session id must be + // rejected so the wire format stays a 1:1 mapping. + #[test] + fn decode_setup_rejects_non_canonical_compact_session_id() { + // sdp_type=offer, then session_id encoded in big mode as value 5 with a + // trailing zero byte — a value that canonically fits in single-byte mode. + let data = [0x00, 0x03, 0x05, 0x00, 0x00, 0x00]; + assert!(decode_v2_call_setup_payload(&data).is_err()); + } + + // The candidate batch with an IPv6 (relay) entry must round-trip, covering + // the previously untested 16-byte address path. + #[test] + fn ipv6_candidate_round_trips() { + let bytes = encode_v2_call_candidates_from_sdp(&[ice( + "candidate:relay1 1 udp 123456 2001:db8::1 9999 typ relay", + )]) + .unwrap(); + let decoded = decode_v2_call_candidates_payload(&bytes).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!( + decoded[0].address, + V2CallIpAddress::Ipv6([0x2001, 0x0db8, 0, 0, 0, 0, 0, 1]) + ); + assert_eq!(decoded[0].candidate_type, V2CallCandidateType::Relay); + } + + // A well-formed offer SDP still parses end-to-end after the fixes. + #[test] + fn offer_sdp_still_encodes() { + let bytes = encode_v2_call_setup_from_sdp( + OFFER_SDP, + std::slice::from_ref(&ice( + "candidate:1 1 udp 2122260223 192.168.1.1 1234 typ host", + )), + ) + .unwrap(); + let decoded = decode_v2_call_setup_payload(&bytes).unwrap(); + assert_eq!(decoded.sdp_type, V2CallSdpType::Offer); + assert_eq!(decoded.session_id, 12_345); + assert_eq!(decoded.candidates.len(), 1); + } +} diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs new file mode 100644 index 000000000..c59209bb9 --- /dev/null +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -0,0 +1,3796 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! Chat v2 protocol primitives shared by native, mobile, and web hosts. +//! +//! Chat v2 is the deployed Statement Store chat protocol used by the current +//! iOS v2 and Android v2 applications. This crate intentionally stays pure: +//! no Statement Store I/O, no app persistence, and no UI state machine. It +//! centralizes the deterministic pieces that must not drift between hosts: +//! topic derivation, SCALE wire encoding, invite wrappers, and encrypted +//! request/response transport payloads. +extern crate alloc; + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use blake2::digest::consts::U32; +use blake2::digest::{Digest, KeyInit as BlakeKeyInit, Mac}; +use blake2::{Blake2b, Blake2bMac}; +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; + +#[cfg(feature = "std")] +pub mod call_payload; +#[cfg(feature = "std")] +pub use call_payload::*; + +/// Statement Store topic digest. +pub type Topic = [u8; 32]; +fn encode_compact_u32(value: u32) -> Vec { + if value < 1 << 6 { + vec![(value as u8) << 2] + } else if value < 1 << 14 { + (((value as u16) << 2) | 0b01).to_le_bytes().to_vec() + } else if value < 1 << 30 { + ((value << 2) | 0b10).to_le_bytes().to_vec() + } else { + let mut out = Vec::with_capacity(5); + out.push(0b11); + out.extend_from_slice(&value.to_le_bytes()); + out + } +} + +fn decode_compact_u32(data: &[u8]) -> Result<(u32, usize), String> { + let first = *data.first().ok_or_else(|| "compact: empty".to_string())?; + match first & 0b11 { + 0 => Ok((u32::from(first >> 2), 1)), + 1 => { + let bytes: [u8; 2] = data + .get(..2) + .ok_or_else(|| "compact: truncated 2-byte".to_string())? + .try_into() + .map_err(|_| "compact: truncated 2-byte".to_string())?; + let value = u32::from(u16::from_le_bytes(bytes) >> 2); + if value < 1 << 6 { + return Err("compact: non-canonical 2-byte".into()); + } + Ok((value, 2)) + } + 2 => { + let bytes: [u8; 4] = data + .get(..4) + .ok_or_else(|| "compact: truncated 4-byte".to_string())? + .try_into() + .map_err(|_| "compact: truncated 4-byte".to_string())?; + let value = u32::from_le_bytes(bytes) >> 2; + if value < 1 << 14 { + return Err("compact: non-canonical 4-byte".into()); + } + Ok((value, 4)) + } + 3 => { + if first >> 2 != 0 { + return Err("compact: value exceeds u32".into()); + } + let bytes: [u8; 4] = data + .get(1..5) + .ok_or_else(|| "compact: truncated big".to_string())? + .try_into() + .map_err(|_| "compact: truncated big".to_string())?; + let value = u32::from_le_bytes(bytes); + if value < 1 << 30 { + return Err("compact: non-canonical big".into()); + } + Ok((value, 5)) + } + _ => unreachable!(), + } +} + +fn blake2b_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Blake2b::::new(); + Digest::update(&mut hasher, data); + hasher.finalize().into() +} + +fn blake2b_256_keyed(key: &[u8], data: &[u8]) -> Result<[u8; 32], ChatError> { + if key.is_empty() { + return Err(ChatError::KeyDerivationFailed( + "BLAKE2b key must not be empty".into(), + )); + } + let mut mac = as BlakeKeyInit>::new_from_slice(key).map_err(|_| { + ChatError::KeyDerivationFailed(format!( + "BLAKE2b key length must be 1..=64, got {}", + key.len() + )) + })?; + Mac::update(&mut mac, data); + Ok(mac.finalize().into_bytes().into()) +} + +#[derive(Debug, thiserror::Error)] +pub enum ChatError { + #[error("invalid chat v2 encoding: {0}")] + InvalidEncoding(String), + + #[error("chat v2 key derivation failed: {0}")] + KeyDerivationFailed(String), +} + +/// Statement-store context used by the v2 first-contact chat-request protocol. +pub const CHAT_REQUEST_CONTEXT: &[u8] = b"chat-request"; + +/// Protocol epoch used by the v2 apps for day-partitioned request topics. +pub const PROTOCOL_EPOCH_SECONDS: u64 = 1_763_164_800; + +/// Number of seconds per v2 chat-request pagination day. +pub const SECONDS_IN_DAY: u64 = 86_400; +/// Context bound into multi-device Chat v2 identity proofs. +pub const MULTI_DEVICE_CHAT_REQUEST_CONTEXT: &str = "mds-chat-request"; +/// Domain separator for the opt-in context-bound cipher suite. +pub const CONTEXT_BOUND_CIPHER_DOMAIN: &[u8] = b"dotli-chat/context-bound/v1"; +const CONTEXT_BOUND_INVITE_MAGIC: &[u8; 8] = b"DCHAT\x03\0\0"; + +/// Derive an X25519 public key without platform services. +pub fn x25519_public_key(private_key: &[u8; 32]) -> [u8; 32] { + X25519PublicKey::from(&StaticSecret::from(*private_key)).to_bytes() +} + +/// Perform X25519 agreement, rejecting non-contributory peer keys. +pub fn x25519_shared_secret( + private_key: &[u8; 32], + peer_public_key: &[u8; 32], +) -> Result<[u8; 32], ChatError> { + let shared = StaticSecret::from(*private_key) + .diffie_hellman(&X25519PublicKey::from(*peer_public_key)) + .to_bytes(); + if shared == [0; 32] { + return Err(ChatError::KeyDerivationFailed( + "X25519 peer public key is non-contributory".into(), + )); + } + Ok(shared) +} + +/// Expand key material exactly as CryptoKit's +/// `hkdfDerivedSymmetricKey(SHA256, salt: empty, sharedInfo: empty, 32)`. +pub fn hkdf_sha256_32(input_key_material: &[u8]) -> Result<[u8; 32], ChatError> { + let mut output = [0; 32]; + Hkdf::::new(Some(&[]), input_key_material) + .expand(&[], &mut output) + .map_err(|_| ChatError::KeyDerivationFailed("HKDF-SHA256 expansion failed".into()))?; + Ok(output) +} + +/// Derive the Chat v2 AEAD key shared with a peer X25519 public key. +pub fn x25519_hkdf_sha256_key( + private_key: &[u8; 32], + peer_public_key: &[u8; 32], +) -> Result<[u8; 32], ChatError> { + hkdf_sha256_32(&x25519_shared_secret(private_key, peer_public_key)?) +} +/// Encrypt with the opt-in context-bound suite. +/// +/// The authenticated context binds the product/network identifier, sender, +/// recipient, route, and direction. The output remains nonce-prefixed. +pub fn context_bound_encrypt_with_nonce( + input_key_material: &[u8], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + plaintext: &[u8], + nonce: [u8; 12], +) -> Result, ChatError> { + let (key, aad) = context_bound_aead_material( + input_key_material, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + )?; + chacha20poly1305_encrypt_with_nonce_and_aad(&key, plaintext, nonce, &aad) +} + +/// Decrypt with the exact context selected by the sender. +pub fn context_bound_decrypt( + input_key_material: &[u8], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + ciphertext: &[u8], +) -> Result, ChatError> { + let (key, aad) = context_bound_aead_material( + input_key_material, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + )?; + chacha20poly1305_decrypt_with_aad(&key, ciphertext, &aad) +} + +/// X25519 agreement followed by context-bound encryption. +pub fn x25519_context_bound_encrypt_with_nonce( + private_key: &[u8; 32], + peer_public_key: &[u8; 32], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + plaintext: &[u8], + nonce: [u8; 12], +) -> Result, ChatError> { + context_bound_encrypt_with_nonce( + &x25519_shared_secret(private_key, peer_public_key)?, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + plaintext, + nonce, + ) +} + +/// X25519 agreement followed by context-bound decryption. +pub fn x25519_context_bound_decrypt( + private_key: &[u8; 32], + peer_public_key: &[u8; 32], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + ciphertext: &[u8], +) -> Result, ChatError> { + context_bound_decrypt( + &x25519_shared_secret(private_key, peer_public_key)?, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + ciphertext, + ) +} + +fn context_bound_aead_material( + input_key_material: &[u8], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], +) -> Result<([u8; 32], Vec), ChatError> { + let product_id_len = u32::try_from(product_id.len()) + .map_err(|_| ChatError::InvalidEncoding("product identifier is too long".into()))?; + let mut aad = + Vec::with_capacity(CONTEXT_BOUND_CIPHER_DOMAIN.len() + 4 + product_id.len() + 32 + 32 + 32); + aad.extend_from_slice(CONTEXT_BOUND_CIPHER_DOMAIN); + aad.extend_from_slice(&product_id_len.to_le_bytes()); + aad.extend_from_slice(product_id.as_bytes()); + aad.extend_from_slice(sender_account_id); + aad.extend_from_slice(recipient_account_id); + aad.extend_from_slice(channel_id); + let mut key = [0; 32]; + Hkdf::::new(Some(CONTEXT_BOUND_CIPHER_DOMAIN), input_key_material) + .expand(&aad, &mut key) + .map_err(|_| ChatError::KeyDerivationFailed("context-bound HKDF-SHA256 failed".into()))?; + Ok((key, aad)) +} + +/// Build the keyed proof binding an identity account to a product device. +/// +/// The keyed BLAKE2b-256 input is the raw identity account, raw device account, +/// then the SCALE string `mds-chat-request`. +pub fn v2_identity_proof( + identity_account_id: &[u8; 32], + device_account_id: &[u8; 32], + shared_secret: &[u8; 32], +) -> Result<[u8; 32], ChatError> { + let mut payload = Vec::with_capacity(64 + 1 + MULTI_DEVICE_CHAT_REQUEST_CONTEXT.len()); + payload.extend_from_slice(identity_account_id); + payload.extend_from_slice(device_account_id); + encode_string(&mut payload, MULTI_DEVICE_CHAT_REQUEST_CONTEXT)?; + blake2b_256_keyed(shared_secret, &payload) +} + +/// Supported subset of the v2 remote chat message content enum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2ChatMessageContent { + /// Plain text content. V2 enum index 0. + Text(String), + /// Token content. V2 enum index 1. + Token { + token: Vec, + platform: V2PushPlatform, + }, + /// Legacy send payload for iOS v2 compatibility. V2 enum index 2. + SendLegacy { + amount: String, + block_hash: Vec, + extrinsic_hash: Vec, + }, + /// Contact added notification (deprecated). V2 enum index 3. + ContactAdded, + /// Emoji reaction to a message. V2 enum index 4. + Reacted { message_id: String, emoji: String }, + /// Emoji reaction removal. V2 enum index 5. + ReactionRemoved { message_id: String, emoji: String }, + /// Reply to a message with own content. V2 enum index 7. + Reply { + message_id: String, + text: Option, + attachments: Option>, + }, + /// WebRTC/data-channel offer signaling. V2 enum index 8. + DataChannelOffer { + sdp: Vec, + purpose: V2DataChannelPurpose, + }, + /// WebRTC/data-channel answer signaling. V2 enum index 9. + DataChannelAnswer { offer_id: String, sdp: Vec }, + /// WebRTC/data-channel ICE candidates signaling. V2 enum index 10. + DataChannelCandidates { offer_id: String, sdp: Vec }, + /// WebRTC/data-channel closed signaling. V2 enum index 11. + DataChannelClosed { offer_id: String }, + /// Edited message content. V2 enum index 12. + Edited { + message_id: String, + new_text: Option, + attachments: Option>, + }, + /// User left the chat. V2 enum index 13. + LeftChat, + /// Chat request acceptance content. V2 enum index 14. + ChatAccepted { request_id: String }, + /// Rich text message. V2 enum index 15. + RichText { + text: Option, + attachments: Option>, + }, + /// Coinage payment. V2 enum index 16. + CoinageSend { + total_value: String, + coin_keys: Vec>, + }, + /// A device was added to the identity. V2 enum index 17. + DeviceAdded { + statement_account_id: Vec, + encryption_public_key: Vec, + }, + /// A device was removed from the identity. V2 enum index 18. + DeviceRemoved { statement_account_id: Vec }, + /// Reference to a compacted message batch. V2 enum index 19. + CompactedMessages { + claim_identifier: Vec, + claim_ticket: Vec, + node: V2NodeEndpoint, + }, + /// Multi-device chat acceptance. V2 wire enum index 20. + MultiChatAccepted { + request_id: String, + device: V2PeerDevice, + }, + /// The envelope was valid enough to recover id/timestamp, but the versioned + /// content wrapper is not yet represented by this SDK surface. + UnsupportedVersion { version_index: u8 }, + /// The V1 envelope was valid enough to recover id/timestamp, but its + /// content enum is not yet represented by this SDK surface. + UnsupportedContent { content_index: u8 }, +} + +/// V2 chat message statement payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatMessage { + pub message_id: String, + pub timestamp: u64, + pub content: V2ChatMessageContent, +} +/// Fixed-width device record carried by multi-device Chat v2 messages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2PeerDevice { + pub statement_account_id: [u8; 32], + pub encryption_public_key: [u8; 32], +} + +/// Endpoint for a Chat v2 attachment or compacted message batch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2NodeEndpoint { + /// Secure WebSocket URL. Endpoint enum index 0. + WssUrl(String), +} + +/// Attachment transport. Native SCALE enum index 0. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2FileVariant { + P2pMixnet(V2P2pMixnetFile), +} + +/// Private HOP reference; identifiers and claim tickets are exactly 32 bytes. +#[derive(Clone, PartialEq, Eq)] +pub struct V2P2pMixnetFile { + pub identifier: Vec, + pub claim_ticket: Vec, + pub node: V2NodeEndpoint, + pub meta: V2FileMeta, +} + +impl core::fmt::Debug for V2P2pMixnetFile { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("V2P2pMixnetFile") + .field("meta", &self.meta) + .finish_non_exhaustive() + } +} + +impl Drop for V2P2pMixnetFile { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.claim_ticket); + } +} + +/// Native SCALE metadata indices: General = 0, Image = 1, Video = 2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2FileMeta { + General(V2GeneralFileMeta), + Image(V2ImageFileMeta), + Video(V2VideoFileMeta), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2GeneralFileMeta { + pub mime_type: String, + pub file_size: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ImageFileMeta { + pub general: V2GeneralFileMeta, + pub width: u32, + pub height: u32, + /// Native UTF-8 BlurHash bytes, not an encoded image. + pub thumbnail: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2VideoFileMeta { + pub general: V2GeneralFileMeta, + pub duration: u32, + /// Native UTF-8 BlurHash bytes, not an encoded image. + pub thumbnail: Option>, +} + +/// Shared data-channel purpose enum used by the v2 apps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum V2DataChannelPurpose { + Audio, + Video, +} + +/// Transport-neutral call signaling model shared across v2 chat and host extensions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2CallSignal { + Offer { + offer_id: String, + sdp: Vec, + purpose: V2DataChannelPurpose, + }, + Answer { + offer_id: String, + sdp: Vec, + }, + Candidates { + offer_id: String, + sdp: Vec, + }, + Closed { + offer_id: String, + }, +} + +impl V2ChatMessage { + /// Lift supported v2 data-channel chat content into the shared call-signal model. + pub fn as_call_signal(&self) -> Option { + match &self.content { + V2ChatMessageContent::DataChannelOffer { sdp, purpose } => Some(V2CallSignal::Offer { + offer_id: self.message_id.clone(), + sdp: sdp.clone(), + purpose: *purpose, + }), + V2ChatMessageContent::DataChannelAnswer { offer_id, sdp } => { + Some(V2CallSignal::Answer { + offer_id: offer_id.clone(), + sdp: sdp.clone(), + }) + } + V2ChatMessageContent::DataChannelCandidates { offer_id, sdp } => { + Some(V2CallSignal::Candidates { + offer_id: offer_id.clone(), + sdp: sdp.clone(), + }) + } + V2ChatMessageContent::DataChannelClosed { offer_id } => Some(V2CallSignal::Closed { + offer_id: offer_id.clone(), + }), + _ => None, + } + } +} + +/// Push platform enum used inside first-contact chat requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum V2PushPlatform { + Android, + Ios, + IosVoip, +} + +/// Optional push token bundled with a first-contact chat request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2PushToken { + pub token: Vec, + pub platform: V2PushPlatform, +} + +/// Supported subset of the v2 first-contact request content. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestMessage { + pub message_id: String, + pub timestamp: u64, + pub push_token: Option, + pub welcome_text: Option, +} +/// Raw fixed-width keyed proof carried by request content V2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestIdentityProof { + pub identity_account_id: [u8; 32], + pub proof: [u8; 32], +} + +/// Current iOS multi-device first-contact request content (version index 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestContentV2 { + pub identity_proof: V2ChatRequestIdentityProof, + pub device_enc_pub_key: [u8; 32], + pub push_token: Option, + pub welcome_text: Option, +} + +/// First-contact request message carrying request content V2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestMessageV2 { + pub message_id: String, + pub timestamp: u64, + pub content: V2ChatRequestContentV2, +} + +/// Inner sr25519 proof carried by a first-contact chat request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestProof { + pub signature: Vec, + pub signer: Vec, +} + +/// Decrypted first-contact chat request payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequest { + pub message: V2ChatRequestMessage, + pub proof: V2ChatRequestProof, +} + +/// Decrypted first-contact payload carrying current request content V2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2ChatRequestV2 { + pub message: V2ChatRequestMessageV2, + pub proof: V2ChatRequestProof, +} + +/// Encrypted transport wrapper for first-contact chat requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2EncryptedChatRequest { + pub encryption_pubkey: Vec, + pub encrypted_request: Vec, +} + +/// Per-recipient wrapped one-shot key in a multi-device transport. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2RequestDeviceInfo { + pub statement_account_id: [u8; 32], + pub encrypted_key: Vec, +} + +/// Multi-device request envelope, StatementData index 2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2MultiDeviceRequest { + pub encrypted_request: Vec, + pub devices_info: Vec, +} + +/// Multi-device response envelope, StatementData index 3. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2MultiDeviceResponse { + pub encrypted_response: Vec, + pub devices_info: Vec, +} + +/// Bare MessageExchange request encrypted inside a multi-device request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2MessageExchangeRequest { + pub request_id: String, + pub messages: Vec>, +} + +/// Bare MessageExchange response encrypted inside a multi-device response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2MessageExchangeResponse { + pub request_id: String, + pub response_code: u8, +} + +/// Ongoing v2 statement-store transport payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum V2StatementTransportData { + Request { + request_id: String, + messages: Vec>, + }, + Response { + request_id: String, + response_code: u8, + }, + MultiRequest(V2MultiDeviceRequest), + MultiResponse(V2MultiDeviceResponse), +} + +/// Calculate the v2 day number from a Unix timestamp in seconds. +/// +/// Returns `None` for timestamps before the v2 protocol epoch. +pub fn chat_request_day_from_unix(unix_timestamp_seconds: u64) -> Option { + unix_timestamp_seconds + .checked_sub(PROTOCOL_EPOCH_SECONDS) + .map(|relative| relative / SECONDS_IN_DAY) +} + +/// Derive the full-history first-contact request topic for an acceptor account. +/// +/// Matches v2 iOS/Android: +/// `blake2b256(scale(Data("chat-request")) || scale(Data(acceptor_account_id)))`. +pub fn chat_request_full_topic(acceptor_account_id: &[u8; 32]) -> Topic { + derive_chat_request_topic(CHAT_REQUEST_CONTEXT, acceptor_account_id, &[]) +} + +/// Derive the day-partitioned first-contact request topic for an acceptor account. +/// +/// Matches v2 iOS/Android: +/// `blake2b256(scale(Data("chat-request")) || scale(Data(acceptor_account_id)) || scale(UInt64(day)))`. +pub fn chat_request_day_topic(acceptor_account_id: &[u8; 32], day: u64) -> Topic { + chat_request_day_topic_with_context(CHAT_REQUEST_CONTEXT, acceptor_account_id, day) +} + +fn chat_request_day_topic_with_context( + context: &[u8], + acceptor_account_id: &[u8; 32], + day: u64, +) -> Topic { + let day_bytes = day.to_le_bytes(); + derive_chat_request_topic(context, acceptor_account_id, &day_bytes) +} + +fn derive_chat_request_topic( + context: &[u8], + acceptor_account_id: &[u8; 32], + suffix: &[u8], +) -> Topic { + let mut input = Vec::with_capacity( + compact_len_size(context.len() as u32) + + context.len() + + compact_len_size(acceptor_account_id.len() as u32) + + acceptor_account_id.len() + + suffix.len(), + ); + input.extend_from_slice(&encode_compact_u32(context.len() as u32)); + input.extend_from_slice(context); + input.extend_from_slice(&encode_compact_u32(acceptor_account_id.len() as u32)); + input.extend_from_slice(acceptor_account_id); + input.extend_from_slice(suffix); + blake2b_256(&input) +} + +fn compact_len_size(value: u32) -> usize { + encode_compact_u32(value).len() +} + +/// Build v2 session id parameters: +/// `requester_account_id || acceptor_account_id || "/" || requester_pin || "/" || acceptor_pin`. +pub fn chat_request_session_id_params( + requester_account_id: &[u8; 32], + requester_pin: Option<&str>, + acceptor_account_id: &[u8; 32], + acceptor_pin: Option<&str>, +) -> Vec { + let requester_pin = requester_pin.unwrap_or("").as_bytes(); + let acceptor_pin = acceptor_pin.unwrap_or("").as_bytes(); + let mut out = Vec::with_capacity(32 + 32 + 1 + requester_pin.len() + 1 + acceptor_pin.len()); + out.extend_from_slice(requester_account_id); + out.extend_from_slice(acceptor_account_id); + out.push(b'/'); + out.extend_from_slice(requester_pin); + out.push(b'/'); + out.extend_from_slice(acceptor_pin); + out +} + +/// Derive the session fallback first-contact request topic. +/// +/// Matches v2 Android: +/// `keyed_blake2b256(shared_secret, "chat-request" || session_id_params)`. +pub fn chat_request_session_topic( + shared_secret: &[u8], + requester_account_id: &[u8; 32], + requester_pin: Option<&str>, + acceptor_account_id: &[u8; 32], + acceptor_pin: Option<&str>, +) -> Result { + if shared_secret.len() != 32 { + return Err(ChatError::KeyDerivationFailed(format!( + "shared_secret must be 32 bytes, got {}", + shared_secret.len() + ))); + } + + let session_id_params = chat_request_session_id_params( + requester_account_id, + requester_pin, + acceptor_account_id, + acceptor_pin, + ); + let mut input = Vec::with_capacity(CHAT_REQUEST_CONTEXT.len() + session_id_params.len()); + input.extend_from_slice(CHAT_REQUEST_CONTEXT); + input.extend_from_slice(&session_id_params); + blake2b_256_keyed(shared_secret, &input) +} + +/// Derive an ongoing identity session id in one account direction. +pub fn chat_identity_session_id( + shared_secret: &[u8; 32], + first_account_id: &[u8; 32], + first_pin: Option<&str>, + second_account_id: &[u8; 32], + second_pin: Option<&str>, +) -> Result<[u8; 32], ChatError> { + let params = + chat_request_session_id_params(first_account_id, first_pin, second_account_id, second_pin); + let mut input = Vec::with_capacity(7 + params.len()); + input.extend_from_slice(b"session"); + input.extend_from_slice(¶ms); + blake2b_256_keyed(shared_secret, &input) +} + +/// Derive the request topic for an ongoing identity session. +pub fn chat_identity_request_topic(session_id: &[u8; 32]) -> Result { + blake2b_256_keyed(session_id, b"request") +} + +/// Derive the response topic for an ongoing identity session. +pub fn chat_identity_response_topic(session_id: &[u8; 32]) -> Result { + blake2b_256_keyed(session_id, b"response") +} + +/// Encode a v2 text message statement payload. +pub fn encode_text_message( + message_id: &str, + timestamp: u64, + text: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(0); + encode_string(out, text) + }) +} + +/// Encode a v2 chat-accepted message statement payload. +pub fn encode_chat_accepted_message( + message_id: &str, + timestamp: u64, + request_id: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(14); + encode_string(out, request_id) + }) +} + +/// Encode a v2 token message statement payload. +pub fn encode_token_message( + message_id: &str, + timestamp: u64, + token: &[u8], + platform: V2PushPlatform, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(1); + encode_bytes(out, token)?; + out.push(push_platform_index(platform)); + Ok(()) + }) +} + +/// Encode a v2 legacy send message statement payload. +pub fn encode_send_legacy_message( + message_id: &str, + timestamp: u64, + amount: &str, + block_hash: &[u8], + extrinsic_hash: &[u8], +) -> Result, ChatError> { + if block_hash.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "legacy send block_hash must be 32 bytes, got {}", + block_hash.len() + ))); + } + if extrinsic_hash.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "legacy send extrinsic_hash must be 32 bytes, got {}", + extrinsic_hash.len() + ))); + } + encode_message(message_id, timestamp, |out| { + out.push(2); + encode_balance(out, amount)?; + out.extend_from_slice(block_hash); + out.extend_from_slice(extrinsic_hash); + Ok(()) + }) +} + +/// Encode a v2 contact-added message statement payload. +pub fn encode_contact_added_message( + message_id: &str, + timestamp: u64, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(3); + Ok(()) + }) +} + +/// Encode a v2 reacted message statement payload. +pub fn encode_reacted_message( + message_id: &str, + timestamp: u64, + referenced_message_id: &str, + emoji: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(4); + encode_string(out, referenced_message_id)?; + encode_string(out, emoji) + }) +} + +/// Encode a v2 reaction-removed message statement payload. +pub fn encode_reaction_removed_message( + message_id: &str, + timestamp: u64, + referenced_message_id: &str, + emoji: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(5); + encode_string(out, referenced_message_id)?; + encode_string(out, emoji) + }) +} + +/// Encode a v2 reply message statement payload. +pub fn encode_reply_message( + message_id: &str, + timestamp: u64, + referenced_message_id: &str, + text: Option<&str>, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(7); + encode_string(out, referenced_message_id)?; + encode_rich_text(out, text, None) + }) +} + +/// Encode a v2 edited message statement payload. +pub fn encode_edited_message( + message_id: &str, + timestamp: u64, + referenced_message_id: &str, + new_text: Option<&str>, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(12); + encode_string(out, referenced_message_id)?; + encode_rich_text(out, new_text, None) + }) +} + +/// Encode a v2 left-chat message statement payload. +pub fn encode_left_chat_message(message_id: &str, timestamp: u64) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(13); + Ok(()) + }) +} + +/// Encode a v2 rich-text message statement payload. +pub fn encode_rich_text_message( + message_id: &str, + timestamp: u64, + text: Option<&str>, + attachments: Option<&[V2FileVariant]>, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(15); + encode_rich_text(out, text, attachments) + }) +} + +/// Encode a v2 coinage-send message statement payload. +pub fn encode_coinage_send_message( + message_id: &str, + timestamp: u64, + total_value: &str, + coin_keys: &[Vec], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(16); + encode_balance(out, total_value)?; + encode_vec_of_bytes(out, coin_keys) + }) +} +/// Encode a v2 device-added message (content index 17). +pub fn encode_device_added_message( + message_id: &str, + timestamp: u64, + statement_account_id: &[u8], + encryption_public_key: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(17); + encode_bytes(out, statement_account_id)?; + encode_bytes(out, encryption_public_key) + }) +} + +/// Encode a v2 device-removed message (content index 18). +pub fn encode_device_removed_message( + message_id: &str, + timestamp: u64, + statement_account_id: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(18); + encode_bytes(out, statement_account_id) + }) +} + +/// Encode a v2 compacted-messages reference (content index 19). +pub fn encode_compacted_messages_message( + message_id: &str, + timestamp: u64, + claim_identifier: &[u8], + claim_ticket: &[u8], + node: &V2NodeEndpoint, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(19); + encode_bytes(out, claim_identifier)?; + encode_bytes(out, claim_ticket)?; + match node { + V2NodeEndpoint::WssUrl(url) => { + out.push(0); + encode_string(out, url) + } + } + }) +} + +/// Encode a v2 multi-device chat acceptance (wire content index 20). +pub fn encode_multi_chat_accepted_message( + message_id: &str, + timestamp: u64, + request_id: &str, + device: &V2PeerDevice, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(20); + encode_string(out, request_id)?; + out.extend_from_slice(&device.statement_account_id); + out.extend_from_slice(&device.encryption_public_key); + Ok(()) + }) +} + +/// Encode a transport-neutral call signal into the v2 chat message wire format. +pub fn encode_call_signal_message( + message_id: &str, + timestamp: u64, + signal: &V2CallSignal, +) -> Result, ChatError> { + match signal { + V2CallSignal::Offer { sdp, purpose, .. } => { + encode_data_channel_offer_message(message_id, timestamp, sdp, *purpose) + } + V2CallSignal::Answer { offer_id, sdp } => { + encode_data_channel_answer_message(message_id, timestamp, offer_id, sdp) + } + V2CallSignal::Candidates { offer_id, sdp } => { + encode_data_channel_candidates_message(message_id, timestamp, offer_id, sdp) + } + V2CallSignal::Closed { offer_id } => { + encode_data_channel_closed_message(message_id, timestamp, offer_id) + } + } +} + +/// Encode a v2 data-channel-offer message statement payload. +pub fn encode_data_channel_offer_message( + message_id: &str, + timestamp: u64, + sdp: &[u8], + purpose: V2DataChannelPurpose, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(8); + encode_bytes(out, sdp)?; + out.push(match purpose { + V2DataChannelPurpose::Audio => 0, + V2DataChannelPurpose::Video => 1, + }); + Ok(()) + }) +} + +/// Encode a v2 data-channel-answer message statement payload. +pub fn encode_data_channel_answer_message( + message_id: &str, + timestamp: u64, + offer_id: &str, + sdp: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(9); + encode_string(out, offer_id)?; + encode_bytes(out, sdp) + }) +} + +/// Encode a v2 data-channel-candidates message statement payload. +pub fn encode_data_channel_candidates_message( + message_id: &str, + timestamp: u64, + offer_id: &str, + sdp: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(10); + encode_string(out, offer_id)?; + encode_bytes(out, sdp) + }) +} + +/// Encode a v2 data-channel-closed message statement payload. +pub fn encode_data_channel_closed_message( + message_id: &str, + timestamp: u64, + offer_id: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(11); + encode_string(out, offer_id) + }) +} + +/// Decode a v2 chat message statement payload. +/// +/// The decoder preserves `message_id` and `timestamp` for unsupported content +/// variants so host apps can store/display an unsupported placeholder. +pub fn decode_message(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Ok(V2ChatMessage { + message_id, + timestamp, + content: V2ChatMessageContent::UnsupportedVersion { version_index }, + }); + } + + let content_index = cursor.read_u8("content_index")?; + let content = match content_index { + 0 => { + let text = cursor.read_string("text")?; + cursor.finish()?; + V2ChatMessageContent::Text(text) + } + 1 => { + let token = cursor.read_bytes("push_token")?; + let platform = decode_push_platform(cursor.read_u8("push_platform")?)?; + cursor.finish()?; + V2ChatMessageContent::Token { token, platform } + } + 2 => { + let amount = cursor.read_balance("amount")?; + let block_hash = cursor.read_exact(32, "block_hash")?.to_vec(); + let extrinsic_hash = cursor.read_exact(32, "extrinsic_hash")?.to_vec(); + cursor.finish()?; + V2ChatMessageContent::SendLegacy { + amount, + block_hash, + extrinsic_hash, + } + } + 3 => { + cursor.finish()?; + V2ChatMessageContent::ContactAdded + } + 4 => { + let message_id = cursor.read_string("referenced_message_id")?; + let emoji = cursor.read_string("emoji")?; + cursor.finish()?; + V2ChatMessageContent::Reacted { message_id, emoji } + } + 5 => { + let message_id = cursor.read_string("referenced_message_id")?; + let emoji = cursor.read_string("emoji")?; + cursor.finish()?; + V2ChatMessageContent::ReactionRemoved { message_id, emoji } + } + 7 => { + let message_id = cursor.read_string("referenced_message_id")?; + let (text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::Reply { + message_id, + text, + attachments, + } + } + 8 => { + let sdp = cursor.read_bytes("sdp")?; + let purpose = match cursor.read_u8("purpose")? { + 0 => V2DataChannelPurpose::Audio, + 1 => V2DataChannelPurpose::Video, + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported data channel purpose {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::DataChannelOffer { sdp, purpose } + } + 9 => { + let offer_id = cursor.read_string("offer_id")?; + let sdp = cursor.read_bytes("sdp")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelAnswer { offer_id, sdp } + } + 10 => { + let offer_id = cursor.read_string("offer_id")?; + let sdp = cursor.read_bytes("sdp")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelCandidates { offer_id, sdp } + } + 11 => { + let offer_id = cursor.read_string("offer_id")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelClosed { offer_id } + } + 12 => { + let message_id = cursor.read_string("referenced_message_id")?; + let (new_text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::Edited { + message_id, + new_text, + attachments, + } + } + 13 => { + cursor.finish()?; + V2ChatMessageContent::LeftChat + } + 14 => { + let request_id = cursor.read_string("request_id")?; + cursor.finish()?; + V2ChatMessageContent::ChatAccepted { request_id } + } + 15 => { + let (text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::RichText { text, attachments } + } + 16 => { + let total_value = cursor.read_balance("total_value")?; + let coin_keys = cursor.read_vec_of_bytes("coin_keys")?; + cursor.finish()?; + V2ChatMessageContent::CoinageSend { + total_value, + coin_keys, + } + } + 17 => { + let statement_account_id = cursor.read_bytes("statement_account_id")?; + let encryption_public_key = cursor.read_bytes("encryption_public_key")?; + cursor.finish()?; + V2ChatMessageContent::DeviceAdded { + statement_account_id, + encryption_public_key, + } + } + 18 => { + let statement_account_id = cursor.read_bytes("statement_account_id")?; + cursor.finish()?; + V2ChatMessageContent::DeviceRemoved { + statement_account_id, + } + } + 19 => { + let claim_identifier = cursor.read_bytes("claim_identifier")?; + let claim_ticket = cursor.read_bytes("claim_ticket")?; + let node = match cursor.read_u8("node_endpoint")? { + 0 => V2NodeEndpoint::WssUrl(cursor.read_string("wss_url")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported node endpoint {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::CompactedMessages { + claim_identifier, + claim_ticket, + node, + } + } + 20 => { + let request_id = cursor.read_string("request_id")?; + let statement_account_id = cursor.read_array_32("device_statement_account_id")?; + let encryption_public_key = cursor.read_array_32("device_encryption_public_key")?; + cursor.finish()?; + V2ChatMessageContent::MultiChatAccepted { + request_id, + device: V2PeerDevice { + statement_account_id, + encryption_public_key, + }, + } + } + index => V2ChatMessageContent::UnsupportedContent { + content_index: index, + }, + }; + + Ok(V2ChatMessage { + message_id, + timestamp, + content, + }) +} + +/// Encode a first-contact chat request message. +pub fn encode_chat_request_message( + message_id: &str, + timestamp: u64, + push_token: Option<&V2PushToken>, + welcome_text: Option<&str>, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, message_id)?; + out.extend_from_slice(×tamp.to_le_bytes()); + out.push(0); // VersionedRequestContent::V1 + + encode_optional_push_token(&mut out, push_token)?; + encode_optional_rich_text(&mut out, welcome_text)?; + + Ok(out) +} + +/// Decode a first-contact chat request message. +pub fn decode_chat_request_message(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 chat request version index {version_index}" + ))); + } + + let push_token = decode_optional_push_token(&mut cursor)?; + let welcome_text = decode_optional_rich_text(&mut cursor)?; + cursor.finish()?; + + Ok(V2ChatRequestMessage { + message_id, + timestamp, + push_token, + welcome_text, + }) +} + +/// Encode the proof payload that the requester signs for a first-contact chat request. +pub fn encode_chat_request_proof_payload( + message: &V2ChatRequestMessage, + acceptor_account_id: &[u8; 32], +) -> Result, ChatError> { + let mut out = encode_chat_request_message( + &message.message_id, + message.timestamp, + message.push_token.as_ref(), + message.welcome_text.as_deref(), + )?; + encode_bytes(&mut out, acceptor_account_id)?; + Ok(out) +} + +/// Encode a decrypted first-contact chat request payload. +pub fn encode_chat_request(request: &V2ChatRequest) -> Result, ChatError> { + if request.proof.signature.len() != 64 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signature must be 64 bytes, got {}", + request.proof.signature.len() + ))); + } + if request.proof.signer.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signer must be 32 bytes, got {}", + request.proof.signer.len() + ))); + } + + let mut out = encode_chat_request_message( + &request.message.message_id, + request.message.timestamp, + request.message.push_token.as_ref(), + request.message.welcome_text.as_deref(), + )?; + out.push(0); // StatementProof::sr25519 + out.extend_from_slice(&request.proof.signature); + out.extend_from_slice(&request.proof.signer); + Ok(out) +} +/// Encode a first-contact request carrying current multi-device content V2. +pub fn encode_chat_request_message_v2( + message: &V2ChatRequestMessageV2, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, &message.message_id)?; + out.extend_from_slice(&message.timestamp.to_le_bytes()); + out.push(1); + out.extend_from_slice(&message.content.identity_proof.identity_account_id); + out.extend_from_slice(&message.content.identity_proof.proof); + out.extend_from_slice(&message.content.device_enc_pub_key); + encode_optional_push_token(&mut out, message.content.push_token.as_ref())?; + encode_optional_rich_text(&mut out, message.content.welcome_text.as_deref())?; + Ok(out) +} + +/// Decode a first-contact request carrying current multi-device content V2. +pub fn decode_chat_request_message_v2(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_v2_cursor(&mut cursor)?; + cursor.finish()?; + Ok(message) +} + +/// Encode the sr25519 proof payload for request content V2. +pub fn encode_chat_request_v2_proof_payload( + message: &V2ChatRequestMessageV2, + acceptor_account_id: &[u8; 32], +) -> Result, ChatError> { + let mut out = encode_chat_request_message_v2(message)?; + encode_bytes(&mut out, acceptor_account_id)?; + Ok(out) +} + +/// Encode a decrypted first-contact request carrying request content V2. +pub fn encode_chat_request_v2(request: &V2ChatRequestV2) -> Result, ChatError> { + validate_chat_request_proof(&request.proof)?; + let mut out = encode_chat_request_message_v2(&request.message)?; + out.push(0); // StatementProof::sr25519 + out.extend_from_slice(&request.proof.signature); + out.extend_from_slice(&request.proof.signer); + Ok(out) +} + +/// Decode a decrypted first-contact request carrying request content V2. +pub fn decode_chat_request_v2(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_v2_cursor(&mut cursor)?; + let proof = decode_chat_request_proof(&mut cursor)?; + cursor.finish()?; + Ok(V2ChatRequestV2 { message, proof }) +} + +/// Decode a decrypted first-contact chat request payload. +pub fn decode_chat_request(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_cursor(&mut cursor)?; + let proof_index = cursor.read_u8("proof_index")?; + if proof_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported chat request proof index {proof_index}" + ))); + } + let signature = cursor.read_exact(64, "proof_signature")?.to_vec(); + let signer = cursor.read_exact(32, "proof_signer")?.to_vec(); + cursor.finish()?; + + Ok(V2ChatRequest { + message, + proof: V2ChatRequestProof { signature, signer }, + }) +} + +/// Encode the encrypted outer wrapper for a first-contact chat request. +pub fn encode_encrypted_chat_request( + request: &V2EncryptedChatRequest, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_bytes(&mut out, &request.encryption_pubkey)?; + encode_bytes(&mut out, &request.encrypted_request)?; + Ok(out) +} + +/// Decode the encrypted outer wrapper for a first-contact chat request. +pub fn decode_encrypted_chat_request(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let encryption_pubkey = cursor.read_bytes("encryption_pubkey")?; + let encrypted_request = cursor.read_bytes("encrypted_request")?; + cursor.finish()?; + Ok(V2EncryptedChatRequest { + encryption_pubkey, + encrypted_request, + }) +} + +/// Encode and seal request content V2 with a caller-provided fresh ephemeral +/// private key and unique nonce. +/// +/// The output is `SCALE Data(ephemeral_public_key) || SCALE +/// Data(nonce || ciphertext || tag)`. +pub fn seal_chat_request_v2_with_nonce( + ephemeral_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + request: &V2ChatRequestV2, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_chat_request_v2(request)?; + let key = x25519_hkdf_sha256_key(ephemeral_private_key, peer_public_key)?; + let encrypted_request = chacha20poly1305_encrypt_with_nonce(&key, &plaintext, nonce)?; + encode_encrypted_chat_request(&V2EncryptedChatRequest { + encryption_pubkey: x25519_public_key(ephemeral_private_key).to_vec(), + encrypted_request, + }) +} + +/// Encode and seal request content V2 with OS-generated ephemeral key material +/// and nonce. +#[cfg(feature = "std")] +pub fn seal_chat_request_v2( + peer_public_key: &[u8; 32], + request: &V2ChatRequestV2, +) -> Result, ChatError> { + let ephemeral_private_key = random_bytes_32()?; + seal_chat_request_v2_with_nonce( + &ephemeral_private_key, + peer_public_key, + request, + random_nonce()?, + ) +} + +/// Open and decode a request-content-V2 first-contact wrapper. +pub fn open_chat_request_v2( + static_private_key: &[u8; 32], + encoded_wrapper: &[u8], +) -> Result { + let wrapper = decode_encrypted_chat_request(encoded_wrapper)?; + let ephemeral_public_key: [u8; 32] = + wrapper + .encryption_pubkey + .try_into() + .map_err(|key: Vec| { + ChatError::InvalidEncoding(format!( + "first-contact ephemeral public key must be 32 bytes, got {}", + key.len() + )) + })?; + let key = x25519_hkdf_sha256_key(static_private_key, &ephemeral_public_key)?; + let plaintext = chacha20poly1305_decrypt(&key, &wrapper.encrypted_request)?; + decode_chat_request_v2(&plaintext) +} +/// Seal a first-contact request with explicit product, account, route, and +/// direction binding. The clear header is authenticated as AEAD context. +pub fn seal_context_bound_chat_request_v2_with_nonce( + ephemeral_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + request: &V2ChatRequestV2, + nonce: [u8; 12], +) -> Result, ChatError> { + if request.message.content.identity_proof.identity_account_id != *sender_account_id { + return Err(ChatError::InvalidEncoding( + "context-bound invite sender does not match its identity proof".into(), + )); + } + let ephemeral_public_key = x25519_public_key(ephemeral_private_key); + let encrypted = x25519_context_bound_encrypt_with_nonce( + ephemeral_private_key, + peer_public_key, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + &encode_chat_request_v2(request)?, + nonce, + )?; + let mut out = Vec::with_capacity(CONTEXT_BOUND_INVITE_MAGIC.len() + 128 + encrypted.len()); + out.extend_from_slice(CONTEXT_BOUND_INVITE_MAGIC); + out.extend_from_slice(sender_account_id); + out.extend_from_slice(recipient_account_id); + out.extend_from_slice(channel_id); + out.extend_from_slice(&ephemeral_public_key); + out.extend_from_slice(&encrypted); + Ok(out) +} + +/// Clear first-contact header. Fields remain untrusted until AEAD opening and +/// verification of the decrypted request identity and proof. +pub struct V2ContextBoundChatRequest<'a> { + pub sender_account_id: [u8; 32], + pub recipient_account_id: [u8; 32], + pub channel_id: [u8; 32], + pub ephemeral_public_key: [u8; 32], + pub encrypted_request: &'a [u8], +} + +/// Recognize the context-bound format family, including unsupported versions. +/// Invalid members of this family must never be retried as legacy invites. +pub fn is_context_bound_chat_request_v2(encoded_wrapper: &[u8]) -> bool { + encoded_wrapper.starts_with(&CONTEXT_BOUND_INVITE_MAGIC[..5]) +} + +/// Decode and check the clear header without accessing identity secrets. +/// The caller must authenticate ciphertext and verify the decrypted sender. +pub fn decode_context_bound_chat_request_v2<'a>( + expected_recipient_account_id: &[u8; 32], + expected_channel_id: &[u8; 32], + encoded_wrapper: &'a [u8], +) -> Result, ChatError> { + const HEADER_LEN: usize = 8 + 32 + 32 + 32 + 32; + if encoded_wrapper.len() < HEADER_LEN + 28 + || &encoded_wrapper[..CONTEXT_BOUND_INVITE_MAGIC.len()] != CONTEXT_BOUND_INVITE_MAGIC + { + return Err(ChatError::InvalidEncoding( + "invalid context-bound invite header".into(), + )); + } + let sender_account_id: [u8; 32] = encoded_wrapper[8..40] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite sender".into()))?; + let recipient_account_id: [u8; 32] = encoded_wrapper[40..72] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite recipient".into()))?; + let channel_id: [u8; 32] = encoded_wrapper[72..104] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite route".into()))?; + let ephemeral_public_key: [u8; 32] = + encoded_wrapper[104..HEADER_LEN].try_into().map_err(|_| { + ChatError::InvalidEncoding("invalid context-bound invite ephemeral key".into()) + })?; + if recipient_account_id != *expected_recipient_account_id || channel_id != *expected_channel_id + { + return Err(ChatError::InvalidEncoding( + "context-bound invite recipient or route mismatch".into(), + )); + } + Ok(V2ContextBoundChatRequest { + sender_account_id, + recipient_account_id, + channel_id, + ephemeral_public_key, + encrypted_request: &encoded_wrapper[HEADER_LEN..], + }) +} + +/// Open a context-bound first-contact request without legacy fallback. +pub fn open_context_bound_chat_request_v2( + static_private_key: &[u8; 32], + product_id: &str, + expected_recipient_account_id: &[u8; 32], + expected_channel_id: &[u8; 32], + encoded_wrapper: &[u8], +) -> Result { + let V2ContextBoundChatRequest { + sender_account_id, + recipient_account_id, + channel_id, + ephemeral_public_key, + encrypted_request, + } = decode_context_bound_chat_request_v2( + expected_recipient_account_id, + expected_channel_id, + encoded_wrapper, + )?; + let plaintext = x25519_context_bound_decrypt( + static_private_key, + &ephemeral_public_key, + product_id, + &sender_account_id, + &recipient_account_id, + &channel_id, + encrypted_request, + )?; + let request = decode_chat_request_v2(&plaintext)?; + if request.message.content.identity_proof.identity_account_id != sender_account_id { + return Err(ChatError::InvalidEncoding( + "context-bound invite identity proof does not match sender".into(), + )); + } + Ok(request) +} + +/// Encode the plaintext StatementData::request payload without applying AEAD. +pub fn encode_transport_request_plaintext( + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(0); + encode_string(&mut plaintext, request_id)?; + encode_vec_of_bytes(&mut plaintext, messages)?; + Ok(plaintext) +} + +/// Encode the plaintext StatementData::response payload without applying AEAD. +pub fn encode_transport_response_plaintext( + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(1); + encode_string(&mut plaintext, request_id)?; + plaintext.push(response_code); + Ok(plaintext) +} + +/// Encode bare MessageExchange::Request plaintext for the encryptedRequest +/// field of StatementData::MultiRequest. +pub fn encode_message_exchange_request_plaintext( + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + encode_string(&mut plaintext, request_id)?; + encode_vec_of_bytes(&mut plaintext, messages)?; + Ok(plaintext) +} + +/// Decode bare MessageExchange::Request plaintext. +pub fn decode_message_exchange_request_plaintext( + plaintext: &[u8], +) -> Result { + let mut cursor = Cursor::new(plaintext); + let request_id = cursor.read_string("request_id")?; + let messages = cursor.read_vec_of_bytes("messages")?; + cursor.finish()?; + Ok(V2MessageExchangeRequest { + request_id, + messages, + }) +} + +/// Encode bare MessageExchange::Response plaintext for the encryptedResponse +/// field of StatementData::MultiResponse. +pub fn encode_message_exchange_response_plaintext( + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + encode_string(&mut plaintext, request_id)?; + plaintext.push(response_code); + Ok(plaintext) +} + +/// Decode bare MessageExchange::Response plaintext. +pub fn decode_message_exchange_response_plaintext( + plaintext: &[u8], +) -> Result { + let mut cursor = Cursor::new(plaintext); + let request_id = cursor.read_string("request_id")?; + let response_code = cursor.read_u8("response_code")?; + cursor.finish()?; + Ok(V2MessageExchangeResponse { + request_id, + response_code, + }) +} + +/// Encode the plaintext StatementData::multirequest payload without applying AEAD. +pub fn encode_transport_multi_request_plaintext( + request: &V2MultiDeviceRequest, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(2); + encode_bytes(&mut plaintext, &request.encrypted_request)?; + encode_request_device_infos(&mut plaintext, &request.devices_info)?; + Ok(plaintext) +} + +/// Encode the plaintext StatementData::multiresponse payload without applying AEAD. +pub fn encode_transport_multi_response_plaintext( + response: &V2MultiDeviceResponse, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(3); + encode_bytes(&mut plaintext, &response.encrypted_response)?; + encode_request_device_infos(&mut plaintext, &response.devices_info)?; + Ok(plaintext) +} + +/// Encode and encrypt an ongoing v2 statement-store request payload with OS +/// randomness. Guests must use [`encode_transport_request_with_nonce`]. +#[cfg(feature = "std")] +pub fn encode_transport_request( + aead_key: &[u8; 32], + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + encode_transport_request_with_nonce(aead_key, request_id, messages, random_nonce()?) +} + +/// Encode and encrypt a request with a caller-supplied unique nonce. +pub fn encode_transport_request_with_nonce( + aead_key: &[u8; 32], + request_id: &str, + messages: &[Vec], + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_request_plaintext(request_id, messages)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt an ongoing v2 response with OS randomness. Guests must +/// use [`encode_transport_response_with_nonce`]. +#[cfg(feature = "std")] +pub fn encode_transport_response( + aead_key: &[u8; 32], + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + encode_transport_response_with_nonce(aead_key, request_id, response_code, random_nonce()?) +} + +/// Encode and encrypt a response with a caller-supplied unique nonce. +pub fn encode_transport_response_with_nonce( + aead_key: &[u8; 32], + request_id: &str, + response_code: u8, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_response_plaintext(request_id, response_code)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt StatementData::multirequest with OS randomness. +#[cfg(feature = "std")] +pub fn encode_transport_multi_request( + aead_key: &[u8; 32], + request: &V2MultiDeviceRequest, +) -> Result, ChatError> { + encode_transport_multi_request_with_nonce(aead_key, request, random_nonce()?) +} + +/// Encode and encrypt StatementData::multiresponse with OS randomness. +#[cfg(feature = "std")] +pub fn encode_transport_multi_response( + aead_key: &[u8; 32], + response: &V2MultiDeviceResponse, +) -> Result, ChatError> { + encode_transport_multi_response_with_nonce(aead_key, response, random_nonce()?) +} + +/// Wrap a one-shot key with an OS-random nonce. +#[cfg(feature = "std")] +pub fn wrap_multi_device_key( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + one_shot_key: &[u8; 32], +) -> Result, ChatError> { + wrap_multi_device_key_with_nonce( + own_private_key, + peer_public_key, + one_shot_key, + random_nonce()?, + ) +} + +/// Encrypt an inner multi-device payload with an OS-random nonce. +#[cfg(feature = "std")] +pub fn encrypt_multi_device_payload( + one_shot_key: &[u8; 32], + plaintext: &[u8], +) -> Result, ChatError> { + encrypt_multi_device_payload_with_nonce(one_shot_key, plaintext, random_nonce()?) +} + +/// Encode and encrypt StatementData::multirequest with a caller-supplied nonce. +pub fn encode_transport_multi_request_with_nonce( + aead_key: &[u8; 32], + request: &V2MultiDeviceRequest, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_multi_request_plaintext(request)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt StatementData::multiresponse with a caller-supplied nonce. +pub fn encode_transport_multi_response_with_nonce( + aead_key: &[u8; 32], + response: &V2MultiDeviceResponse, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_multi_response_plaintext(response)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encrypt a one-shot key for one recipient device using the X25519-derived +/// CryptoKit-compatible key and a caller-supplied unique nonce. +pub fn wrap_multi_device_key_with_nonce( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + one_shot_key: &[u8; 32], + nonce: [u8; 12], +) -> Result, ChatError> { + let wrapping_key = x25519_hkdf_sha256_key(own_private_key, peer_public_key)?; + chacha20poly1305_encrypt_with_nonce(&wrapping_key, one_shot_key, nonce) +} + +/// Unwrap a one-shot key received from a peer device. +pub fn unwrap_multi_device_key( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + encrypted_key: &[u8], +) -> Result<[u8; 32], ChatError> { + let wrapping_key = x25519_hkdf_sha256_key(own_private_key, peer_public_key)?; + let plaintext = chacha20poly1305_decrypt(&wrapping_key, encrypted_key)?; + plaintext.try_into().map_err(|plaintext: Vec| { + ChatError::InvalidEncoding(format!( + "unwrapped multi-device key must be 32 bytes, got {}", + plaintext.len() + )) + }) +} + +/// Encrypt an inner multi-device request/response using its one-shot key and a +/// caller-supplied unique nonce. +pub fn encrypt_multi_device_payload_with_nonce( + one_shot_key: &[u8; 32], + plaintext: &[u8], + nonce: [u8; 12], +) -> Result, ChatError> { + chacha20poly1305_encrypt_with_nonce(one_shot_key, plaintext, nonce) +} + +/// Decrypt an inner multi-device request/response. +pub fn decrypt_multi_device_payload( + one_shot_key: &[u8; 32], + ciphertext: &[u8], +) -> Result, ChatError> { + chacha20poly1305_decrypt(one_shot_key, ciphertext) +} + +/// Decode and decrypt an ongoing v2 statement-store transport payload. +pub fn decode_transport( + data: &[u8], + aead_key: &[u8; 32], +) -> Result { + let plaintext = match chacha20poly1305_decrypt(aead_key, data) { + Ok(plaintext) => plaintext, + Err(raw_error) => { + let mut outer = Cursor::new(data); + let encrypted = outer.read_bytes("encrypted_transport")?; + outer.finish()?; + chacha20poly1305_decrypt(aead_key, &encrypted).map_err(|_| raw_error)? + } + }; + decode_transport_plaintext(&plaintext) +} + +/// Decode plaintext StatementData after a Host has opened the identity route. +pub fn decode_transport_plaintext(plaintext: &[u8]) -> Result { + let mut cursor = Cursor::new(plaintext); + let kind = cursor.read_u8("transport_kind")?; + let decoded = match kind { + 0 => { + let request_id = cursor.read_string("request_id")?; + let messages = cursor.read_vec_of_bytes("messages")?; + cursor.finish()?; + V2StatementTransportData::Request { + request_id, + messages, + } + } + 1 => { + let request_id = cursor.read_string("request_id")?; + let response_code = cursor.read_u8("response_code")?; + cursor.finish()?; + V2StatementTransportData::Response { + request_id, + response_code, + } + } + 2 => { + let encrypted_request = cursor.read_bytes("encrypted_request")?; + let devices_info = cursor.read_request_device_infos("devices_info")?; + cursor.finish()?; + V2StatementTransportData::MultiRequest(V2MultiDeviceRequest { + encrypted_request, + devices_info, + }) + } + 3 => { + let encrypted_response = cursor.read_bytes("encrypted_response")?; + let devices_info = cursor.read_request_device_infos("devices_info")?; + cursor.finish()?; + V2StatementTransportData::MultiResponse(V2MultiDeviceResponse { + encrypted_response, + devices_info, + }) + } + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 transport kind {value}" + ))); + } + }; + Ok(decoded) +} + +fn encode_message( + message_id: &str, + timestamp: u64, + encode_content: impl FnOnce(&mut Vec) -> Result<(), ChatError>, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, message_id)?; + out.extend_from_slice(×tamp.to_le_bytes()); + out.push(0); // VersionedChatMessage.V1 + encode_content(&mut out)?; + Ok(out) +} + +fn encode_string(out: &mut Vec, value: &str) -> Result<(), ChatError> { + let len = u32::try_from(value.len()).map_err(|_| { + ChatError::InvalidEncoding("string is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + out.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn encode_bytes(out: &mut Vec, value: &[u8]) -> Result<(), ChatError> { + let len = u32::try_from(value.len()).map_err(|_| { + ChatError::InvalidEncoding("byte array is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + out.extend_from_slice(value); + Ok(()) +} + +fn encode_balance(out: &mut Vec, value: &str) -> Result<(), ChatError> { + let value = value + .parse::() + .map_err(|_| ChatError::InvalidEncoding(format!("invalid balance value: {value}")))?; + out.extend_from_slice(&encode_compact_u128(value)); + Ok(()) +} + +fn encode_compact_u128(value: u128) -> Vec { + if value < 1 << 6 { + vec![(value as u8) << 2] + } else if value < 1 << 14 { + (((value as u16) << 2) | 0b01).to_le_bytes().to_vec() + } else if value < 1 << 30 { + (((value as u32) << 2) | 0b10).to_le_bytes().to_vec() + } else { + let mut bytes = value.to_le_bytes().to_vec(); + while bytes.last() == Some(&0) { + bytes.pop(); + } + let header = (((bytes.len() - 4) as u8) << 2) | 0b11; + let mut out = Vec::with_capacity(1 + bytes.len()); + out.push(header); + out.extend_from_slice(&bytes); + out + } +} + +fn push_platform_index(platform: V2PushPlatform) -> u8 { + match platform { + V2PushPlatform::Android => 0, + V2PushPlatform::Ios => 1, + V2PushPlatform::IosVoip => 2, + } +} + +fn decode_push_platform(value: u8) -> Result { + match value { + 0 => Ok(V2PushPlatform::Android), + 1 => Ok(V2PushPlatform::Ios), + 2 => Ok(V2PushPlatform::IosVoip), + value => Err(ChatError::InvalidEncoding(format!( + "unsupported push platform {value}" + ))), + } +} + +fn encode_rich_text( + out: &mut Vec, + text: Option<&str>, + attachments: Option<&[V2FileVariant]>, +) -> Result<(), ChatError> { + match text { + Some(t) => { + out.push(1); // text = Some + encode_string(out, t)?; + } + None => out.push(0), // text = None + } + match attachments { + None => out.push(0), + Some(files) => { + let count = u32::try_from(files.len()) + .map_err(|_| ChatError::InvalidEncoding("too many attachments".into()))?; + out.push(1); + out.extend_from_slice(&encode_compact_u32(count)); + for file in files { + encode_file(out, file)?; + } + } + } + Ok(()) +} + +fn validate_file_node(node: &V2NodeEndpoint) -> Result<(), ChatError> { + let V2NodeEndpoint::WssUrl(url) = node; + let invalid = || ChatError::InvalidEncoding("invalid attachment WSS endpoint".into()); + let (scheme, rest) = url.split_once("://").ok_or_else(invalid)?; + if !scheme.eq_ignore_ascii_case("wss") + || url + .bytes() + .any(|byte| byte <= b' ' || byte == 0x7f || byte == b'\\') + || rest.contains('#') + { + return Err(invalid()); + } + let authority = rest.split(['/', '?']).next().ok_or_else(invalid)?; + if authority.is_empty() || authority.contains('@') { + return Err(invalid()); + } + let port = if let Some(ipv6) = authority.strip_prefix('[') { + let (address, suffix) = ipv6.split_once(']').ok_or_else(invalid)?; + address + .parse::() + .map_err(|_| invalid())?; + if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':').ok_or_else(invalid)?) + } + } else { + let (host, port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = host.strip_suffix('.').unwrap_or(host); + if host.is_empty() + || host.len() > 253 + || !host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(invalid()); + } + if host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + host.parse::().map_err(|_| invalid())?; + } + port + }; + if let Some(port) = port { + if port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err() + { + return Err(invalid()); + } + } + Ok(()) +} + +fn encode_file(out: &mut Vec, file: &V2FileVariant) -> Result<(), ChatError> { + let V2FileVariant::P2pMixnet(file) = file; + if file.identifier.len() != 32 || file.claim_ticket.len() != 32 { + return Err(ChatError::InvalidEncoding( + "attachment identifier and claim ticket must be exactly 32 bytes".into(), + )); + } + validate_file_node(&file.node)?; + out.push(0); // FileVariant::P2pMixnet + encode_bytes(out, &file.identifier)?; + encode_bytes(out, &file.claim_ticket)?; + let V2NodeEndpoint::WssUrl(url) = &file.node; + out.push(0); // NodeEndpoint::WssUrl + encode_string(out, url)?; + match &file.meta { + V2FileMeta::General(general) => { + out.push(0); + encode_general_file_meta(out, general)?; + } + V2FileMeta::Image(image) => { + out.push(1); + encode_general_file_meta(out, &image.general)?; + out.extend_from_slice(&image.width.to_le_bytes()); + out.extend_from_slice(&image.height.to_le_bytes()); + encode_thumbnail(out, image.thumbnail.as_deref())?; + } + V2FileMeta::Video(video) => { + out.push(2); + encode_general_file_meta(out, &video.general)?; + out.extend_from_slice(&video.duration.to_le_bytes()); + encode_thumbnail(out, video.thumbnail.as_deref())?; + } + } + Ok(()) +} + +fn encode_general_file_meta( + out: &mut Vec, + general: &V2GeneralFileMeta, +) -> Result<(), ChatError> { + encode_string(out, &general.mime_type)?; + out.extend_from_slice(&general.file_size.to_le_bytes()); + Ok(()) +} + +fn encode_thumbnail(out: &mut Vec, thumbnail: Option<&[u8]>) -> Result<(), ChatError> { + match thumbnail { + None => out.push(0), + Some(bytes) => { + out.push(1); + encode_bytes(out, bytes)?; + } + } + Ok(()) +} + +fn decode_file(cursor: &mut Cursor<'_>) -> Result { + let variant = cursor.read_u8("file_variant")?; + if variant != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported file variant {variant}" + ))); + } + let identifier = decode_file_key(cursor, "file_identifier")?; + let mut claim_ticket = zeroize::Zeroizing::new(decode_file_key(cursor, "file_claim_ticket")?); + let node = match cursor.read_u8("file_node_endpoint")? { + 0 => V2NodeEndpoint::WssUrl(cursor.read_string("file_wss_url")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported attachment node endpoint {value}" + ))); + } + }; + validate_file_node(&node)?; + let meta_index = cursor.read_u8("file_meta")?; + if meta_index > 2 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported file metadata {meta_index}" + ))); + } + let general = V2GeneralFileMeta { + mime_type: cursor.read_string("file_mime_type")?, + file_size: cursor.read_u32("file_size")?, + }; + let meta = match meta_index { + 0 => V2FileMeta::General(general), + 1 => V2FileMeta::Image(V2ImageFileMeta { + general, + width: cursor.read_u32("image_width")?, + height: cursor.read_u32("image_height")?, + thumbnail: decode_thumbnail(cursor)?, + }), + 2 => V2FileMeta::Video(V2VideoFileMeta { + general, + duration: cursor.read_u32("video_duration")?, + thumbnail: decode_thumbnail(cursor)?, + }), + _ => unreachable!(), + }; + Ok(V2FileVariant::P2pMixnet(V2P2pMixnetFile { + identifier, + claim_ticket: core::mem::take(&mut *claim_ticket), + node, + meta, + })) +} + +fn decode_file_key(cursor: &mut Cursor<'_>, field: &str) -> Result, ChatError> { + let (len, consumed) = + decode_compact_u32(&cursor.data[cursor.offset..]).map_err(ChatError::InvalidEncoding)?; + cursor.offset += consumed; + if len != 32 { + return Err(ChatError::InvalidEncoding(format!( + "{field} must be exactly 32 bytes" + ))); + } + Ok(cursor.read_exact(32, field)?.to_vec()) +} + +fn decode_thumbnail(cursor: &mut Cursor<'_>) -> Result>, ChatError> { + match cursor.read_u8("thumbnail_option")? { + 0 => Ok(None), + 1 => Ok(Some(cursor.read_bytes("thumbnail")?)), + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for thumbnail: {value}" + ))), + } +} + +fn encode_optional_push_token( + out: &mut Vec, + push_token: Option<&V2PushToken>, +) -> Result<(), ChatError> { + match push_token { + Some(push_token) => { + out.push(1); + encode_bytes(out, &push_token.token)?; + out.push(match push_token.platform { + V2PushPlatform::Android => 0, + V2PushPlatform::Ios => 1, + V2PushPlatform::IosVoip => 2, + }); + } + None => out.push(0), + } + Ok(()) +} + +fn encode_optional_rich_text( + out: &mut Vec, + welcome_text: Option<&str>, +) -> Result<(), ChatError> { + match welcome_text { + Some(text) => { + out.push(1); // Some(RichText) + out.push(1); // RichText.text = Some + encode_string(out, text)?; + out.push(0); // RichText.attachments = None + } + None => out.push(0), + } + Ok(()) +} + +fn encode_vec_of_bytes(out: &mut Vec, items: &[Vec]) -> Result<(), ChatError> { + let len = u32::try_from(items.len()).map_err(|_| { + ChatError::InvalidEncoding("messages vector is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + for item in items { + encode_bytes(out, item)?; + } + Ok(()) +} + +fn encode_request_device_infos( + out: &mut Vec, + devices: &[V2RequestDeviceInfo], +) -> Result<(), ChatError> { + let len = u32::try_from(devices.len()).map_err(|_| { + ChatError::InvalidEncoding("devices vector is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + for device in devices { + out.extend_from_slice(&device.statement_account_id); + encode_bytes(out, &device.encrypted_key)?; + } + Ok(()) +} + +fn decode_chat_request_message_cursor( + cursor: &mut Cursor<'_>, +) -> Result { + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 chat request version index {version_index}" + ))); + } + + let push_token = decode_optional_push_token(cursor)?; + let welcome_text = decode_optional_rich_text(cursor)?; + + Ok(V2ChatRequestMessage { + message_id, + timestamp, + push_token, + welcome_text, + }) +} + +fn decode_chat_request_message_v2_cursor( + cursor: &mut Cursor<'_>, +) -> Result { + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 1 { + return Err(ChatError::InvalidEncoding(format!( + "expected chat request content version index 1, got {version_index}" + ))); + } + let identity_account_id = cursor.read_array_32("identity_account_id")?; + let proof = cursor.read_array_32("identity_proof")?; + let device_enc_pub_key = cursor.read_array_32("device_enc_pub_key")?; + let push_token = decode_optional_push_token(cursor)?; + let welcome_text = decode_optional_rich_text(cursor)?; + Ok(V2ChatRequestMessageV2 { + message_id, + timestamp, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id, + proof, + }, + device_enc_pub_key, + push_token, + welcome_text, + }, + }) +} + +fn validate_chat_request_proof(proof: &V2ChatRequestProof) -> Result<(), ChatError> { + if proof.signature.len() != 64 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signature must be 64 bytes, got {}", + proof.signature.len() + ))); + } + if proof.signer.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signer must be 32 bytes, got {}", + proof.signer.len() + ))); + } + Ok(()) +} + +fn decode_chat_request_proof(cursor: &mut Cursor<'_>) -> Result { + let proof_index = cursor.read_u8("proof_index")?; + if proof_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported chat request proof index {proof_index}" + ))); + } + let signature = cursor.read_exact(64, "proof_signature")?.to_vec(); + let signer = cursor.read_exact(32, "proof_signer")?.to_vec(); + Ok(V2ChatRequestProof { signature, signer }) +} + +fn decode_optional_push_token(cursor: &mut Cursor<'_>) -> Result, ChatError> { + match cursor.read_u8("push_token_option")? { + 0 => Ok(None), + 1 => { + let token = cursor.read_bytes("push_token")?; + let platform = match cursor.read_u8("push_platform")? { + 0 => V2PushPlatform::Android, + 1 => V2PushPlatform::Ios, + 2 => V2PushPlatform::IosVoip, + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported push platform {value}" + ))); + } + }; + Ok(Some(V2PushToken { token, platform })) + } + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for push token: {value}" + ))), + } +} + +/// Decode a RichText value from the cursor (non-optional outer wrapper). +/// +/// RichText = Option text + Option attachments. +fn decode_rich_text( + cursor: &mut Cursor<'_>, +) -> Result<(Option, Option>), ChatError> { + let text = match cursor.read_u8("rich_text_text_option")? { + 0 => None, + 1 => Some(cursor.read_string("rich_text_text")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text: {value}" + ))); + } + }; + + let attachments = match cursor.read_u8("rich_text_attachments_option")? { + 0 => None, + 1 => { + let (count, consumed) = decode_compact_u32(&cursor.data[cursor.offset..]) + .map_err(ChatError::InvalidEncoding)?; + cursor.offset += consumed; + let count = count as usize; + // Even with empty URL/MIME strings, a native file needs 75 bytes. + // Bound the collection before reserving from an untrusted count. + if count > cursor.data.len().saturating_sub(cursor.offset) / 75 { + return Err(ChatError::InvalidEncoding( + "attachment count exceeds remaining input".into(), + )); + } + let mut files = Vec::with_capacity(count); + for _ in 0..count { + files.push(decode_file(cursor)?); + } + Some(files) + } + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text attachments: {value}" + ))); + } + }; + Ok((text, attachments)) +} + +fn decode_optional_rich_text(cursor: &mut Cursor<'_>) -> Result, ChatError> { + match cursor.read_u8("welcome_option")? { + 0 => Ok(None), + 1 => { + let text = match cursor.read_u8("welcome_text_option")? { + 0 => None, + 1 => Some(cursor.read_string("welcome_text")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text text: {value}" + ))); + } + }; + + match cursor.read_u8("welcome_attachments_option")? { + 0 => Ok(text), + 1 => Err(ChatError::InvalidEncoding( + "welcome message attachments cannot be represented".into(), + )), + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text attachments: {value}" + ))), + } + } + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for welcome message: {value}" + ))), + } +} + +#[cfg(feature = "std")] +fn random_bytes_32() -> Result<[u8; 32], ChatError> { + let mut bytes = [0; 32]; + getrandom::getrandom(&mut bytes) + .map_err(|_| ChatError::KeyDerivationFailed("OS key generation failed".into()))?; + Ok(bytes) +} + +#[cfg(feature = "std")] +fn random_nonce() -> Result<[u8; 12], ChatError> { + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce) + .map_err(|_| ChatError::KeyDerivationFailed("OS nonce generation failed".into()))?; + Ok(nonce) +} + +fn chacha20poly1305_encrypt_with_nonce( + key: &[u8; 32], + plaintext: &[u8], + nonce_bytes: [u8; 12], +) -> Result, ChatError> { + chacha20poly1305_encrypt_with_nonce_and_aad(key, plaintext, nonce_bytes, &[]) +} + +fn chacha20poly1305_encrypt_with_nonce_and_aad( + key: &[u8; 32], + plaintext: &[u8], + nonce_bytes: [u8; 12], + aad: &[u8], +) -> Result, ChatError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| ChatError::InvalidEncoding("ChaCha20-Poly1305 encryption failed".into()))?; + + let mut out = Vec::with_capacity(12 + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +fn chacha20poly1305_decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result, ChatError> { + chacha20poly1305_decrypt_with_aad(key, ciphertext, &[]) +} + +fn chacha20poly1305_decrypt_with_aad( + key: &[u8; 32], + ciphertext: &[u8], + aad: &[u8], +) -> Result, ChatError> { + if ciphertext.len() < 28 { + return Err(ChatError::InvalidEncoding(format!( + "ciphertext too short: {} bytes", + ciphertext.len() + ))); + } + + let cipher = ChaCha20Poly1305::new(key.into()); + let nonce = Nonce::from_slice(&ciphertext[..12]); + cipher + .decrypt( + nonce, + Payload { + msg: &ciphertext[12..], + aad, + }, + ) + .map_err(|_| ChatError::InvalidEncoding("ChaCha20-Poly1305 decryption failed".into())) +} + +struct Cursor<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + fn read_u8(&mut self, field: &str) -> Result { + let value = *self.data.get(self.offset).ok_or_else(|| { + ChatError::InvalidEncoding(format!("v2 chat message truncated at {field}")) + })?; + self.offset += 1; + Ok(value) + } + + #[cfg(feature = "std")] + fn read_u16(&mut self, field: &str) -> Result { + let bytes = self.read_exact(2, field)?; + Ok(u16::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u16 for {field}")) + })?)) + } + + fn read_u32(&mut self, field: &str) -> Result { + let bytes = self.read_exact(4, field)?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u32 for {field}")) + })?)) + } + + #[cfg(feature = "std")] + fn read_compact_u128(&mut self, field: &str) -> Result { + let first = self.read_u8(field)?; + let mode = first & 0b11; + let non_canonical = + || ChatError::InvalidEncoding(format!("{field}: non-canonical compact integer")); + let value = match mode { + 0b00 => u128::from(first >> 2), + 0b01 => { + let next = self.read_exact(1, field)?[0]; + let value = u128::from(u16::from_le_bytes([first, next]) >> 2); + if value < 1 << 6 { + return Err(non_canonical()); + } + value + } + 0b10 => { + let rest = self.read_exact(3, field)?; + let value = u128::from(u32::from_le_bytes([first, rest[0], rest[1], rest[2]]) >> 2); + if value < 1 << 14 { + return Err(non_canonical()); + } + value + } + 0b11 => { + let len = usize::from(first >> 2) + 4; + if len > 16 { + return Err(ChatError::InvalidEncoding(format!( + "{field}: compact integer exceeds u128" + ))); + } + let bytes = self.read_exact(len, field)?; + if bytes[len - 1] == 0 { + return Err(non_canonical()); + } + let mut padded = [0_u8; 16]; + padded[..len].copy_from_slice(bytes); + let value = u128::from_le_bytes(padded); + if value < 1 << 30 { + return Err(non_canonical()); + } + value + } + _ => unreachable!(), + }; + Ok(value) + } + + fn read_u64(&mut self, field: &str) -> Result { + let bytes = self.read_exact(8, field)?; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u64 for {field}")) + })?)) + } + + fn read_array_32(&mut self, field: &str) -> Result<[u8; 32], ChatError> { + self.read_exact(32, field)? + .try_into() + .map_err(|_| ChatError::InvalidEncoding(format!("invalid 32-byte field {field}"))) + } + + fn read_balance(&mut self, field: &str) -> Result { + let first = self.read_u8(field)?; + let mode = first & 0b11; + let value = match mode { + 0b00 => u128::from(first >> 2), + 0b01 => { + let next = self.read_exact(1, field)?[0]; + u128::from(u16::from_le_bytes([first, next]) >> 2) + } + 0b10 => { + let rest = self.read_exact(3, field)?; + u128::from(u32::from_le_bytes([first, rest[0], rest[1], rest[2]]) >> 2) + } + 0b11 => { + let len = usize::from(first >> 2) + 4; + if len > 16 { + return Err(ChatError::InvalidEncoding(format!( + "{field}: balance exceeds u128" + ))); + } + let bytes = self.read_exact(len, field)?; + let mut padded = [0_u8; 16]; + padded[..len].copy_from_slice(bytes); + u128::from_le_bytes(padded) + } + _ => unreachable!(), + }; + Ok(value.to_string()) + } + + fn read_string(&mut self, field: &str) -> Result { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + let bytes = self.read_exact(len as usize, field)?; + String::from_utf8(bytes.to_vec()) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: invalid utf-8: {e}"))) + } + + fn read_exact(&mut self, len: usize, field: &str) -> Result<&'a [u8], ChatError> { + let end = self.offset.checked_add(len).ok_or_else(|| { + ChatError::InvalidEncoding(format!("v2 chat message length overflow at {field}")) + })?; + if end > self.data.len() { + return Err(ChatError::InvalidEncoding(format!( + "v2 chat message truncated at {field}" + ))); + } + let bytes = &self.data[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn read_bytes(&mut self, field: &str) -> Result, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + Ok(self.read_exact(len as usize, field)?.to_vec()) + } + + fn read_vec_of_bytes(&mut self, field: &str) -> Result>, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + let len = len as usize; + if len > self.data.len().saturating_sub(self.offset) { + return Err(ChatError::InvalidEncoding(format!( + "{field}: item count exceeds remaining input" + ))); + } + let mut out = Vec::with_capacity(len); + for index in 0..len { + out.push(self.read_bytes(&format!("{field}[{index}]"))?); + } + Ok(out) + } + + fn read_request_device_infos( + &mut self, + field: &str, + ) -> Result, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|error| ChatError::InvalidEncoding(format!("{field}: {error}")))?; + self.offset += consumed; + let len = len as usize; + if len > self.data.len().saturating_sub(self.offset) / 33 { + return Err(ChatError::InvalidEncoding(format!( + "{field}: device count exceeds remaining input" + ))); + } + let mut devices = Vec::with_capacity(len); + for index in 0..len { + devices.push(V2RequestDeviceInfo { + statement_account_id: self + .read_array_32(&format!("{field}[{index}].statement_account_id"))?, + encrypted_key: self.read_bytes(&format!("{field}[{index}].encrypted_key"))?, + }); + } + Ok(devices) + } + + fn finish(&self) -> Result<(), ChatError> { + if self.offset != self.data.len() { + return Err(ChatError::InvalidEncoding(format!( + "v2 chat message has {} trailing bytes", + self.data.len() - self.offset + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_array_32(hex: &str) -> [u8; 32] { + assert_eq!(hex.len(), 64); + let mut out = [0_u8; 32]; + for (index, byte) in out.iter_mut().enumerate() { + let offset = index * 2; + *byte = u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap(); + } + out + } + + #[test] + fn chacha20poly1305_matches_rfc_8439_vector() { + let key = hex_array_32("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f"); + let nonce: [u8; 12] = hex::decode("070000004041424344454647") + .unwrap() + .try_into() + .unwrap(); + let plaintext = hex::decode(concat!( + "4c616469657320616e642047656e746c656d656e206f662074686520636c617373", + "206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c", + "79206f6e652074697020666f7220746865206675747572652c2073756e7363726565", + "6e20776f756c642062652069742e" + )) + .unwrap(); + let aad = hex::decode("50515253c0c1c2c3c4c5c6c7").unwrap(); + let expected = hex::decode(concat!( + "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63", + "dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692d", + "dbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4d", + "ef08e4b7a9de576d26586cec64b6116", + "1ae10b594f09e26a7e902ecbd0600691" + )) + .unwrap(); + + use chacha20poly1305::aead::Payload; + let cipher = ChaCha20Poly1305::new((&key).into()); + let encrypted = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) + .unwrap(); + assert_eq!(encrypted, expected); + assert_eq!( + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &encrypted, + aad: &aad, + }, + ) + .unwrap(), + plaintext + ); + } + + #[test] + fn day_from_unix_uses_v2_protocol_epoch() { + assert_eq!(chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS - 1), None); + assert_eq!(chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS), Some(0)); + assert_eq!( + chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS + SECONDS_IN_DAY * 7 + 12), + Some(7) + ); + } + + #[test] + fn full_topic_matches_ios_v2_data_layout() { + let account = [0x11; 32]; + let mut expected_input = Vec::new(); + expected_input.extend_from_slice(&encode_compact_u32(CHAT_REQUEST_CONTEXT.len() as u32)); + expected_input.extend_from_slice(CHAT_REQUEST_CONTEXT); + expected_input.extend_from_slice(&encode_compact_u32(account.len() as u32)); + expected_input.extend_from_slice(&account); + assert_eq!( + chat_request_full_topic(&account), + blake2b_256(&expected_input) + ); + } + + #[test] + fn day_topic_appends_little_endian_u64_day() { + let account = [0x22; 32]; + let day = 42_u64; + let mut expected_input = Vec::new(); + expected_input.extend_from_slice(&encode_compact_u32(CHAT_REQUEST_CONTEXT.len() as u32)); + expected_input.extend_from_slice(CHAT_REQUEST_CONTEXT); + expected_input.extend_from_slice(&encode_compact_u32(account.len() as u32)); + expected_input.extend_from_slice(&account); + expected_input.extend_from_slice(&day.to_le_bytes()); + assert_eq!( + chat_request_day_topic(&account, day), + blake2b_256(&expected_input) + ); + } + + #[test] + fn full_topic_matches_ios_v2_observed_topic() { + let account = + hex_array_32("e6f8b1d6f1c8fde666469b9662d3d0925b21085f876722e428b1226d78ae1301"); + assert_eq!( + chat_request_full_topic(&account), + hex_array_32("463725e892e8c663bb531e8ed8ecf4b4e7e4a198ddfbce76a646d515dab1c5b2") + ); + } + + #[test] + fn session_id_params_match_v2_ordering() { + let requester = [0xAA; 32]; + let acceptor = [0xBB; 32]; + let params = chat_request_session_id_params(&requester, Some("1234"), &acceptor, None); + let mut expected = Vec::new(); + expected.extend_from_slice(&requester); + expected.extend_from_slice(&acceptor); + expected.extend_from_slice(b"/1234/"); + assert_eq!(params, expected); + } + + #[test] + fn session_topic_is_keyed_hash_of_context_and_params() { + let secret = [0x44; 32]; + let requester = [0x55; 32]; + let acceptor = [0x66; 32]; + let params = chat_request_session_id_params(&requester, None, &acceptor, Some("9999")); + let mut input = Vec::new(); + input.extend_from_slice(CHAT_REQUEST_CONTEXT); + input.extend_from_slice(¶ms); + let expected = blake2b_256_keyed(&secret, &input).unwrap(); + assert_eq!( + chat_request_session_topic(&secret, &requester, None, &acceptor, Some("9999")).unwrap(), + expected + ); + } + + #[test] + fn session_topic_rejects_non_32_byte_secret() { + let requester = [0x55; 32]; + let acceptor = [0x66; 32]; + let err = + chat_request_session_topic(&[0x44; 31], &requester, None, &acceptor, None).unwrap_err(); + assert!(err.to_string().contains("shared_secret must be 32 bytes")); + } + + #[test] + fn text_message_round_trips() { + let encoded = encode_text_message("msg-1", 123, "hello").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-1".into(), + timestamp: 123, + content: V2ChatMessageContent::Text("hello".into()) + } + ); + } + + #[test] + fn chat_accepted_message_round_trips() { + let encoded = encode_chat_accepted_message("msg-2", 456, "request-1").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-2".into(), + timestamp: 456, + content: V2ChatMessageContent::ChatAccepted { + request_id: "request-1".into() + } + } + ); + } + + #[test] + fn data_channel_offer_round_trips() { + let encoded = encode_data_channel_offer_message( + "msg-offer", + 457, + b"offer-sdp", + V2DataChannelPurpose::Video, + ) + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-offer".into(), + timestamp: 457, + content: V2ChatMessageContent::DataChannelOffer { + sdp: b"offer-sdp".to_vec(), + purpose: V2DataChannelPurpose::Video, + } + } + ); + } + + #[test] + fn data_channel_offer_lifts_to_transport_neutral_call_signal() { + let decoded = decode_message( + &encode_data_channel_offer_message( + "msg-offer", + 457, + b"offer-sdp", + V2DataChannelPurpose::Video, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + decoded.as_call_signal(), + Some(V2CallSignal::Offer { + offer_id: "msg-offer".into(), + sdp: b"offer-sdp".to_vec(), + purpose: V2DataChannelPurpose::Video, + }) + ); + } + + #[test] + fn data_channel_answer_round_trips() { + let encoded = + encode_data_channel_answer_message("msg-answer", 458, "offer-1", b"answer-sdp") + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-answer".into(), + timestamp: 458, + content: V2ChatMessageContent::DataChannelAnswer { + offer_id: "offer-1".into(), + sdp: b"answer-sdp".to_vec(), + } + } + ); + } + + #[test] + fn data_channel_candidates_round_trips() { + let encoded = encode_data_channel_candidates_message( + "msg-candidates", + 459, + "offer-2", + b"candidate-batch", + ) + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-candidates".into(), + timestamp: 459, + content: V2ChatMessageContent::DataChannelCandidates { + offer_id: "offer-2".into(), + sdp: b"candidate-batch".to_vec(), + } + } + ); + } + + #[test] + fn data_channel_closed_round_trips() { + let encoded = encode_data_channel_closed_message("msg-closed", 460, "offer-3").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-closed".into(), + timestamp: 460, + content: V2ChatMessageContent::DataChannelClosed { + offer_id: "offer-3".into(), + } + } + ); + } + + #[test] + fn transport_neutral_call_signal_encodes_to_v2_message() { + let encoded = encode_call_signal_message( + "msg-call", + 461, + &V2CallSignal::Candidates { + offer_id: "offer-4".into(), + sdp: b"candidate-batch".to_vec(), + }, + ) + .unwrap(); + + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-call".into(), + timestamp: 461, + content: V2ChatMessageContent::DataChannelCandidates { + offer_id: "offer-4".into(), + sdp: b"candidate-batch".to_vec(), + } + } + ); + } + + #[test] + fn unsupported_content_preserves_header() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-3").unwrap(); + encoded.extend_from_slice(&789_u64.to_le_bytes()); + encoded.push(0); + encoded.push(99); // 99 is not a known content index + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-3".into(), + timestamp: 789, + content: V2ChatMessageContent::UnsupportedContent { content_index: 99 } + } + ); + } + + #[test] + fn unsupported_version_preserves_header() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-4").unwrap(); + encoded.extend_from_slice(&890_u64.to_le_bytes()); + encoded.push(1); + encoded.extend_from_slice(b"future payload"); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-4".into(), + timestamp: 890, + content: V2ChatMessageContent::UnsupportedVersion { version_index: 1 } + } + ); + } + + #[test] + fn supported_content_rejects_trailing_bytes() { + let mut encoded = encode_text_message("msg-5", 901, "hello").unwrap(); + encoded.push(0); + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("trailing bytes")); + } + + #[test] + fn invalid_data_channel_purpose_rejects_decode() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-invalid").unwrap(); + encoded.extend_from_slice(&902_u64.to_le_bytes()); + encoded.push(0); + encoded.push(8); + encode_bytes(&mut encoded, b"sdp").unwrap(); + encoded.push(9); + + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("unsupported data channel purpose")); + } + + #[test] + fn test_token_message_roundtrip() { + let encoded = + encode_token_message("msg-1", 1000, &[0xAA, 0xBB], V2PushPlatform::Ios).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.message_id, "msg-1"); + assert_eq!(decoded.timestamp, 1000); + assert_eq!( + decoded.content, + V2ChatMessageContent::Token { + token: vec![0xAA, 0xBB], + platform: V2PushPlatform::Ios + } + ); + } + + #[test] + fn token_message_decodes_v2_push_token_shape() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-token-push").unwrap(); + encoded.extend_from_slice(&1001_u64.to_le_bytes()); + encoded.push(0); + encoded.push(1); + encode_bytes(&mut encoded, &[0xAA, 0xBB, 0xCC]).unwrap(); + encoded.push(2); + + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Token { + token: vec![0xAA, 0xBB, 0xCC], + platform: V2PushPlatform::IosVoip + } + ); + } + + #[test] + fn test_send_legacy_message_roundtrip() { + let block_hash = vec![0x11; 32]; + let extrinsic_hash = vec![0x22; 32]; + let encoded = + encode_send_legacy_message("msg-2", 2000, "100", &block_hash, &extrinsic_hash).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::SendLegacy { + amount: "100".into(), + block_hash, + extrinsic_hash + } + ); + } + + #[test] + fn send_legacy_rejects_truncated_ios_payload_shape() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-send-legacy-real").unwrap(); + encoded.extend_from_slice(&2001_u64.to_le_bytes()); + encoded.push(0); + encoded.push(2); + encode_balance(&mut encoded, "100").unwrap(); + encoded.extend_from_slice(&[0x11; 31]); + + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("truncated at block_hash")); + } + + #[test] + fn test_contact_added_message_roundtrip() { + let encoded = encode_contact_added_message("msg-3", 3000).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.content, V2ChatMessageContent::ContactAdded); + } + + #[test] + fn test_reacted_message_roundtrip() { + let encoded = encode_reacted_message("msg-4", 4000, "ref-1", "\u{1F44D}").unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reacted { + message_id: "ref-1".into(), + emoji: "\u{1F44D}".into() + } + ); + } + + #[test] + fn test_reaction_removed_message_roundtrip() { + let encoded = + encode_reaction_removed_message("msg-5", 5000, "ref-2", "\u{2764}\u{FE0F}").unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::ReactionRemoved { + message_id: "ref-2".into(), + emoji: "\u{2764}\u{FE0F}".into() + } + ); + } + + #[test] + fn test_reply_message_roundtrip() { + let encoded = encode_reply_message("msg-6", 6000, "ref-3", Some("reply text")).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reply { + message_id: "ref-3".into(), + text: Some("reply text".into()), + attachments: None, + } + ); + } + + #[test] + fn test_reply_message_without_text_roundtrip() { + let encoded = encode_reply_message("msg-6b", 6001, "ref-3b", None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reply { + message_id: "ref-3b".into(), + text: None, + attachments: None, + } + ); + } + + #[test] + fn test_edited_message_roundtrip() { + let encoded = encode_edited_message("msg-7", 7000, "ref-4", Some("new content")).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Edited { + message_id: "ref-4".into(), + new_text: Some("new content".into()), + attachments: None, + } + ); + } + + #[test] + fn test_left_chat_message_roundtrip() { + let encoded = encode_left_chat_message("msg-8", 8000).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.content, V2ChatMessageContent::LeftChat); + } + + #[test] + fn test_rich_text_message_roundtrip() { + let encoded = encode_rich_text_message("msg-9", 9000, Some("hello rich"), None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: Some("hello rich".into()), + attachments: None, + } + ); + } + + #[test] + fn egui_rich_text_matches_native_chat_v2_vector() { + let encoded = encode_rich_text_message( + "egui-message", + 1_700_000_000_123, + Some("hello from egui"), + None, + ) + .unwrap(); + assert_eq!( + hex::encode(&encoded), + "30656775692d6d6573736167657b68e5cf8b010000000f013c68656c6c6f2066726f6d206567756900" + ); + + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.message_id, "egui-message"); + assert_eq!(decoded.timestamp, 1_700_000_000_123); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: Some("hello from egui".into()), + attachments: None, + } + ); + } + + #[test] + fn test_rich_text_message_without_text_roundtrip() { + let encoded = encode_rich_text_message("msg-9b", 9001, None, None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: None, + attachments: None + } + ); + } + + // Native brevity-chat wire.rs field order/indices and fixed-u32 numeric + // fixture, with real 32-byte HOP entry hashes and FileTicket keys. + fn native_attachment_fixture(content_index: u8, meta_index: u8) -> Vec { + let mut bytes = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 0, content_index]; + if content_index == 7 || content_index == 12 { + bytes.extend_from_slice(&[4, b'r']); + } + bytes.extend_from_slice(&[0, 1, 4, 0, 0x80]); // None text, Some(one file), hash length + bytes.extend_from_slice(&[0xa1; 32]); + bytes.push(0x80); // ticket length + bytes.extend_from_slice(&[0xb1; 32]); + bytes.extend_from_slice(&[0, 0x1c]); // WssUrl, length 7 + bytes.extend_from_slice(b"wss://n"); + bytes.push(meta_index); + match meta_index { + 0 => { + bytes.push(0x28); + bytes.extend_from_slice(b"text/plain"); + bytes.extend_from_slice(&[0xff; 4]); // file_size = u32::MAX + } + 1 => { + bytes.push(0x28); + bytes.extend_from_slice(b"image/jpeg"); + bytes.extend_from_slice(&[0x40, 0xe2, 1, 0]); // file_size = 123456 + bytes.extend_from_slice(&[0x20, 3, 0, 0]); // width = 800 + bytes.extend_from_slice(&[0x58, 2, 0, 0]); // height = 600 + bytes.extend_from_slice(&[1, 0x10, b'L', b'K', b'O', b'2']); + } + 2 => { + bytes.push(0x24); + bytes.extend_from_slice(b"video/mp4"); + bytes.extend_from_slice(&[7, 0, 0, 0]); // file_size = 7 + bytes.extend_from_slice(&[90, 0, 0, 0, 0]); // duration = 90, no thumbnail + } + _ => unreachable!(), + } + bytes + } + + fn native_attachment(meta: V2FileMeta) -> V2FileVariant { + V2FileVariant::P2pMixnet(V2P2pMixnetFile { + identifier: vec![0xa1; 32], + claim_ticket: vec![0xb1; 32], + node: V2NodeEndpoint::WssUrl("wss://n".into()), + meta, + }) + } + + #[test] + fn native_ordinary_reply_and_edit_attachment_fixtures() { + let cases = [ + ( + 7, + 0, + V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: u32::MAX, + }), + ), + ( + 15, + 1, + V2FileMeta::Image(V2ImageFileMeta { + general: V2GeneralFileMeta { + mime_type: "image/jpeg".into(), + file_size: 123_456, + }, + width: 800, + height: 600, + thumbnail: Some(b"LKO2".to_vec()), + }), + ), + ( + 12, + 2, + V2FileMeta::Video(V2VideoFileMeta { + general: V2GeneralFileMeta { + mime_type: "video/mp4".into(), + file_size: 7, + }, + duration: 90, + thumbnail: None, + }), + ), + ]; + for (content_index, meta_index, meta) in cases { + let attachments = vec![native_attachment(meta)]; + let expected = match content_index { + 7 => V2ChatMessageContent::Reply { + message_id: "r".into(), + text: None, + attachments: Some(attachments.clone()), + }, + 12 => V2ChatMessageContent::Edited { + message_id: "r".into(), + new_text: None, + attachments: Some(attachments.clone()), + }, + _ => V2ChatMessageContent::RichText { + text: None, + attachments: Some(attachments.clone()), + }, + }; + assert_eq!( + decode_message(&native_attachment_fixture(content_index, meta_index)).unwrap(), + V2ChatMessage { + message_id: "m".into(), + timestamp: 1, + content: expected + }, + ); + assert_eq!( + encode_rich_text_message("m", 1, None, Some(&attachments)).unwrap(), + native_attachment_fixture(15, meta_index), + ); + } + } + + #[test] + fn rich_attachment_options_preserve_empty_and_multiple_files() { + let file = native_attachment(V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: 0, + })); + for attachments in [Some(vec![]), Some(vec![file.clone(), file]), None] { + let encoded = + encode_rich_text_message("m", 1, Some("caption"), attachments.as_deref()).unwrap(); + assert_eq!( + decode_message(&encoded).unwrap().content, + V2ChatMessageContent::RichText { + text: Some("caption".into()), + attachments + } + ); + } + } + + #[test] + fn attachment_decoder_rejects_malformed_boundaries() { + let valid = native_attachment_fixture(15, 1); + // Each offset is a different SCALE discriminant in the native image fixture. + for (offset, value) in [(12, 2), (13, 2), (15, 1), (82, 1), (91, 3), (115, 2)] { + let mut malformed = valid.clone(); + malformed[offset] = value; + assert!( + decode_message(&malformed).is_err(), + "discriminant at {offset}" + ); + } + // The hash and ticket are Vec on wire but must be exact native keys. + for offset in [16, 49] { + let mut short = valid.clone(); + short[offset] = 31 << 2; + short.remove(offset + 1); + assert!(decode_message(&short).is_err()); + let mut long = valid.clone(); + long[offset] = 33 << 2; + long.insert(offset + 1, 0); + assert!(decode_message(&long).is_err()); + } + // Counts and variable lengths must be checked before allocation. + for offset in [14, 83, 92, 116] { + let mut oversized = valid[..offset].to_vec(); + oversized.extend_from_slice(&[3, 255, 255, 255, 255]); // compact u32::MAX + oversized.extend_from_slice(&valid[offset + 1..]); + assert!(decode_message(&oversized).is_err(), "length at {offset}"); + } + let mut noncanonical = valid[..14].to_vec(); + noncanonical.extend_from_slice(&[5, 0]); // noncanonical compact count 1 + noncanonical.extend_from_slice(&valid[15..]); + assert!(decode_message(&noncanonical).is_err()); + for end in 0..valid.len() { + assert!(decode_message(&valid[..end]).is_err(), "truncated at {end}"); + } + let mut trailing = valid; + trailing.push(0); + assert!(decode_message(&trailing).is_err()); + } + + #[test] + fn attachment_encoder_rejects_wrong_key_widths_and_unsafe_nodes() { + let V2FileVariant::P2pMixnet(file) = + native_attachment(V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: 0, + })); + let mut short_hash = file.clone(); + short_hash.identifier.pop(); + let mut long_ticket = file.clone(); + long_ticket.claim_ticket.push(0); + for invalid in [short_hash, long_ticket] { + assert!( + encode_rich_text_message("m", 1, None, Some(&[V2FileVariant::P2pMixnet(invalid),])) + .is_err() + ); + } + for url in [ + "ws://n", + "wss://", + "wss://user@n", + "wss://n#fragment", + "wss://n:65536", + "wss://[bad]", + "wss://n\n/path", + ] { + let mut invalid = file.clone(); + invalid.node = V2NodeEndpoint::WssUrl(url.into()); + assert!( + encode_rich_text_message("m", 1, None, Some(&[V2FileVariant::P2pMixnet(invalid),])) + .is_err() + ); + } + let mut invalid_wire = native_attachment_fixture(15, 0); + invalid_wire[84] = b'x'; // xss://n is not a secure WebSocket endpoint. + assert!(decode_message(&invalid_wire).is_err()); + } + + #[test] + fn invitation_decoders_do_not_drop_attachments() { + let rich = native_attachment_fixture(15, 1); + let mut legacy = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + legacy.extend_from_slice(&rich[12..]); + assert!(decode_chat_request_message(&legacy).is_err()); + let mut current = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 1]; + current.extend_from_slice(&[1; 96]); // identity, proof, device encryption key + current.extend_from_slice(&[0, 1]); // no push token, Some welcome + current.extend_from_slice(&rich[12..]); + assert!(decode_chat_request_message_v2(¤t).is_err()); + } + + #[test] + fn test_coinage_send_message_roundtrip() { + let coin_keys = vec![vec![0xAAu8; 32], vec![0xBBu8; 32]]; + let encoded = encode_coinage_send_message("msg-10", 10000, "1000", &coin_keys).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::CoinageSend { + total_value: "1000".into(), + coin_keys: coin_keys.clone() + } + ); + } + #[test] + fn current_multi_device_wire_roundtrips() { + let added = encode_device_added_message("add", 1, &[1; 32], &[2; 32]).unwrap(); + assert!(matches!( + decode_message(&added).unwrap().content, + V2ChatMessageContent::DeviceAdded { .. } + )); + let removed = encode_device_removed_message("remove", 2, &[1; 32]).unwrap(); + assert!(matches!( + decode_message(&removed).unwrap().content, + V2ChatMessageContent::DeviceRemoved { .. } + )); + let compacted = encode_compacted_messages_message( + "compact", + 3, + &[3; 4], + &[4; 5], + &V2NodeEndpoint::WssUrl("wss://chat.example".into()), + ) + .unwrap(); + assert!(matches!( + decode_message(&compacted).unwrap().content, + V2ChatMessageContent::CompactedMessages { .. } + )); + let accepted = encode_multi_chat_accepted_message( + "accept", + 4, + "request", + &V2PeerDevice { + statement_account_id: [5; 32], + encryption_public_key: [6; 32], + }, + ) + .unwrap(); + assert_eq!(accepted[16], 20, "Android DeviceChatAccepted wire index"); + assert!(matches!( + decode_message(&accepted).unwrap().content, + V2ChatMessageContent::MultiChatAccepted { .. } + )); + } + + #[test] + fn request_content_v2_seals_and_opens_with_fixed_nonce() { + let request = V2ChatRequestV2 { + message: V2ChatRequestMessageV2 { + message_id: "request-v2".into(), + timestamp: 42, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id: [7; 32], + proof: [8; 32], + }, + device_enc_pub_key: [9; 32], + push_token: None, + welcome_text: Some("hello".into()), + }, + }, + proof: V2ChatRequestProof { + signature: vec![10; 64], + signer: vec![11; 32], + }, + }; + let recipient_private = [12; 32]; + let wrapper = seal_chat_request_v2_with_nonce( + &[13; 32], + &x25519_public_key(&recipient_private), + &request, + [14; 12], + ) + .unwrap(); + assert_eq!( + open_chat_request_v2(&recipient_private, &wrapper).unwrap(), + request + ); + } + #[test] + fn context_bound_invite_authenticates_product_accounts_and_route() { + let sender_account_id = [7; 32]; + let recipient_account_id = [12; 32]; + let channel_id = [15; 32]; + let request = V2ChatRequestV2 { + message: V2ChatRequestMessageV2 { + message_id: "context-bound".into(), + timestamp: 42, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id: sender_account_id, + proof: [8; 32], + }, + device_enc_pub_key: [9; 32], + push_token: None, + welcome_text: Some("secure hello".into()), + }, + }, + proof: V2ChatRequestProof { + signature: vec![10; 64], + signer: vec![11; 32], + }, + }; + let recipient_private = [12; 32]; + let wrapper = seal_context_bound_chat_request_v2_with_nonce( + &[13; 32], + &x25519_public_key(&recipient_private), + "egui-chat.paseo", + &sender_account_id, + &recipient_account_id, + &channel_id, + &request, + [14; 12], + ) + .unwrap(); + assert_eq!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.paseo", + &recipient_account_id, + &channel_id, + &wrapper, + ) + .unwrap(), + request + ); + assert!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.westend", + &recipient_account_id, + &channel_id, + &wrapper, + ) + .is_err() + ); + assert!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.paseo", + &recipient_account_id, + &[16; 32], + &wrapper, + ) + .is_err() + ); + assert!(open_chat_request_v2(&recipient_private, &wrapper).is_err()); + assert!(decode_context_bound_chat_request_v2(&[99; 32], &channel_id, &wrapper).is_err()); + assert!( + decode_context_bound_chat_request_v2( + &recipient_account_id, + &channel_id, + &wrapper[..163] + ) + .is_err() + ); + let mut unsupported_version = wrapper.clone(); + unsupported_version[5] = 4; + assert!(is_context_bound_chat_request_v2(&unsupported_version)); + assert!( + decode_context_bound_chat_request_v2( + &recipient_account_id, + &channel_id, + &unsupported_version, + ) + .is_err() + ); + } + + #[test] + fn bare_message_exchange_has_no_statement_data_index() { + let request = + encode_message_exchange_request_plaintext("inner", &[vec![0xaa, 0xbb]]).unwrap(); + assert_eq!( + request, + [vec![0x14], b"inner".to_vec(), vec![0x04, 0x08, 0xaa, 0xbb],].concat() + ); + assert_eq!( + decode_message_exchange_request_plaintext(&request).unwrap(), + V2MessageExchangeRequest { + request_id: "inner".into(), + messages: vec![vec![0xaa, 0xbb]], + } + ); + let response = encode_message_exchange_response_plaintext("inner", 0).unwrap(); + assert_eq!(response, [vec![0x14], b"inner".to_vec(), vec![0]].concat()); + assert_eq!( + decode_message_exchange_response_plaintext(&response).unwrap(), + V2MessageExchangeResponse { + request_id: "inner".into(), + response_code: 0, + } + ); + } + + #[test] + fn oversized_collection_counts_are_rejected_before_allocation() { + assert!(decode_transport_plaintext(&[2, 0, 3, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!( + decode_message_exchange_request_plaintext(&[0, 3, 0xff, 0xff, 0xff, 0xff]).is_err() + ); + } +} diff --git a/rust/crates/truapi-host-cli/NOTICE b/rust/crates/truapi-host-cli/NOTICE index c5dde8f4f..553bc8d9a 100644 --- a/rust/crates/truapi-host-cli/NOTICE +++ b/rust/crates/truapi-host-cli/NOTICE @@ -7,9 +7,10 @@ d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. Detailed provenance accompanies the truapi-coinage crate in its NOTICE. Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. -Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, -based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, -including local native-attachment codec and secret-zeroization modifications. +Native Chat wire/crypto is included in rust/crates/truapi-chat-v2, derived from +paritytech/polkavm-app-kit useragent-chat-v2 at revision +57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, with native +attachment codec and secret-zeroization modifications included in this source. Corresponding Source must include the exact Host source revision, all local modifications, dependency provenance/license notices and build instructions. diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index fff1b2262..987d06bb8 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -35,7 +35,7 @@ truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } truapi-macros = { path = "../truapi-macros" } truapi-coinage = { path = "../truapi-coinage" } -useragent-chat-v2 = { git = "https://github.com/paritytech/polkavm-app-kit.git", rev = "57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17", default-features = false } +truapi-chat-v2 = { path = "../truapi-chat-v2", default-features = false } async-trait = "0.1" derive_more = { version = "2", features = ["debug", "display", "error", "from"] } futures = "0.3" diff --git a/rust/crates/truapi-server/NOTICE b/rust/crates/truapi-server/NOTICE index 76e9ee8e5..6b1d62150 100644 --- a/rust/crates/truapi-server/NOTICE +++ b/rust/crates/truapi-server/NOTICE @@ -7,9 +7,10 @@ d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. Detailed provenance accompanies the truapi-coinage crate in its NOTICE. Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. -Native Chat wire/crypto uses paritytech/polkavm-app-kit useragent-chat-v2, -based on revision 57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, -including local native-attachment codec and secret-zeroization modifications. +Native Chat wire/crypto is included in rust/crates/truapi-chat-v2, derived from +paritytech/polkavm-app-kit useragent-chat-v2 at revision +57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, with native +attachment codec and secret-zeroization modifications included in this source. Corresponding Source must include the exact Host source revision, all local modifications, dependency provenance/license notices and build instructions. diff --git a/rust/crates/truapi-server/src/runtime/chat_device.rs b/rust/crates/truapi-server/src/runtime/chat_device.rs index 0c67c43bb..cfe19c410 100644 --- a/rust/crates/truapi-server/src/runtime/chat_device.rs +++ b/rust/crates/truapi-server/src/runtime/chat_device.rs @@ -10,7 +10,7 @@ pub(crate) use rich::{MAX_ATTACHMENTS, RichContent, validate_metadata}; use parity_scale_codec::{Compact, Decode, Encode}; use truapi::latest::HostNativeChatRichMessageKind as RichKind; -use useragent_chat_v2::{self as chat, V2ChatMessageContent, V2StatementTransportData}; +use truapi_chat_v2::{self as chat, V2ChatMessageContent, V2StatementTransportData}; use zeroize::Zeroizing; /// Maximum authenticated devices in a native Chat recipient roster. diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index 44911e2a4..b36766857 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -15,7 +15,7 @@ use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }; use truapi::latest::*; -use useragent_chat_v2 as wire; +use truapi_chat_v2 as wire; use zeroize::{Zeroize, Zeroizing}; use super::{ From e4af1e67f9ce6c7c2e7da17d9f2b76a6faff7445 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 21 Sep 2026 20:28:50 -0400 Subject: [PATCH 25/67] build(chat): lock dependency resolution for reproducible Host generation --- .changeset/chat-product-authority.md | 82 +- README.md | 615 +++++----- docs/rfcs/0017-coinage-payment.md | 562 ++++----- docs/rfcs/native-chat-main-purse.md | 662 +++++------ ios/truapi-host/README.md | 330 +++--- js/packages/truapi-host/README.md | 372 +++--- .../truapi-host/src/adapter-support.ts | 28 +- .../src/host-callbacks-adapter.test.ts | 155 ++- js/packages/truapi-host/src/runtime.ts | 4 +- js/packages/truapi-host/src/test-support.ts | 12 +- .../src/web/create-worker-host-runtime.ts | 113 +- js/packages/truapi-host/src/web/index.ts | 5 +- .../truapi-host/src/web/native-chat-files.ts | 389 ++++-- .../src/web/native-chat-media.test.ts | 189 ++- .../truapi-host/src/web/native-chat-media.ts | 224 +++- .../src/web/worker-provider.test.ts | 199 +++- .../truapi-host/src/worker-protocol.ts | 7 +- js/packages/truapi-host/src/worker-runtime.ts | 57 +- js/packages/truapi/src/client.test.ts | 75 +- rust/crates/truapi-host-cli/README.md | 1048 +++++++---------- rust/crates/truapi-server/README.md | 342 +++--- scripts/codegen.sh | 4 +- 22 files changed, 2813 insertions(+), 2661 deletions(-) diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md index 17f50a6b9..6179cb66b 100644 --- a/.changeset/chat-product-authority.md +++ b/.changeset/chat-product-authority.md @@ -8,51 +8,41 @@ results. Support keyless Statement Store allowances for a selected product account in the native signing host. -Replace raw guest Chat crypto with a Host-owned native actor on account method 12; -retire method 11, including over SSO. Keep main-purse Coinage secrets and durable -claim/recovery plans behind trusted per-payment review. Add private nested HOP -history recovery and resumable native file/image/video attachments with trusted -selection/export, metadata-only guest views and live Bulletin endpoint/session -fences. The combined signing runtime includes AGPL-3.0-only code; retain the -included provenance, licenses and exact Corresponding Source. - -Include the complete native Chat wire, attachment and cryptography implementation -as the source-owned `truapi-chat-v2` crate, with upstream provenance and licensing. -Remove the release dependency on unpublished local Cargo overrides. - -Align Coinage keys with current iOS MAIN_PURSE/page-0 derivations, including the -soft coin item junction. Keep complete exported coin secrets stable for durable -payment replay. Authenticate the new purse layout through snapshot version 3; -reject legacy `//pps` snapshots without discarding pending wallet state. Native -iOS allocator sharing remains a separate integration requirement. - -Read origin-specific free Coinage unload-token limits from the runtime view at -the finalized planning snapshot, rather than a removed metadata constant. -Recover full Statement Store accounts by refreshing only the signed statement's -priority while preserving committed ciphertext and payment IDs; continue other -peers' queued deliveries when one account or channel is blocked. - -Accept validated native push-token metadata without discarding the surrounding -iOS acceptance batch. Keep token credentials out of guest history and storage, -while binding the complete metadata frame into authenticated replay detection. - -Match native iOS acknowledgment direction on identity and device sessions. -Subscribe to peer-originating routes and encrypt replies on the Host's own -outgoing route. Repair previously queued reverse-route acknowledgments on +Replace raw guest Chat crypto with a Host-owned native actor on account method 12; retire method 11, including over SSO. +Keep main-purse Coinage secrets and durable claim/recovery plans behind trusted per-payment review. Add private nested +HOP history recovery and resumable native file/image/video attachments with trusted selection/export, metadata-only +guest views and live Bulletin endpoint/session fences. The combined signing runtime includes AGPL-3.0-only code; retain +the included provenance, licenses and exact Corresponding Source. + +Include the complete native Chat wire, attachment and cryptography implementation as the source-owned `truapi-chat-v2` +crate, with upstream provenance and licensing. Remove the release dependency on unpublished local Cargo overrides. + +Align Coinage keys with current iOS MAIN_PURSE/page-0 derivations, including the soft coin item junction. Keep complete +exported coin secrets stable for durable payment replay. Authenticate the new purse layout through snapshot version 3; +reject legacy `//pps` snapshots without discarding pending wallet state. Native iOS allocator sharing remains a separate +integration requirement. + +Read origin-specific free Coinage unload-token limits from the runtime view at the finalized planning snapshot, rather +than a removed metadata constant. Recover full Statement Store accounts by refreshing only the signed statement's +priority while preserving committed ciphertext and payment IDs; continue other peers' queued deliveries when one account +or channel is blocked. + +Accept validated native push-token metadata without discarding the surrounding iOS acceptance batch. Keep token +credentials out of guest history and storage, while binding the complete metadata frame into authenticated replay +detection. + +Match native iOS acknowledgment direction on identity and device sessions. Subscribe to peer-originating routes and +encrypt replies on the Host's own outgoing route. Repair previously queued reverse-route acknowledgments on authenticated replay while preserving payment and message commitments. -Allow outgoing payments once an active, keyed peer device acknowledges the -legacy-device revocation update. Encrypt only for acknowledged devices and -reject payment acknowledgments from devices excluded from the committed envelope. -Reset eligibility after authenticated roster changes while preserving payment -identity and exact memo custody through rewrapping and retries. - -Document the method 12 request/response, compatibility, custody, device -eligibility, and per-spend consent contract in the unnumbered -[draft native Chat/main-purse RFC](../docs/rfcs/native-chat-main-purse.md), -submitted for review with this implementation. Clarify its relationship to -[RFC 0017](../docs/rfcs/0017-coinage-payment.md), including the distinct integer -amount/asset contracts and the absence of general purse APIs or a Chat balance -query. Treat same-wallet competing native/Rust allocators as a release blocker. -This specification link does not assert RFC approval, publication, or -cross-platform qualification. +Allow outgoing payments once an active, keyed peer device acknowledges the legacy-device revocation update. Encrypt only +for acknowledged devices and reject payment acknowledgments from devices excluded from the committed envelope. Reset +eligibility after authenticated roster changes while preserving payment identity and exact memo custody through +rewrapping and retries. + +Document the method 12 request/response, compatibility, custody, device eligibility, and per-spend consent contract in +the unnumbered [draft native Chat/main-purse RFC](../docs/rfcs/native-chat-main-purse.md), submitted for review with +this implementation. Clarify its relationship to [RFC 0017](../docs/rfcs/0017-coinage-payment.md), including the +distinct integer amount/asset contracts and the absence of general purse APIs or a Chat balance query. Treat same-wallet +competing native/Rust allocators as a release blocker. This specification link does not assert RFC approval, +publication, or cross-platform qualification. diff --git a/README.md b/README.md index 1200266f0..c5b8293e2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # TrUAPI -TrUAPI (Triangle User-Agent Programming Interface) is the API surface that hosts like the Polkadot Desktop Browser expose to the products that run inside them. One Rust crate defines the contract, a code generator produces a typed TypeScript client, and hosts and products implement against the same shared types. +TrUAPI (Triangle User-Agent Programming Interface) is the API surface that hosts like the Polkadot Desktop Browser +expose to the products that run inside them. One Rust crate defines the contract, a code generator produces a typed +TypeScript client, and hosts and products implement against the same shared types. -> [!WARNING] -> The following is a prototype, reference implementation, and proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. +> [!WARNING] The following is a prototype, reference implementation, and proof-of-concept. This open source code is +> provided for research, experimentation, and developer education only. This code has not been audited, is actively +> experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/host-rust-core/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/host-rust-core/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-rustdoc-blue?style=flat-square)](https://paritytech.github.io/host-rust-core) [![Playground](https://img.shields.io/badge/playground-live-success?style=flat-square)](https://truapi-playground.paseo.li/) - ## Documentation - [TrUAPI reference](https://docs.polkadot.com/reference/apps/protocol/truapi/) @@ -21,36 +23,43 @@ TrUAPI (Triangle User-Agent Programming Interface) is the API surface that hosts ## Try it -Browse the published Rust API docs at [paritytech.github.io/host-rust-core](https://paritytech.github.io/host-rust-core). +Browse the published Rust API docs at +[paritytech.github.io/host-rust-core](https://paritytech.github.io/host-rust-core). -The interactive playground lets you browse every method, edit request payloads, and call or subscribe to them live against a connected host. It also drives an end-to-end **Diagnosis** that produces a per-host pass/fail report ([playground/README.md → Diagnosis](playground/README.md#diagnosis)). The explorer aggregates those reports into a cross-host **Compatibility** matrix ([explorer/README.md → Host compatibility matrix](explorer/README.md#host-compatibility-matrix)). +The interactive playground lets you browse every method, edit request payloads, and call or subscribe to them live +against a connected host. It also drives an end-to-end **Diagnosis** that produces a per-host pass/fail report +([playground/README.md → Diagnosis](playground/README.md#diagnosis)). The explorer aggregates those reports into a +cross-host **Compatibility** matrix +([explorer/README.md → Host compatibility matrix](explorer/README.md#host-compatibility-matrix)). -**Live:** [truapi-playground.paseo.li](https://truapi-playground.paseo.li/) (open from inside the Polkadot Desktop Browser) +**Live:** [truapi-playground.paseo.li](https://truapi-playground.paseo.li/) (open from inside the Polkadot Desktop +Browser) ## Install the CLI -`truapi-host` runs a TrUAPI host on your machine, so you can develop and test a product without a phone or a desktop host build: +`truapi-host` runs a TrUAPI host on your machine, so you can develop and test a product without a phone or a desktop +host build: ```bash curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scripts/truapi-host-installer.sh | bash ``` -Prebuilt for macOS on Apple silicon and Linux on x86_64 and arm64. No Rust toolchain or checkout needed, and it keeps itself up to date. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for the commands, the terminal UI, and product scripts. +Prebuilt for macOS on Apple silicon and Linux on x86_64 and arm64. No Rust toolchain or checkout needed, and it keeps +itself up to date. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for the commands, the +terminal UI, and product scripts. ## Usage -`@parity/truapi` is the low-level generated protocol client. Product apps should normally use a higher-level product SDK, such as [`paritytech/product-sdk`](https://github.com/paritytech/product-sdk), while SDK and host-integration layers can depend on this package directly. +`@parity/truapi` is the low-level generated protocol client. Product apps should normally use a higher-level product +SDK, such as [`paritytech/product-sdk`](https://github.com/paritytech/product-sdk), while SDK and host-integration +layers can depend on this package directly. ```bash npm install @parity/truapi ``` ```ts -import { - createClient, - createMessagePortProvider, - createTransport, -} from "@parity/truapi"; +import { createClient, createMessagePortProvider, createTransport } from "@parity/truapi"; const transport = createTransport(createMessagePortProvider(port)); const truapi = createClient(transport); @@ -60,97 +69,73 @@ const result = await truapi.accountManagement.accountGet({ }); ``` -The transport retries iframe bootstrap until the host channel arrives and rejects unanswered -requests after a bounded deadline; pass `requestTimeoutMs` to `createTransport` to override it. +The transport retries iframe bootstrap until the host channel arrives and rejects unanswered requests after a bounded +deadline; pass `requestTimeoutMs` to `createTransport` to override it. See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full client reference. -`account.deviceChat` is a high-level, Host-owned native Chat actor -(`Account::product_device_chat` in Rust). -Account method 12 initializes a private device, manages authenticated peers, -receives/decrypts native traffic, and sends ordinary messages or reviewed Coinage -payments. Retired method 11 and its raw Open/Seal/proof operations are unsupported, -including over SSO. Guest and Host must upgrade together. - -The signing Host owns the device secret, encrypted roster/outbox, payment WAL, -and spendable memos. Chat-authority permission is not spending permission: -every outgoing main-purse payment requires a separate trusted Host review. -Stable retries resume the same recipient/amount operation; incoming batches -require durable custody and complete claim plans before acknowledgment. -Delivery acknowledgment and finalized clearing are separate states. - -The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies -the method 12 request/response and compatibility contract, device eligibility, -custody-before-ACK rule, and delivery versus clearing semantics. It is a draft -for review in #709, not an approved standard or a release claim. It builds on -[RFC 0017's](docs/rfcs/0017-coinage-payment.md) main-purse custody model without -implementing its general purse/receivable/cheque APIs. Chat amounts are `u64` -cents of the trusted selected Coinage asset; RFC 0017's `u32` dotUSD-cent -`Balance` is not an interchangeable type. Method 12 provides payment cards, -not a product-visible wallet balance. - -The Coinage engine uses the current iOS MAIN_PURSE/page-0 paths: -`//coinage//4294967295//0/` (soft item) and -`//coinage-ring-vrf//4294967295//0//` (hard item). -Snapshot version 3 rejects legacy `//pps` snapshots without modifying them; -old counters, reservations and pending memos must not be reinterpreted under -the new keys. Native iOS CoreData/Keychain and Host snapshots are still separate: -do not operate both allocators for the same wallet. Same-wallet integration -requires explicit state reconciliation and a single allocator owner. Competing -native and Rust allocators able to spend the same inventory are a release -blocker, not an acceptable temporary integration state. -Runtime storage keys and asset-instance -encoding come from metadata. Instance-scoped runtimes require a trusted -`coinage_instance_id`; the encrypted wallet binds that selection permanently, -so a configuration change cannot retarget pending claims or payments. - -Once initialized and authorized, a Host-owned subscription receives and -reconciles without an open guest. Revocation, logout, or session replacement -stops the old receiver. This is in-process execution, not OS wake support. -The draft requires a durable initialized-product index and post-unlock receiver -restoration only for products whose Chat and transport grants remain valid; -embedding Hosts must qualify that cold-restart path separately. - -Native push-token announcements are validated as private metadata, including -when batched with iOS acceptance controls. Their timestamps are checked and -their digests bind replay detection; token credentials are discarded rather -than persisted or exposed to the guest. This actor has no mobile push provider -and does not wake a backgrounded native client. - -Native requests and acknowledgments use the sender's own outgoing identity or -device session; responses do not reuse the original requester's session. -Authenticated request replay repairs queued acknowledgments from the old -reversed route without replacing message or payment commitments. Outgoing -payment readiness requires at least one active, keyed peer device to acknowledge -the legacy-device revocation update. Payment envelopes include only those -acknowledged devices, so an offline advertised device does not block an eligible -recipient. Payment acknowledgments must come from a recipient of the committed -envelope. Authenticated roster changes reset eligibility; retries preserve the -payment identity and exact memo when rewrapping for the updated recipients. -Ordinary chat does not require these revocation acknowledgments. -Incoming payments instead require an authenticated admitted sender and durable -memo custody and claim plans before acknowledgment; they do not use that -outgoing readiness gate. - -Native HOP history is expanded privately, including nested compacted batches; -all payment claim plans and file references are durable before either HOP or -statement acknowledgment. Attachments use trusted Host selection/export, -bounded encrypted chunk storage, resumable uploads/downloads and immutable -retry IDs/ciphertexts. Guests receive metadata, progress and opaque file IDs, -never claim tickets, URLs, source handles or file bytes. Uploads require the -existing Bulletin allowance and Preimage-submit permission; this grants no -Coinage spending authority. HOP connections use the live trusted Bulletin WSS -allowlist, not arbitrary guest endpoints. - -Protocol/storage fixtures cover acceptance loss, restart and download after -pool deletion. This is not evidence of a funded native-device round trip. -Attachment-bearing first-contact welcomes and call signaling remain rejected. - -Native Chat wire, cryptography, attachment codecs, and secret-zeroization changes -are included in the workspace's `truapi-chat-v2` crate. Building the Host requires -neither a local Cargo override nor an unpublished guest SDK checkout. -Distributing the runtime also requires the exact modified Corresponding Source, -not only the base repository URLs in the notices. +`account.deviceChat` is a high-level, Host-owned native Chat actor (`Account::product_device_chat` in Rust). Account +method 12 initializes a private device, manages authenticated peers, receives/decrypts native traffic, and sends +ordinary messages or reviewed Coinage payments. Retired method 11 and its raw Open/Seal/proof operations are +unsupported, including over SSO. Guest and Host must upgrade together. + +The signing Host owns the device secret, encrypted roster/outbox, payment WAL, and spendable memos. Chat-authority +permission is not spending permission: every outgoing main-purse payment requires a separate trusted Host review. Stable +retries resume the same recipient/amount operation; incoming batches require durable custody and complete claim plans +before acknowledgment. Delivery acknowledgment and finalized clearing are separate states. + +The [native Chat/main-purse RFC](docs/rfcs/native-chat-main-purse.md) specifies the method 12 request/response and +compatibility contract, device eligibility, custody-before-ACK rule, and delivery versus clearing semantics. It is a +draft for review in #709, not an approved standard or a release claim. It builds on +[RFC 0017's](docs/rfcs/0017-coinage-payment.md) main-purse custody model without implementing its general +purse/receivable/cheque APIs. Chat amounts are `u64` cents of the trusted selected Coinage asset; RFC 0017's `u32` +dotUSD-cent `Balance` is not an interchangeable type. Method 12 provides payment cards, not a product-visible wallet +balance. + +The Coinage engine uses the current iOS MAIN_PURSE/page-0 paths: `//coinage//4294967295//0/` (soft item) and +`//coinage-ring-vrf//4294967295//0//` (hard item). Snapshot version 3 rejects legacy `//pps` snapshots without +modifying them; old counters, reservations and pending memos must not be reinterpreted under the new keys. Native iOS +CoreData/Keychain and Host snapshots are still separate: do not operate both allocators for the same wallet. Same-wallet +integration requires explicit state reconciliation and a single allocator owner. Competing native and Rust allocators +able to spend the same inventory are a release blocker, not an acceptable temporary integration state. Runtime storage +keys and asset-instance encoding come from metadata. Instance-scoped runtimes require a trusted `coinage_instance_id`; +the encrypted wallet binds that selection permanently, so a configuration change cannot retarget pending claims or +payments. + +Once initialized and authorized, a Host-owned subscription receives and reconciles without an open guest. Revocation, +logout, or session replacement stops the old receiver. This is in-process execution, not OS wake support. The draft +requires a durable initialized-product index and post-unlock receiver restoration only for products whose Chat and +transport grants remain valid; embedding Hosts must qualify that cold-restart path separately. + +Native push-token announcements are validated as private metadata, including when batched with iOS acceptance controls. +Their timestamps are checked and their digests bind replay detection; token credentials are discarded rather than +persisted or exposed to the guest. This actor has no mobile push provider and does not wake a backgrounded native +client. + +Native requests and acknowledgments use the sender's own outgoing identity or device session; responses do not reuse the +original requester's session. Authenticated request replay repairs queued acknowledgments from the old reversed route +without replacing message or payment commitments. Outgoing payment readiness requires at least one active, keyed peer +device to acknowledge the legacy-device revocation update. Payment envelopes include only those acknowledged devices, so +an offline advertised device does not block an eligible recipient. Payment acknowledgments must come from a recipient of +the committed envelope. Authenticated roster changes reset eligibility; retries preserve the payment identity and exact +memo when rewrapping for the updated recipients. Ordinary chat does not require these revocation acknowledgments. +Incoming payments instead require an authenticated admitted sender and durable memo custody and claim plans before +acknowledgment; they do not use that outgoing readiness gate. + +Native HOP history is expanded privately, including nested compacted batches; all payment claim plans and file +references are durable before either HOP or statement acknowledgment. Attachments use trusted Host selection/export, +bounded encrypted chunk storage, resumable uploads/downloads and immutable retry IDs/ciphertexts. Guests receive +metadata, progress and opaque file IDs, never claim tickets, URLs, source handles or file bytes. Uploads require the +existing Bulletin allowance and Preimage-submit permission; this grants no Coinage spending authority. HOP connections +use the live trusted Bulletin WSS allowlist, not arbitrary guest endpoints. + +Protocol/storage fixtures cover acceptance loss, restart and download after pool deletion. This is not evidence of a +funded native-device round trip. Attachment-bearing first-contact welcomes and call signaling remain rejected. + +Native Chat wire, cryptography, attachment codecs, and secret-zeroization changes are included in the workspace's +`truapi-chat-v2` crate. Building the Host requires neither a local Cargo override nor an unpublished guest SDK checkout. +Distributing the runtime also requires the exact modified Corresponding Source, not only the base repository URLs in the +notices. ## Repository layout @@ -192,105 +177,97 @@ scripts/battery.sh Run the generated battery against both headless CLI h plus the Pocket phase a Worker execution serves ``` -The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime -live in -[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). -Native hosts that need both runtimes link the optional `truapi-polkavm-host` -composition crate; the base `truapi-server` remains PolkaVM-free. Browser hosts -consume `@parity/polkavm-browser-runtime` directly; browser assets are not -shipped from this repository. - -Taking a screenshot opens **Report app issue** wherever the shake-opened Debug -menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` -on Android, false only for the `release` build type, and `TESTNET_FEATURE` on -iOS, unset only for the `Release` configuration. Android screenshot detection -requires Android 14+. -The modal includes a snapshot of the app screen, a description, and ZIP logs. -Send uploads the report through [issue-proxy](https://github.com/paritytech/issue-proxy). -Configure these Firebase Remote Config string parameters for each mobile environment: - -| Parameter | Value | -| --- | --- | -| `issue_proxy_url` | Full HTTPS endpoint, including `/v1/issues` | +The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime live in +[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). Native hosts that need both +runtimes link the optional `truapi-polkavm-host` composition crate; the base `truapi-server` remains PolkaVM-free. +Browser hosts consume `@parity/polkavm-browser-runtime` directly; browser assets are not shipped from this repository. + +Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the +store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on +iOS, unset only for the `Release` configuration. Android screenshot detection requires Android 14+. The modal includes a +snapshot of the app screen, a description, and ZIP logs. Send uploads the report through +[issue-proxy](https://github.com/paritytech/issue-proxy). Configure these Firebase Remote Config string parameters for +each mobile environment: + +| Parameter | Value | +| --------------------- | --------------------------------------------------------- | +| `issue_proxy_url` | Full HTTPS endpoint, including `/v1/issues` | | `issue_proxy_api_key` | The proxy's `ISSUE_PROXY_API_KEY`, sent as a bearer token | -Both hosts use the app's existing Remote Config readiness path before reading -the URL and key. There are no bundled defaults; missing configuration shows an -error. Remote Config values are readable by clients, so the GitHub credential -stays on the proxy and must never be placed here. The thank-you popup appears only after HTTP 201. Uploads -include PNG screenshots up to 10 MiB and ZIP logs, with a 25 MiB limit for the -whole multipart request. The Debug menu and **Share logs** remain available. - -See the [proc-macro guide](rust/crates/truapi-macros/README.md) for typed SSO handlers, their shared response envelope, and the macro implementation modules. - -The Swift host adapter (the `TrUAPIHost` SPM package over the truapi-server -UniFFI core) lives under [`ios/truapi-host/`](ios/truapi-host), with its SPM -manifest at the repo root (`Package.swift`) so apps can consume it as a git-URL -dependency. The UniFFI bindings and the container bundle are gitignored build -outputs; `scripts/rebuild.sh` regenerates them along with the xcframework -(`make xcframework` + `make uniffi`); see -[`ios/truapi-host/README.md`](ios/truapi-host/README.md). -Native bindings expose the canonical Rust domain and protocol value types; -native-only adapter types are limited to lifecycle and callback behavior. -On iOS, a wallet host that manages its own statement-store SSO session can call -`handleSsoRequest` (routes one decrypted remote message through the core, -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`. +Both hosts use the app's existing Remote Config readiness path before reading the URL and key. There are no bundled +defaults; missing configuration shows an error. Remote Config values are readable by clients, so the GitHub credential +stays on the proxy and must never be placed here. The thank-you popup appears only after HTTP 201. Uploads include PNG +screenshots up to 10 MiB and ZIP logs, with a 25 MiB limit for the whole multipart request. The Debug menu and **Share +logs** remain available. + +See the [proc-macro guide](rust/crates/truapi-macros/README.md) for typed SSO handlers, their shared response envelope, +and the macro implementation modules. + +The Swift host adapter (the `TrUAPIHost` SPM package over the truapi-server UniFFI core) lives under +[`ios/truapi-host/`](ios/truapi-host), with its SPM manifest at the repo root (`Package.swift`) so apps can consume it +as a git-URL dependency. The UniFFI bindings and the container bundle are gitignored build outputs; `scripts/rebuild.sh` +regenerates them along with the xcframework (`make xcframework` + `make uniffi`); see +[`ios/truapi-host/README.md`](ios/truapi-host/README.md). Native bindings expose the canonical Rust domain and protocol +value types; native-only adapter types are limited to lifecycle and callback behavior. On iOS, a wallet host that +manages its own statement-store SSO session can call `handleSsoRequest` (routes one decrypted remote message through the +core, 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 -JS hosts integrate the Rust core through [`@parity/truapi-host`](js/packages/truapi-host), -a single package with tree-shakeable subpath entries: +JS hosts integrate the Rust core through [`@parity/truapi-host`](js/packages/truapi-host), a single package with +tree-shakeable subpath entries: - `@parity/truapi-host` (the `.` entry) exposes shared host runtime types and generated callback contracts. -- `@parity/truapi-host/web` wires the WASM provider into a browser host: the iframe - MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. -- `@parity/truapi-host/worker-runtime` is the Web Worker entrypoint so the WASM core can - run off the page main thread. +- `@parity/truapi-host/web` wires the WASM provider into a browser host: the iframe MessageChannel handshake + (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host/worker-runtime` is the Web Worker entrypoint so the WASM core can run off the page main thread. ### Chain transport -A host that serves chain traffic itself embeds the `truapi-provider` crate: an -embedded smoldot light client plus a bundled chain-spec catalog, addressed by -genesis hash, so the host ships no chain specs and never refreshes them. The light -client holds at most 32 connections at once and refuses a `connect` past that, so a -consumer that leaks them fails instead of growing; closing one hands its slot back. -Connections to a remote node, which only the WASM build compiles, are not counted -against it. The crate -compiles to one binary artifact per platform, each exposing the same -`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a -dependency on the crate: - -- [`@parity/truapi-provider`](js/packages/truapi-provider) is the WASM build for - browser and webview hosts, rebuilt by `make wasm` alongside the host bundle. -- [`TrUAPIProvider`](ios/truapi-provider) is the second product of the root - `Package.swift`, an xcframework plus generated Swift bindings, built by - `make provider-ios`. -- [`truapi-provider-android`](android/truapi-provider) is an AAR carrying the - Kotlin bindings and the cdylib per ABI, built by - `make provider-android-publish-local`. - -A light client that starts cold warp syncs from the checkpoint in the chain spec, so -every artifact resumes from stored finalized state instead, including the relay a -parachain syncs through. The provider owns when a blob is read and written; the -host owns where the bytes live. The crate stores nothing itself: a host implements -`StorageClient` over storage it already owns, on web and native alike, so it keeps -control of quota and of whether the bytes are backed up or encrypted. +A host that serves chain traffic itself embeds the `truapi-provider` crate: an embedded smoldot light client plus a +bundled chain-spec catalog, addressed by genesis hash, so the host ships no chain specs and never refreshes them. The +light client holds at most 32 connections at once and refuses a `connect` past that, so a consumer that leaks them fails +instead of growing; closing one hands its slot back. Connections to a remote node, which only the WASM build compiles, +are not counted against it. The crate compiles to one binary artifact per platform, each exposing the same +`ChainProvider` contract, so a consumer needs neither a Rust toolchain nor a dependency on the crate: + +- [`@parity/truapi-provider`](js/packages/truapi-provider) is the WASM build for browser and webview hosts, rebuilt by + `make wasm` alongside the host bundle. +- [`TrUAPIProvider`](ios/truapi-provider) is the second product of the root `Package.swift`, an xcframework plus + generated Swift bindings, built by `make provider-ios`. +- [`truapi-provider-android`](android/truapi-provider) is an AAR carrying the Kotlin bindings and the cdylib per ABI, + built by `make provider-android-publish-local`. + +A light client that starts cold warp syncs from the checkpoint in the chain spec, so every artifact resumes from stored +finalized state instead, including the relay a parachain syncs through. The provider owns when a blob is read and +written; the host owns where the bytes live. The crate stores nothing itself: a host implements `StorageClient` over +storage it already owns, on web and native alike, so it keeps control of quota and of whether the bytes are backed up or +encrypted. ## How it works -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/`, the Rust host dispatcher, and the transport-neutral `no_std` Rust client. The Rust client exports typed method markers plus complete App, Widget, Worker, and Worker-only catalogs from the same wire schema. -3. Higher-level SDKs wrap the generated client; each runtime provides only its native frame transport. Browser products use `MessagePort` (or `postMessage` in iframe mode), while sandboxed runtimes such as PolkaVM supply explicit host imports. +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/`, the Rust host dispatcher, and the transport-neutral `no_std` Rust client. The Rust client + exports typed method markers plus complete App, Widget, Worker, and Worker-only catalogs from the same wire schema. +3. Higher-level SDKs wrap the generated client; each runtime provides only its native frame transport. Browser products + use `MessagePort` (or `postMessage` in iframe mode), while sandboxed runtimes such as PolkaVM supply explicit host + imports. 4. The host decodes the frame, dispatches to the matching trait method, encodes the response, and ships it back. -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. +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 @@ -304,23 +281,19 @@ make check # full suite: build, fmt, clippy, test, TS tests, playground build make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/ ``` -CI regenerates the shared bindings before building and testing both npm -packages, so generated client and host callback changes are checked together. +CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback +changes are checked together. -The native `truapi-host` utility runs pairing and signing hosts against the real -SSO transport for local end-to-end work. See [Install the CLI](#install-the-cli) -to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) -for its commands and controls. +The native `truapi-host` utility runs pairing and signing hosts against the real SSO transport for local end-to-end +work. See [Install the CLI](#install-the-cli) to get it, and the +[`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for its commands and controls. -CLI reserved identities follow the selected network's dotNS suffix. Old account -and pairing stores are left unused as the CLI starts fresh under its -[versioned state directory](rust/crates/truapi-host-cli/README.md#state-directory). +CLI reserved identities follow the selected network's dotNS suffix. Old account and pairing stores are left unused as +the CLI starts fresh under its [versioned state directory](rust/crates/truapi-host-cli/README.md#state-directory). -`scripts/battery.sh` drives that CLI from source over every code-generated -example and writes both committed compatibility reports: -`explorer/diagnosis-reports/spa/signing-host-cli.md` from a direct signing-host -run, and `spa/pairing-host-cli.md` from a pairing host that the script pairs with a -signing host it starts itself. +`scripts/battery.sh` drives that CLI from source over every code-generated example and writes both committed +compatibility reports: `explorer/diagnosis-reports/spa/signing-host-cli.md` from a direct signing-host run, and +`spa/pairing-host-cli.md` from a pairing host that the script pairs with a signing host it starts itself. ```bash scripts/battery.sh # both phases @@ -332,107 +305,88 @@ make e2e-chat-cli # chat content screening against a chat sign make e2e-pocket-cli # Pocket protocol check against a Pocket signing-host ``` -The Pocket phase runs its product as a Worker execution, the only execution -Pocket is served to. It seeds the CLI's in-memory Pocket host from -`TRUAPI_POCKET_CARDS` (`loyalty,humanity:privileged`), which is what makes one -card removable and one privileged, and records every removal it is asked for in -the transcript named by `TRUAPI_POCKET_LOG`. The cases read that transcript, so -a pass means the host and the product agree on what happened rather than -resting on the product's word. The report lands at -`explorer/diagnosis-reports/pocket/signing-host-cli.md` and feeds the explorer's +The Pocket phase runs its product as a Worker execution, the only execution Pocket is served to. It seeds the CLI's +in-memory Pocket host from `TRUAPI_POCKET_CARDS` (`loyalty,humanity:privileged`), which is what makes one card removable +and one privileged, and records every removal it is asked for in the transcript named by `TRUAPI_POCKET_LOG`. The cases +read that transcript, so a pass means the host and the product agree on what happened rather than resting on the +product's word. The report lands at `explorer/diagnosis-reports/pocket/signing-host-cli.md` and feeds the explorer's Pocket compatibility matrix. -To run the playground locally in a plain browser tab, against a signing host on -your own machine: +To run the playground locally in a plain browser tab, against a signing host on your own machine: ```bash cd playground truapi-host dev -- yarn dev ``` -`truapi-host dev` starts a signing host on `127.0.0.1:9955`, waits for its -signer, then runs the wrapped command with the host already live. The product -reaches it through a development-only `"]) { - const metadata = await inspectNativeChatFileMetadata(new Blob([content], { type: "video/mp4" })); + for (const content of [ + "", + "", + ]) { + const metadata = await inspectNativeChatFileMetadata( + new Blob([content], { type: "video/mp4" }), + ); expect(metadata.kind.tag).toBe("File"); expect(metadata.mimeType).toBe("application/octet-stream"); expect(nativeChatExportFilename(metadata)).toBe("chat-attachment.bin"); @@ -105,13 +183,20 @@ describe("native Chat immutable media metadata", () => { const bytes = new Uint8Array(65_552); bytes.set([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff]); bytes.set([0xff, 0xc0, 0, 11, 8, 0, 10, 0, 10, 1, 1, 0x11, 0], 65_539); - expect((await inspectNativeChatFileMetadata(new Blob([bytes]))).kind.tag).toBe("File"); + expect( + (await inspectNativeChatFileMetadata(new Blob([bytes]))).kind.tag, + ).toBe("File"); }); - for (const fixture of [{ bytes: mp4, mime: "video/mp4", extension: "mp4" }, { bytes: webm, mime: "video/webm", extension: "webm" }]) { + for (const fixture of [ + { bytes: mp4, mime: "video/mp4", extension: "mp4" }, + { bytes: webm, mime: "video/webm", extension: "webm" }, + ]) { it(`probes whitelisted ${fixture.mime} bytes without playback and revokes its URL`, async () => { await withVideoDocument(async (videos) => { - const pending = inspectNativeChatFileMetadata(new Blob([fixture.bytes], { type: "text/html" })); + const pending = inspectNativeChatFileMetadata( + new Blob([fixture.bytes], { type: "text/html" }), + ); await settle(); expect(videos).toHaveLength(1); const video = videos[0]!; @@ -120,8 +205,17 @@ describe("native Chat immutable media metadata", () => { expect(video.autoplay).toBe(false); video.onloadedmetadata!(); const metadata = await pending; - expect(metadata).toEqual({ mimeType: fixture.mime, sizeBytes: fixture.bytes.length, kind: { tag: "Video", value: { durationSeconds: 12, thumbnail: undefined } } }); - expect(nativeChatExportFilename(metadata)).toBe(`chat-attachment.${fixture.extension}`); + expect(metadata).toEqual({ + mimeType: fixture.mime, + sizeBytes: fixture.bytes.length, + kind: { + tag: "Video", + value: { durationSeconds: 12, thumbnail: undefined }, + }, + }); + expect(nativeChatExportFilename(metadata)).toBe( + `chat-attachment.${fixture.extension}`, + ); await expect(fetch(url)).rejects.toThrow(); }); }); @@ -130,7 +224,10 @@ describe("native Chat immutable media metadata", () => { it("aborts a pending video probe, releases its URL and ignores a late event", async () => { await withVideoDocument(async (videos) => { const abort = new AbortController(); - const pending = inspectNativeChatFileMetadata(new Blob([mp4]), abort.signal); + const pending = inspectNativeChatFileMetadata( + new Blob([mp4]), + abort.signal, + ); await settle(); const video = videos[0]!; const url = video.src; @@ -158,11 +255,37 @@ describe("native Chat immutable media metadata", () => { }); it("never promotes active MIME types or inconsistent kinds into executable extensions", () => { - const image = { tag: "Image" as const, value: { width: 1, height: 1, thumbnail: undefined } }; - for (const mimeType of ["image/svg+xml", "text/html", "application/javascript", "__proto__", "image/png/../../x.html"]) { - expect(nativeChatExportFilename({ mimeType, sizeBytes: 1, kind: image })).toBe("chat-attachment.bin"); + const image = { + tag: "Image" as const, + value: { width: 1, height: 1, thumbnail: undefined }, + }; + for (const mimeType of [ + "image/svg+xml", + "text/html", + "application/javascript", + "__proto__", + "image/png/../../x.html", + ]) { + expect( + nativeChatExportFilename({ mimeType, sizeBytes: 1, kind: image }), + ).toBe("chat-attachment.bin"); } - expect(nativeChatExportFilename({ mimeType: "image/png", sizeBytes: 1, kind: { tag: "File" } })).toBe("chat-attachment.bin"); - expect(nativeChatExportFilename({ mimeType: "image/png", sizeBytes: 1, kind: { tag: "Image", value: { width: 0, height: 1, thumbnail: undefined } } })).toBe("chat-attachment.bin"); + expect( + nativeChatExportFilename({ + mimeType: "image/png", + sizeBytes: 1, + kind: { tag: "File" }, + }), + ).toBe("chat-attachment.bin"); + expect( + nativeChatExportFilename({ + mimeType: "image/png", + sizeBytes: 1, + kind: { + tag: "Image", + value: { width: 0, height: 1, thumbnail: undefined }, + }, + }), + ).toBe("chat-attachment.bin"); }); }); diff --git a/js/packages/truapi-host/src/web/native-chat-media.ts b/js/packages/truapi-host/src/web/native-chat-media.ts index 8d5326a74..8cb2b9877 100644 --- a/js/packages/truapi-host/src/web/native-chat-media.ts +++ b/js/packages/truapi-host/src/web/native-chat-media.ts @@ -5,12 +5,33 @@ type ImageHeader = { mimeType: string; width: number; height: number }; const HEADER_LIMIT = 65_536; const VIDEO_TIMEOUT_MS = 5_000; const U32_MAX = 0xffff_ffff; -const MP4_BRANDS = ["isom", "iso2", "iso3", "iso4", "iso5", "iso6", "mp41", "mp42", "avc1", "M4V ", "M4VH", "M4VP"]; +const MP4_BRANDS = [ + "isom", + "iso2", + "iso3", + "iso4", + "iso5", + "iso6", + "mp41", + "mp42", + "avc1", + "M4V ", + "M4VH", + "M4VP", +]; const PNG_DEPTHS: Readonly> = { - 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], }; -function matches(bytes: Uint8Array, offset: number, signature: string): boolean { +function matches( + bytes: Uint8Array, + offset: number, + signature: string, +): boolean { if (offset + signature.length > bytes.length) return false; for (let i = 0; i < signature.length; i++) { if (bytes[offset + i] !== signature.charCodeAt(i)) return false; @@ -19,17 +40,37 @@ function matches(bytes: Uint8Array, offset: number, signature: string): boolean } /** Only inspect bounded headers; never render images, XML, SVG or HTML. */ -function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefined { +function imageHeader( + bytes: Uint8Array, + fileSize: number, +): ImageHeader | undefined { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (bytes.length >= 33 && matches(bytes, 0, "\x89PNG\r\n\x1a\n") && view.getUint32(8) === 13 && matches(bytes, 12, "IHDR")) { + if ( + bytes.length >= 33 && + matches(bytes, 0, "\x89PNG\r\n\x1a\n") && + view.getUint32(8) === 13 && + matches(bytes, 12, "IHDR") + ) { const width = view.getUint32(16); const height = view.getUint32(20); - if (width > 0 && height > 0 && width <= 0x7fff_ffff && height <= 0x7fff_ffff && PNG_DEPTHS[bytes[25]!]?.includes(bytes[24]!) && bytes[26] === 0 && bytes[27] === 0 && bytes[28]! <= 1) { + if ( + width > 0 && + height > 0 && + width <= 0x7fff_ffff && + height <= 0x7fff_ffff && + PNG_DEPTHS[bytes[25]!]?.includes(bytes[24]!) && + bytes[26] === 0 && + bytes[27] === 0 && + bytes[28]! <= 1 + ) { return { mimeType: "image/png", width, height }; } return undefined; } - if (bytes.length >= 13 && (matches(bytes, 0, "GIF87a") || matches(bytes, 0, "GIF89a"))) { + if ( + bytes.length >= 13 && + (matches(bytes, 0, "GIF87a") || matches(bytes, 0, "GIF89a")) + ) { const width = view.getUint16(6, true); const height = view.getUint16(8, true); if (width && height) return { mimeType: "image/gif", width, height }; @@ -42,17 +83,34 @@ function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefin while (offset < bytes.length && bytes[offset] === 0xff) offset++; const marker = bytes[offset++]; // Dimensions must precede compressed scan data; never search arbitrarily inside it. - if (marker === undefined || marker === 0xda || marker === 0xd9 || marker === 0x00) return undefined; + if ( + marker === undefined || + marker === 0xda || + marker === 0xd9 || + marker === 0x00 + ) + return undefined; if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; if (offset + 2 > bytes.length) return undefined; const length = view.getUint16(offset); if (length < 2 || offset + length > bytes.length) return undefined; - if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ) { if (length < 8) return undefined; const height = view.getUint16(offset + 3); const width = view.getUint16(offset + 5); const components = bytes[offset + 7]!; - if (width && height && components > 0 && length === 8 + 3 * components) { + if ( + width && + height && + components > 0 && + length === 8 + 3 * components + ) { return { mimeType: "image/jpeg", width, height }; } return undefined; @@ -61,7 +119,12 @@ function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefin } return undefined; } - if (bytes.length >= 30 && matches(bytes, 0, "RIFF") && matches(bytes, 8, "WEBP") && view.getUint32(4, true) + 8 === fileSize) { + if ( + bytes.length >= 30 && + matches(bytes, 0, "RIFF") && + matches(bytes, 8, "WEBP") && + view.getUint32(4, true) + 8 === fileSize + ) { const chunkSize = view.getUint32(16, true); if (20 + chunkSize + (chunkSize & 1) > fileSize) return undefined; if (matches(bytes, 12, "VP8X") && chunkSize === 10) { @@ -69,17 +132,34 @@ function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefin const height = 1 + bytes[27]! + (bytes[28]! << 8) + (bytes[29]! << 16); return { mimeType: "image/webp", width, height }; } - if (matches(bytes, 12, "VP8 ") && chunkSize >= 10 && (bytes[20]! & 1) === 0 && matches(bytes, 23, "\x9d\x01\x2a")) { + if ( + matches(bytes, 12, "VP8 ") && + chunkSize >= 10 && + (bytes[20]! & 1) === 0 && + matches(bytes, 23, "\x9d\x01\x2a") + ) { const width = view.getUint16(26, true) & 0x3fff; const height = view.getUint16(28, true) & 0x3fff; if (width && height) return { mimeType: "image/webp", width, height }; } } - if (bytes.length >= 25 && matches(bytes, 0, "RIFF") && matches(bytes, 8, "WEBP") && matches(bytes, 12, "VP8L") && view.getUint32(4, true) + 8 === fileSize) { + if ( + bytes.length >= 25 && + matches(bytes, 0, "RIFF") && + matches(bytes, 8, "WEBP") && + matches(bytes, 12, "VP8L") && + view.getUint32(4, true) + 8 === fileSize + ) { const chunkSize = view.getUint32(16, true); - if (chunkSize >= 5 && 20 + chunkSize + (chunkSize & 1) <= fileSize && bytes[20] === 0x2f && (bytes[24]! >> 5) === 0) { + if ( + chunkSize >= 5 && + 20 + chunkSize + (chunkSize & 1) <= fileSize && + bytes[20] === 0x2f && + bytes[24]! >> 5 === 0 + ) { const width = 1 + bytes[21]! + ((bytes[22]! & 0x3f) << 8); - const height = 1 + (bytes[22]! >> 6) + (bytes[23]! << 2) + ((bytes[24]! & 0x0f) << 10); + const height = + 1 + (bytes[22]! >> 6) + (bytes[23]! << 2) + ((bytes[24]! & 0x0f) << 10); return { mimeType: "image/webp", width, height }; } } @@ -87,24 +167,37 @@ function imageHeader(bytes: Uint8Array, fileSize: number): ImageHeader | undefin } /** Decode only the EBML header's bounded integer fields, not its media payload. */ -function ebmlInteger(bytes: Uint8Array, offset: number, id: boolean): { value: number; next: number } | undefined { +function ebmlInteger( + bytes: Uint8Array, + offset: number, + id: boolean, +): { value: number; next: number } | undefined { const first = bytes[offset]; if (first === undefined || first === 0) return undefined; let marker = 0x80; let length = 1; - while ((first & marker) === 0) { marker >>= 1; length++; } + while ((first & marker) === 0) { + marker >>= 1; + length++; + } if (length > (id ? 4 : 8) || offset + length > bytes.length) return undefined; let value = id ? first : first & (marker - 1); for (let i = 1; i < length; i++) value = value * 256 + bytes[offset + i]!; // Unknown-sized elements are not valid inside an EBML header. - if (!Number.isSafeInteger(value) || (!id && value === 2 ** (7 * length) - 1)) return undefined; + if (!Number.isSafeInteger(value) || (!id && value === 2 ** (7 * length) - 1)) + return undefined; return { value, next: offset + length }; } function videoMime(bytes: Uint8Array, fileSize: number): string | undefined { if (bytes.length >= 16 && matches(bytes, 4, "ftyp")) { - const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0); - if (size < 16 || size > bytes.length || size > fileSize || size % 4 !== 0) return undefined; + const size = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0); + if (size < 16 || size > bytes.length || size > fileSize || size % 4 !== 0) + return undefined; if (matches(bytes, 8, "qt ")) return "video/quicktime"; for (const brand of MP4_BRANDS) { if (matches(bytes, 8, brand)) return "video/mp4"; @@ -122,9 +215,11 @@ function videoMime(bytes: Uint8Array, fileSize: number): string | undefined { const id = ebmlInteger(bytes, offset, true); if (!id || id.next > end) return undefined; const size = ebmlInteger(bytes, id.next, false); - if (!size || size.next > end || size.value > end - size.next) return undefined; + if (!size || size.next > end || size.value > end - size.next) + return undefined; if (id.value === 0x4282) { - if (webm || size.value !== 4 || !matches(bytes, size.next, "webm")) return undefined; + if (webm || size.value !== 4 || !matches(bytes, size.next, "webm")) + return undefined; webm = true; } offset = size.next + size.value; @@ -133,15 +228,34 @@ function videoMime(bytes: Uint8Array, fileSize: number): string | undefined { } /** Derive metadata from the durable Blob's bytes, not the original name or File.type. */ -export async function inspectNativeChatFileMetadata(blob: Blob, signal?: AbortSignal): Promise { - if (!Number.isInteger(blob.size) || blob.size < 0 || blob.size > U32_MAX) throw new Error("Chat file size exceeds the supported range"); - const fallback: AttachmentMetadata = { mimeType: "application/octet-stream", sizeBytes: blob.size, kind: { tag: "File" } }; +export async function inspectNativeChatFileMetadata( + blob: Blob, + signal?: AbortSignal, +): Promise { + if (!Number.isInteger(blob.size) || blob.size < 0 || blob.size > U32_MAX) + throw new Error("Chat file size exceeds the supported range"); + const fallback: AttachmentMetadata = { + mimeType: "application/octet-stream", + sizeBytes: blob.size, + kind: { tag: "File" }, + }; if (signal?.aborted) return fallback; const bytes = new Uint8Array(await blob.slice(0, HEADER_LIMIT).arrayBuffer()); if (signal?.aborted) return fallback; const image = imageHeader(bytes, blob.size); if (image) { - return { mimeType: image.mimeType, sizeBytes: blob.size, kind: { tag: "Image", value: { width: image.width, height: image.height, thumbnail: undefined } } }; + return { + mimeType: image.mimeType, + sizeBytes: blob.size, + kind: { + tag: "Image", + value: { + width: image.width, + height: image.height, + thumbnail: undefined, + }, + }, + }; } const mimeType = videoMime(bytes, blob.size); if (!mimeType || typeof document === "undefined") return fallback; @@ -183,22 +297,51 @@ export async function inspectNativeChatFileMetadata(blob: Blob, signal?: AbortSi video.onerror = abort; video.onloadedmetadata = () => { const duration = video.duration; - if (!Number.isFinite(duration) || duration < 0 || duration > U32_MAX || video.videoWidth <= 0 || video.videoHeight <= 0) { + if ( + !Number.isFinite(duration) || + duration < 0 || + duration > U32_MAX || + video.videoWidth <= 0 || + video.videoHeight <= 0 + ) { finish(fallback); } else { - finish({ mimeType, sizeBytes: blob.size, kind: { tag: "Video", value: { durationSeconds: Math.floor(duration), thumbnail: undefined } } }); + finish({ + mimeType, + sizeBytes: blob.size, + kind: { + tag: "Video", + value: { + durationSeconds: Math.floor(duration), + thumbnail: undefined, + }, + }, + }); } }; - if (signal?.aborted) { abort(); return; } - try { video.src = url; video.load(); } catch { abort(); } + if (signal?.aborted) { + abort(); + return; + } + try { + video.src = url; + video.load(); + } catch { + abort(); + } }); } const IMAGE_EXTENSIONS: Readonly> = { - "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", }; const VIDEO_EXTENSIONS: Readonly> = { - "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", + "video/mp4": "mp4", + "video/quicktime": "mov", + "video/webm": "webm", }; /** Fixed safe basename and a kind-consistent media whitelist, never a supplied path. */ @@ -206,12 +349,25 @@ export function nativeChatExportFilename(metadata: AttachmentMetadata): string { let extension = "bin"; if (metadata.kind.tag === "Image") { const { width, height } = metadata.kind.value; - if (Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 && width <= U32_MAX && height <= U32_MAX && Object.hasOwn(IMAGE_EXTENSIONS, metadata.mimeType)) { + if ( + Number.isInteger(width) && + Number.isInteger(height) && + width > 0 && + height > 0 && + width <= U32_MAX && + height <= U32_MAX && + Object.hasOwn(IMAGE_EXTENSIONS, metadata.mimeType) + ) { extension = IMAGE_EXTENSIONS[metadata.mimeType]!; } } else if (metadata.kind.tag === "Video") { const { durationSeconds } = metadata.kind.value; - if (Number.isInteger(durationSeconds) && durationSeconds >= 0 && durationSeconds <= U32_MAX && Object.hasOwn(VIDEO_EXTENSIONS, metadata.mimeType)) { + if ( + Number.isInteger(durationSeconds) && + durationSeconds >= 0 && + durationSeconds <= U32_MAX && + Object.hasOwn(VIDEO_EXTENSIONS, metadata.mimeType) + ) { extension = VIDEO_EXTENSIONS[metadata.mimeType]!; } } diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index a340a31c6..69590d697 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -493,7 +493,10 @@ describe("createWebWorkerPairingHostRuntime", () => { makeHostCallbacks({ identityBackend: { identityUsernameCandidates: async (username, peopleGenesis) => { - if (username !== "alice" || bytesToHex(peopleGenesis) !== bytesToHex(genesis)) { + if ( + username !== "alice" || + bytesToHex(peopleGenesis) !== bytesToHex(genesis) + ) { throw new Error("authenticated search unavailable"); } return [account]; @@ -508,7 +511,8 @@ describe("createWebWorkerPairingHostRuntime", () => { }, ); worker.emit({ kind: "loaded" }); - const capabilities = lastMessageOfKind(worker, "init").capabilities as OptionalCapabilities; + const capabilities = lastMessageOfKind(worker, "init") + .capabilities as OptionalCapabilities; worker.emit({ kind: "ready" }); const runtime = await runtimePromise; let requestId = 0; @@ -518,7 +522,8 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "callbackRequest", requestId: id, name, args }); await settle(); const response = worker.messages.find( - (message) => message.kind === "callbackResponse" && message.requestId === id, + (message) => + message.kind === "callbackResponse" && message.requestId === id, ); if (!response) throw new Error("missing callback response"); if (!response.ok) throw new Error(String(response.error)); @@ -531,9 +536,9 @@ describe("createWebWorkerPairingHostRuntime", () => { ) as unknown as RawCallbacks; try { - expect(await callbacks.identityUsernameCandidates!("alice", genesis)).toEqual( - new Uint8Array([4, ...account]), - ); + expect( + await callbacks.identityUsernameCandidates!("alice", genesis), + ).toEqual(new Uint8Array([4, ...account])); await expect( callbacks.identityUsernameCandidates!("unavailable", genesis), ).rejects.toThrow("authenticated search unavailable"); @@ -1135,13 +1140,22 @@ describe("createWebWorkerPairingHostRuntime", () => { const late = Promise.withResolvers(); const owned = new Set(["delivered", "undelivered"]); let calls = 0; - const metadata = { mimeType: "application/octet-stream", sizeBytes: 1, kind: { tag: "File" as const } }; + const metadata = { + mimeType: "application/octet-stream", + sizeBytes: 1, + kind: { tag: "File" as const }, + }; const runtimePromise = createWebWorkerPairingHostRuntime( asWorker(worker), makeHostCallbacks({ nativeChatFiles: { - pickChatFiles: async () => ++calls === 1 ? [{ sourceId: "delivered", metadata }] : late.promise, - releaseChatFile: async (id) => { owned.delete(id); }, + pickChatFiles: async () => + ++calls === 1 + ? [{ sourceId: "delivered", metadata }] + : late.promise, + releaseChatFile: async (id) => { + owned.delete(id); + }, }, }), { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, @@ -1150,17 +1164,34 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "ready" }); const runtime = await runtimePromise; const request = NativeChatFilePickRequest.enc({ - productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined, maxFiles: 1, + productId: "chat.dot", + peerIdentity: new Uint8Array(32), + peerUsername: undefined, + maxFiles: 1, + }); + worker.emit({ + kind: "callbackRequest", + requestId: 1, + name: "pickChatFiles", + args: [request], }); - worker.emit({ kind: "callbackRequest", requestId: 1, name: "pickChatFiles", args: [request] }); await settle(); - worker.emit({ kind: "callbackRequest", requestId: 2, name: "pickChatFiles", args: [request] }); + worker.emit({ + kind: "callbackRequest", + requestId: 2, + name: "pickChatFiles", + args: [request], + }); await settle(); runtime.dispose(); late.resolve([{ sourceId: "undelivered", metadata }]); await settle(); expect([...owned]).toEqual(["delivered"]); - expect(worker.messages.filter((message) => message.kind === "callbackResponse").map((message) => message.requestId)).toEqual([1]); + expect( + worker.messages + .filter((message) => message.kind === "callbackResponse") + .map((message) => message.requestId), + ).toEqual([1]); }); it("cancels active and late file exports after a worker fault, not completed exports", async () => { @@ -1172,9 +1203,12 @@ describe("createWebWorkerPairingHostRuntime", () => { asWorker(worker), makeHostCallbacks({ nativeChatFiles: { - beginChatFileExport: async () => ["completed", "active"][calls++] ?? late.promise, + beginChatFileExport: async () => + ["completed", "active"][calls++] ?? late.promise, finishChatFileExport: async () => {}, - cancelChatFileExport: async (id) => { cancelled.push(id); }, + cancelChatFileExport: async (id) => { + cancelled.push(id); + }, }, }), { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, @@ -1183,22 +1217,52 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "ready" }); await runtimePromise; const request = NativeChatFileExportRequest.enc({ - productId: "chat.dot", peerIdentity: new Uint8Array(32), peerUsername: undefined, - metadata: { mimeType: "application/octet-stream", sizeBytes: 0, kind: { tag: "File" } }, + productId: "chat.dot", + peerIdentity: new Uint8Array(32), + peerUsername: undefined, + metadata: { + mimeType: "application/octet-stream", + sizeBytes: 0, + kind: { tag: "File" }, + }, + }); + worker.emit({ + kind: "callbackRequest", + requestId: 1, + name: "beginChatFileExport", + args: [request], }); - worker.emit({ kind: "callbackRequest", requestId: 1, name: "beginChatFileExport", args: [request] }); await settle(); - worker.emit({ kind: "callbackRequest", requestId: 2, name: "finishChatFileExport", args: ["completed"] }); + worker.emit({ + kind: "callbackRequest", + requestId: 2, + name: "finishChatFileExport", + args: ["completed"], + }); await settle(); - worker.emit({ kind: "callbackRequest", requestId: 3, name: "beginChatFileExport", args: [request] }); + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "beginChatFileExport", + args: [request], + }); await settle(); - worker.emit({ kind: "callbackRequest", requestId: 4, name: "beginChatFileExport", args: [request] }); + worker.emit({ + kind: "callbackRequest", + requestId: 4, + name: "beginChatFileExport", + args: [request], + }); await settle(); worker.emitError("worker stopped"); late.resolve("late"); await settle(); expect(cancelled.sort()).toEqual(["active", "late"]); - expect(worker.messages.filter((message) => message.kind === "callbackResponse").map((message) => message.requestId)).toEqual([1, 2, 3]); + expect( + worker.messages + .filter((message) => message.kind === "callbackResponse") + .map((message) => message.requestId), + ).toEqual([1, 2, 3]); }); it("preserves bigint file offsets and never returns backend private error details", async () => { @@ -1208,7 +1272,8 @@ describe("createWebWorkerPairingHostRuntime", () => { makeHostCallbacks({ nativeChatFiles: { readChatFile: async (_id, offset) => { - if (offset === 0xffff_ffff_ffff_ffffn) return new Uint8Array([0xa5]); + if (offset === 0xffff_ffff_ffff_ffffn) + return new Uint8Array([0xa5]); throw new Error("private-source-and-path"); }, }, @@ -1219,20 +1284,30 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "ready" }); const runtime = await runtimePromise; worker.emit({ - kind: "callbackRequest", requestId: 1, name: "readChatFile", + kind: "callbackRequest", + requestId: 1, + name: "readChatFile", args: ["private-source-and-path", 0xffff_ffff_ffff_ffffn, 1], }); await settle(); expect(lastMessageOfKind(worker, "callbackResponse")).toEqual({ - kind: "callbackResponse", requestId: 1, ok: true, value: new Uint8Array([0xa5]), + kind: "callbackResponse", + requestId: 1, + ok: true, + value: new Uint8Array([0xa5]), }); worker.emit({ - kind: "callbackRequest", requestId: 2, name: "readChatFile", + kind: "callbackRequest", + requestId: 2, + name: "readChatFile", args: ["private-source-and-path", 0n, 1], }); await settle(); expect(lastMessageOfKind(worker, "callbackResponse")).toEqual({ - kind: "callbackResponse", requestId: 2, ok: false, error: "Native Chat file operation failed", + kind: "callbackResponse", + requestId: 2, + ok: false, + error: "Native Chat file operation failed", }); runtime.dispose(); }); @@ -1256,7 +1331,12 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "loaded" }); worker.emit({ kind: "ready" }); const runtime = await runtimePromise; - worker.emit({ kind: "hopConnectStart", connId: 1, genesisHash: "0xab", endpoint }); + worker.emit({ + kind: "hopConnectStart", + connId: 1, + genesisHash: "0xab", + endpoint, + }); await settle(); if (teardown === "dispose") runtime.dispose(); else if (teardown === "fault") worker.emitError("worker stopped"); @@ -1274,9 +1354,13 @@ describe("createWebWorkerPairingHostRuntime", () => { }); await settle(); expect(closes).toBe(1); - expect(worker.messages.filter((message) => - message.kind === "chainConnectAck" || message.kind === "chainResponse" - )).toEqual([]); + expect( + worker.messages.filter( + (message) => + message.kind === "chainConnectAck" || + message.kind === "chainResponse", + ), + ).toEqual([]); runtime.dispose(); expect(closes).toBe(1); }); @@ -1306,7 +1390,9 @@ describe("createWebWorkerPairingHostRuntime", () => { }, }), }), - close() { closes += 1; }, + close() { + closes += 1; + }, }; }, }, @@ -1316,19 +1402,29 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "loaded" }); worker.emit({ kind: "ready" }); const runtime = await runtimePromise; - worker.emit({ kind: "hopConnectStart", connId: 7, genesisHash: "0xab", endpoint }); + worker.emit({ + kind: "hopConnectStart", + connId: 7, + genesisHash: "0xab", + endpoint, + }); await settle(); expect(lastMessageOfKind(worker, "chainConnectAck")).toEqual({ - kind: "chainConnectAck", connId: 7, ok: true, + kind: "chainConnectAck", + connId: 7, + ok: true, }); worker.emit({ kind: "chainSend", connId: 7, request: '{"id":1}' }); response.resolve({ done: false, value: '{"id":1,"result":"ok"}' }); await settle(); expect(lastMessageOfKind(worker, "chainResponse")).toEqual({ - kind: "chainResponse", connId: 7, json: '{"id":1,"result":"ok"}', + kind: "chainResponse", + connId: 7, + json: '{"id":1,"result":"ok"}', }); expect(lastMessageOfKind(worker, "chainClosed")).toEqual({ - kind: "chainClosed", connId: 7, + kind: "chainClosed", + connId: 7, }); worker.emit({ kind: "chainSend", connId: 7, request: "late" }); worker.emit({ kind: "chainClose", connId: 7 }); @@ -1348,7 +1444,10 @@ describe("createWebWorkerPairingHostRuntime", () => { makeHostCallbacks({ hop: { allowedHopEndpoints: async () => [endpoint], - connectHop() { dials += 1; return opening.promise; }, + connectHop() { + dials += 1; + return opening.promise; + }, }, }), { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, @@ -1357,28 +1456,40 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "ready" }); const runtime = await runtimePromise; for (let connId = 1; connId <= MAX_JSON_RPC_CONNECTIONS; connId += 1) { - worker.emit({ kind: "hopConnectStart", connId, genesisHash: "0xab", endpoint }); + worker.emit({ + kind: "hopConnectStart", + connId, + genesisHash: "0xab", + endpoint, + }); worker.emit({ kind: "chainClose", connId }); } worker.emit({ - kind: "hopConnectStart", connId: MAX_JSON_RPC_CONNECTIONS + 1, - genesisHash: "0xab", endpoint, + kind: "hopConnectStart", + connId: MAX_JSON_RPC_CONNECTIONS + 1, + genesisHash: "0xab", + endpoint, }); await settle(); expect(dials).toBe(MAX_JSON_RPC_CONNECTIONS); expect(lastMessageOfKind(worker, "chainConnectAck")).toMatchObject({ - connId: MAX_JSON_RPC_CONNECTIONS + 1, ok: false, + connId: MAX_JSON_RPC_CONNECTIONS + 1, + ok: false, }); opening.resolve({ send() {}, async *responses() {}, - close() { closes += 1; }, + close() { + closes += 1; + }, }); await settle(); expect(closes).toBe(MAX_JSON_RPC_CONNECTIONS); worker.emit({ - kind: "hopConnectStart", connId: MAX_JSON_RPC_CONNECTIONS + 2, - genesisHash: "0xab", endpoint, + kind: "hopConnectStart", + connId: MAX_JSON_RPC_CONNECTIONS + 2, + genesisHash: "0xab", + endpoint, }); await settle(); expect(dials).toBe(MAX_JSON_RPC_CONNECTIONS + 1); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 519353671..dae760a73 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -86,7 +86,12 @@ export type MainToWorker = // frames to it. Null in production, so the host tap stays inert. debuggerUrl: string | null; } - | { kind: "createCore"; coreId: number; product: unknown; capabilities?: OptionalCapabilities } + | { + kind: "createCore"; + coreId: number; + product: unknown; + capabilities?: OptionalCapabilities; + } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } | { kind: "frame"; coreId: number; bytes: Uint8Array } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 8af72ef28..c0da7da87 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -88,7 +88,8 @@ function callbackRequest( args: readonly unknown[], coreId?: number, ): Promise { - if (connectionsDisposed) return Promise.reject(new Error("Host runtime is unavailable")); + if (connectionsDisposed) + return Promise.reject(new Error("Host runtime is unavailable")); return new Promise((resolve, reject) => { const requestId = ++nextRequestId; pendingCallbacks.set(requestId, (r) => { @@ -96,7 +97,13 @@ function callbackRequest( else reject(new Error(r.error)); }); try { - postToMain({ kind: "callbackRequest", requestId, name, args, ...(coreId === undefined ? {} : { coreId }) }); + postToMain({ + kind: "callbackRequest", + requestId, + name, + args, + ...(coreId === undefined ? {} : { coreId }), + }); } catch { pendingCallbacks.delete(requestId); reject(new Error("Host callback transport is unavailable")); @@ -121,7 +128,13 @@ function startSubscription( sendError: (error) => sendError({ reason: error }), }); try { - postToMain({ kind: "subscriptionStart", subId, name, payload, ...(coreId === undefined ? {} : { coreId }) }); + postToMain({ + kind: "subscriptionStart", + subId, + name, + payload, + ...(coreId === undefined ? {} : { coreId }), + }); } catch { subscriptionListeners.delete(subId); sendError({ reason: "Host subscription transport is unavailable" }); @@ -144,7 +157,11 @@ function chainConnect( onResponse: (json: string) => void, onClosed?: () => void, ): Promise { - return connectRpc({ kind: "chainConnectStart", genesisHash }, onResponse, onClosed); + return connectRpc( + { kind: "chainConnectStart", genesisHash }, + onResponse, + onClosed, + ); } function hopConnect( @@ -153,7 +170,11 @@ function hopConnect( onResponse: (json: string) => void, onClosed?: () => void, ): Promise { - return connectRpc({ kind: "hopConnectStart", genesisHash, endpoint }, onResponse, onClosed); + return connectRpc( + { kind: "hopConnectStart", genesisHash, endpoint }, + onResponse, + onClosed, + ); } function closeRpcConnection(connId: number, notify = true): void { @@ -167,7 +188,10 @@ function closeRpcConnection(connId: number, notify = true): void { try { onClosed?.(); } catch { - postToMain({ kind: "disposeError", error: "JSON-RPC close callback failed" }); + postToMain({ + kind: "disposeError", + error: "JSON-RPC close callback failed", + }); } } } @@ -179,11 +203,17 @@ function connectRpc( onResponse: (json: string) => void, onClosed?: () => void, ): Promise { - if (connectionsDisposed || chainCloseListeners.size >= MAX_JSON_RPC_CONNECTIONS) { - return Promise.reject(new Error("JSON-RPC connections unavailable or limit reached")); + if ( + connectionsDisposed || + chainCloseListeners.size >= MAX_JSON_RPC_CONNECTIONS + ) { + return Promise.reject( + new Error("JSON-RPC connections unavailable or limit reached"), + ); } const connId = ++nextConnId; - const { promise, resolve, reject } = Promise.withResolvers(); + const { promise, resolve, reject } = + Promise.withResolvers(); chainConnectAcks.set(connId, (ack) => { if (!ack.ok) { chainResponseListeners.delete(connId); @@ -224,7 +254,10 @@ function connectRpc( } /** Build the host-level callback object passed to the WASM runtime. */ -function buildRawCallbacks(capabilities: OptionalCapabilities, coreId?: number) { +function buildRawCallbacks( + capabilities: OptionalCapabilities, + coreId?: number, +) { return { ...createWorkerRawCallbacks( { @@ -826,7 +859,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { const core = runtime.productRuntime( msg.product, buildCoreCallbacks(msg.coreId), - msg.capabilities === undefined ? undefined : buildRawCallbacks(msg.capabilities, msg.coreId), + msg.capabilities === undefined + ? undefined + : buildRawCallbacks(msg.capabilities, msg.coreId), ); cores.set(msg.coreId, core); postToMain({ kind: "coreReady", coreId: msg.coreId }); diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 6300a146e..95ccb6fba 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -8,16 +8,16 @@ import { createClient, SubscriptionError, TRUAPI_CODEC_VERSION } from "./generat import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; import { - encodeWireMessage, - MESSAGE_TYPE_INTERRUPT, - MESSAGE_TYPE_RECEIVE, - MESSAGE_TYPE_REQUEST, - MESSAGE_TYPE_RESPONSE, - MESSAGE_TYPE_START, - MESSAGE_TYPE_STOP, - PROTOCOL_ERROR_METHOD_ID, - PROTOCOL_ERROR_TRAIT_ID, - UnsupportedMessageError, + encodeWireMessage, + MESSAGE_TYPE_INTERRUPT, + MESSAGE_TYPE_RECEIVE, + MESSAGE_TYPE_REQUEST, + MESSAGE_TYPE_RESPONSE, + MESSAGE_TYPE_START, + MESSAGE_TYPE_STOP, + PROTOCOL_ERROR_METHOD_ID, + PROTOCOL_ERROR_TRAIT_ID, + UnsupportedMessageError, } from "./transport.js"; function toHex(u: Uint8Array): string { @@ -116,17 +116,10 @@ function accountGetResponsePayload( return S.Result( T.VersionedHostAccountGetResponse, S.CallError(T.VersionedHostAccountGetError), - ).enc( - value.success - ? { success: true, value: { tag: "V1", value: value.value } } - : value, - ); + ).enc(value.success ? { success: true, value: { tag: "V1", value: value.value } } : value); } -function rendererStart( - requestId: string, - request: T.ProductRendererRenderRequest, -): Uint8Array { +function rendererStart(requestId: string, request: T.ProductRendererRenderRequest): Uint8Array { return wireFrame( requestId, W.RENDERER_RENDER, @@ -161,9 +154,7 @@ function rendererInterrupt(requestId: string): Uint8Array { requestId, W.RENDERER_RENDER, MESSAGE_TYPE_INTERRUPT, - new Uint8Array([ - 1, 4, 44, 117, 110, 97, 118, 97, 105, 108, 97, 98, 108, 101, - ]), + new Uint8Array([1, 4, 44, 117, 110, 97, 118, 97, 105, 108, 97, 98, 108, 101]), ); } @@ -209,11 +200,7 @@ function protocolError(requestId: string, payload: Uint8Array): Uint8Array { ); } -function unsupportedMessage( - requestId: string, - traitId: number, - methodId: number, -): Uint8Array { +function unsupportedMessage(requestId: string, traitId: number, methodId: number): Uint8Array { // [0] version index, [0] variant index, then the unsupported pair. return protocolError(requestId, new Uint8Array([0, 0, traitId, methodId])); } @@ -253,7 +240,6 @@ describe("generated client transport", () => { expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); }); - it("resolves a request from its versioned response envelope", async () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); @@ -530,13 +516,7 @@ describe("generated client transport", () => { ); expect(fixture.sent.map(toHex)).toEqual([ - toHex( - unsupportedMessage( - "h:known", - W.RENDERER_RENDER.trait, - W.RENDERER_RENDER.method, - ), - ), + toHex(unsupportedMessage("h:known", W.RENDERER_RENDER.trait, W.RENDERER_RENDER.method)), ]); }); @@ -598,7 +578,6 @@ describe("generated client transport", () => { expect(subscriptionFixture.sent).toHaveLength(2); }); - it("auto-responds to an inbound handshake with the versioned-result shape", () => { const fixture = providerFixture(); createTransport(fixture.provider); @@ -662,9 +641,9 @@ describe("generated client transport", () => { it("refuses a non-positive request deadline", () => { const fixture = providerFixture(); - expect(() => - createTransport(fixture.provider, { requestTimeoutMs: 0 }), - ).toThrow("requestTimeoutMs must be a positive finite number"); + expect(() => createTransport(fixture.provider, { requestTimeoutMs: 0 })).toThrow( + "requestTimeoutMs must be a positive finite number", + ); }); it("rejects the handshake call when the host never answers", async () => { @@ -679,9 +658,7 @@ describe("generated client transport", () => { const client = createClient(createTransport(fixture.provider)); const outcome = Promise.resolve(client.system.handshake()); jest.advanceTimersByTime(10_001); - await expect(outcome).rejects.toThrow( - "TrUAPI handshake timed out after 10000ms", - ); + await expect(outcome).rejects.toThrow("TrUAPI handshake timed out after 10000ms"); } finally { jest.useRealTimers(); } @@ -945,7 +922,11 @@ describe("generated client transport", () => { rendererStart(`h:${index}`, { context: { tag: "ChatMessage", - value: { roomId: "room", messageId: `message-${index}`, messageType: "vote" }, + value: { + roomId: "room", + messageId: `message-${index}`, + messageType: "vote", + }, }, payload: "0x", }), @@ -1119,9 +1100,7 @@ describe("generated client transport", () => { sub.subscriptionId, W.PAYMENT_BALANCE_SUBSCRIBE, MESSAGE_TYPE_INTERRUPT, - S.Option( - S.CallError(T.VersionedHostPaymentBalanceSubscribeError), - ).enc(callError), + S.Option(S.CallError(T.VersionedHostPaymentBalanceSubscribeError)).enc(callError), ); fixture.receive(frame); @@ -1151,9 +1130,7 @@ describe("generated client transport", () => { sub.subscriptionId, W.COIN_PAYMENT_REBALANCE_PURSE, MESSAGE_TYPE_INTERRUPT, - S.Option( - S.CallError(T.VersionedHostCoinPaymentRebalancePurseError), - ).enc(callError), + S.Option(S.CallError(T.VersionedHostCoinPaymentRebalancePurseError)).enc(callError), ); fixture.receive(frame); diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 30b8ce9a7..48468c703 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -1,35 +1,30 @@ # truapi-host-cli -Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. -They replace the external signing-bot service: two CLI processes take the two -host-spec §B roles and pair over the **real People-chain statement store** (the -same node an iOS/web client uses), so tests run against a real signer with no -Novasama-operated dependency. +Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. They replace the external signing-bot +service: two CLI processes take the two host-spec §B roles and pair over the **real People-chain statement store** (the +same node an iOS/web client uses), so tests run against a real signer with no Novasama-operated dependency. -See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering -contract. +See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering contract. -Either host can be driven by a **product script** you write: a JS/TS file that -receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) -and calls it like any product would. With `--script`, the CLI runs the script -and exits with its status. Without `--script`, both roles open a full-screen -terminal UI when stdin and stdout are TTYs. +Either host can be driven by a **product script** you write: a JS/TS file that receives a global `truapi` (the +`@parity/truapi` client, scoped to a product id) and calls it like any product would. With `--script`, the CLI runs the +script and exits with its status. Without `--script`, both roles open a full-screen terminal UI when stdin and stdout +are TTYs. One binary, `truapi-host`: -| Command | Role | -| --- | --- | -| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | -| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, decodes copied pairing QR images or accepts deeplinks, registers statement allowance on-chain, signs. | -| `identity-check` | Probe the root and the network's `uid.` identity account for a registered username (read from the dotNS contracts on Asset Hub). | -| `register-name` | Register a full-person username via `DotnsGateway.register_name` on Asset Hub, linked to a lite username or standalone with a chat key. | -| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. On a full period it prints each occupied slot's age and which one would be replaced. | -| `pgas-check` | Diagnose (or `--submit`) an Asset Hub PGAS allowance claim: ring membership on People, whether Asset Hub has imported that ring revision, the day's first unclaimed slot, and the `Pgas.claim_pgas` extrinsic. | +| Command | Role | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, decodes copied pairing QR images or accepts deeplinks, registers statement allowance on-chain, signs. | +| `identity-check` | Probe the root and the network's `uid.` identity account for a registered username (read from the dotNS contracts on Asset Hub). | +| `register-name` | Register a full-person username via `DotnsGateway.register_name` on Asset Hub, linked to a lite username or standalone with a chat key. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. On a full period it prints each occupied slot's age and which one would be replaced. | +| `pgas-check` | Diagnose (or `--submit`) an Asset Hub PGAS allowance claim: ring membership on People, whether Asset Hub has imported that ring revision, the day's first unclaimed slot, and the `Pgas.claim_pgas` extrinsic. | -The repository's `make e2e-dotli` target builds this binary and runs the -dotli/playground Diagnosis suite with a non-interactive signing-host responder. -It verifies the initial pairing, remote signing, host sign-out, and -same-account reconnect without the external signer-bot service. +The repository's `make e2e-dotli` target builds this binary and runs the dotli/playground Diagnosis suite with a +non-interactive signing-host responder. It verifies the initial pairing, remote signing, host sign-out, and same-account +reconnect without the external signer-bot service. ## Install @@ -38,36 +33,30 @@ curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scri truapi-host signing-host ``` -Prebuilt binaries exist for `aarch64-apple-darwin`, -`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`. The Linux -binaries are statically linked, so they run on any distribution. The installer -puts each version in `$XDG_DATA_HOME/truapi-host/versions//` and -symlinks `~/.local/bin/truapi-host` through a `current` link, so an update only -moves that one link. +Prebuilt binaries exist for `aarch64-apple-darwin`, `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`. The +Linux binaries are statically linked, so they run on any distribution. The installer puts each version in +`$XDG_DATA_HOME/truapi-host/versions//` and symlinks `~/.local/bin/truapi-host` through a `current` link, so an +update only moves that one link. -| Variable | Effect | -| --- | --- | -| `TRUAPI_HOST_VERSION` | Install this version instead of the current stable one. | -| `TRUAPI_HOST_INSTALL_DIR` | Version store, default `$XDG_DATA_HOME/truapi-host`. | -| `TRUAPI_HOST_BIN_DIR` | Directory the `PATH` symlink goes in, default `~/.local/bin`. | +| Variable | Effect | +| ------------------------- | ------------------------------------------------------------- | +| `TRUAPI_HOST_VERSION` | Install this version instead of the current stable one. | +| `TRUAPI_HOST_INSTALL_DIR` | Version store, default `$XDG_DATA_HOME/truapi-host`. | +| `TRUAPI_HOST_BIN_DIR` | Directory the `PATH` symlink goes in, default `~/.local/bin`. | -Product scripts (`--script`, `/script`) work from an installed binary: the -archive ships a `runner.js` with the `@parity/truapi` client bundled in. You -still need `bun` on `PATH`, since it executes the runner and your script. +Product scripts (`--script`, `/script`) work from an installed binary: the archive ships a `runner.js` with the +`@parity/truapi` client bundled in. You still need `bun` on `PATH`, since it executes the runner and your script. -Product frames use a private, per-process WebSocket-over-Unix-domain-socket by -default, so starting either host does not reserve a TCP port. Pass -`--frame-listen 127.0.0.1:0` to expose an ordinary loopback WebSocket instead; -this is required for browser clients, which cannot open filesystem sockets. +Product frames use a private, per-process WebSocket-over-Unix-domain-socket by default, so starting either host does not +reserve a TCP port. Pass `--frame-listen 127.0.0.1:0` to expose an ordinary loopback WebSocket instead; this is required +for browser clients, which cannot open filesystem sockets. ### Staying current -A managed install checks for a new release at most once every four hours, -alongside whatever command you ran rather than delaying it, and installs it into -the version store. A command that finishes first waits for the download, so even -a one-shot run lands the update; it prints a line while downloading. The running -process is never replaced underneath itself: the new version takes effect the -next time you start `truapi-host`, and the CLI says so when one is waiting. +A managed install checks for a new release at most once every four hours, alongside whatever command you ran rather than +delaying it, and installs it into the version store. A command that finishes first waits for the download, so even a +one-shot run lands the update; it prints a line while downloading. The running process is never replaced underneath +itself: the new version takes effect the next time you start `truapi-host`, and the CLI says so when one is waiting. Every archive is checked against its published SHA-256 before it is unpacked. ```bash @@ -76,44 +65,37 @@ truapi-host --version # what is running TRUAPI_HOST_NO_UPDATE=1 ... # never check ``` -Binaries that the installer did not put in place — a `cargo install` copy, a -source build, a distro package — are detected and never modified. A local build -says so on every run and prints the install command, since it otherwise looks +Binaries that the installer did not put in place — a `cargo install` copy, a source build, a distro package — are +detected and never modified. A local build says so on every run and prints the install command, since it otherwise looks identical to a managed install that is quietly up to date. -The two install routes shadow each other depending on `PATH` order, so each one -clears the other: installing removes a `cargo install` copy, and -`make headless install` removes a prebuilt install first. To remove a prebuilt -install without replacing it: +The two install routes shadow each other depending on `PATH` order, so each one clears the other: installing removes a +`cargo install` copy, and `make headless install` removes a prebuilt install first. To remove a prebuilt install without +replacing it: ```bash curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scripts/truapi-host-installer.sh | bash -s -- --uninstall ``` -`make e2e-cli-update` exercises the whole chain locally: it packages the binary, -serves a fake release over loopback, installs it with the real installer, and -updates it. Nothing contacts GitHub. +`make e2e-cli-update` exercises the whole chain locally: it packages the binary, serves a fake release over loopback, +installs it with the real installer, and updates it. Nothing contacts GitHub. ### State directory -Reserved identities derive under `uid.paseo` / `peopl.paseo` on -`paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on `previewnet`. -All managed CLI state lives under `/v2`, including accounts, -sessions, pairings, core and product storage, managed scripts, and log -preferences. The CLI appends `v2` to both the default base path and a path set -through `--base-path` or `TRUAPI_HOST_BASE_PATH`. For example, -`--base-path ./truapi-host-paseo` uses `./truapi-host-paseo/v2`. +Reserved identities derive under `uid.paseo` / `peopl.paseo` on `paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on +`previewnet`. All managed CLI state lives under `/v2`, including accounts, sessions, pairings, core and +product storage, managed scripts, and log preferences. The CLI appends `v2` to both the default base path and a path set +through `--base-path` or `TRUAPI_HOST_BASE_PATH`. For example, `--base-path ./truapi-host-paseo` uses +`./truapi-host-paseo/v2`. -The CLI leaves previous state outside `v2` untouched and unused, and starts -normal onboarding automatically. There is no state migration. Pair devices -again; sign out first on any paired host that still uses an old identity. -Existing `.dot` personhood membership does not transfer to the new keys. +The CLI leaves previous state outside `v2` untouched and unused, and starts normal onboarding automatically. There is no +state migration. Pair devices again; sign out first on any paired host that still uses an old identity. Existing `.dot` +personhood membership does not transfer to the new keys. ### Building from source -A source build resolves the product-script runner from the checkout, so it also -needs the generated `@parity/truapi` sources. (An installed release ships its -own bundled runner and does not.) To build and install the CLI yourself: +A source build resolves the product-script runner from the checkout, so it also needs the generated `@parity/truapi` +sources. (An installed release ships its own bundled runner and does not.) To build and install the CLI yourself: ```bash make headless install # build dependencies and install truapi-host once @@ -122,56 +104,48 @@ truapi-host signing-host ### Raw proof contexts (development only) -A product can bind a ring-VRF proof to 32 bytes of its choosing instead of a -product-namespaced context by calling `development_createAccountProof` from -`@parity/truapi`; the signing host honours it as is. Yet to be removed before a +A product can bind a ring-VRF proof to 32 bytes of its choosing instead of a product-namespaced context by calling +`development_createAccountProof` from `@parity/truapi`; the signing host honours it as is. Yet to be removed before a production release. ### Browser products -`truapi-host dev` is one command for "run this product as if it were inside a -host". It starts a signing host on loopback, waits for the signer, then runs the -wrapped development command with the host already live: +`truapi-host dev` is one command for "run this product as if it were inside a host". It starts a signing host on +loopback, waits for the signer, then runs the wrapped development command with the host already live: ```bash truapi-host dev -- yarn dev ``` -The product reaches it through a development-only tag, which the host serves -itself: +The product reaches it through a development-only tag, which the host serves itself: ```jsx -{process.env.NODE_ENV === "development" && ( -