diff --git a/.changeset/chat-product-authority.md b/.changeset/chat-product-authority.md
new file mode 100644
index 000000000..63b2c62d1
--- /dev/null
+++ b/.changeset/chat-product-authority.md
@@ -0,0 +1,89 @@
+---
+"@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.
+
+Use the unified runtime's native UniFFI gates and shared clock, dotNS discovery and runtime view-response decoding
+for Chat and Coinage without changing product authorization or payment review policy.
+
+Keep renewal-ledger mutation native-only and use the chain metadata's extension version for Coinage extrinsics.
+Trusted products still honor explicit network-permission denials.
+
+Align Android's durable core store with suspend callbacks while retaining serialized, committed writes.
+Carry the existing scoped AGPL license exception onto the unified runtime crate.
+
+Support keyless Statement Store allowances for a selected product account in the native signing host.
+
+Replace raw guest Chat crypto with a narrow authenticated Host boundary on account method 12; retire method 11,
+including over SSO. Products own ordinary Chat, subscriptions, transport and ACKs. Keep identity/device keys and
+outgoing payment secrets private; require trusted per-payment review for main-purse debits. Incoming bearer keys may
+enter encrypted product recovery storage and are imported through generic `payment.top_up(Coins)`. Retain private nested
+HOP recovery and resumable file/image/video preparation with trusted selection/export. 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. Inject native wallet custody at runtime
+construction: reference iOS supplies its existing Coinage service adapter; browser/CLI use Rust when none is registered.
+
+Read origin-specific free Coinage unload-token limits from the runtime view at the finalized planning snapshot, rather
+than a removed metadata constant. Preserve committed payment ciphertext and native request IDs when renewing statement
+expiry. Statement submission, backpressure and ordinary retry queues belong to the product.
+
+Accept validated native push-token metadata without discarding the surrounding iOS acceptance batch. Authenticate native
+identity/device ACK direction and reject own-signer ciphertext reflections before opening private payloads.
+
+Use request/response V2 and encrypted SSO V3; reject the former actor request rather than returning fake compatibility
+responses. Provide bounded replayable HOP and public-state pages with original native IDs. Transfer legacy ordinary
+history, pending ciphertext and caller/native ID mappings only after explicit durable migration acknowledgment.
+
+Import incoming keys through the selected owner's durable custody: the encrypted Rust main-purse WAL or native
+IncomingPaymentService. Recover owner-bound accepted imports after unlock independently of Chat grants or a running
+guest. Expose trusted denomination metadata without opening a wallet allocator: native memo totals and top-up minimums
+are raw u128 chain units, while Chat cards use checked native cents. Preserve exact import amounts on retries and do not
+report ambiguous or underfunded claims as fully cleared.
+
+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. Keep the native
+Rust-purse storage guard: the adapter does not permit competing allocators or automatic fallback. This specification
+link does not assert RFC approval, publication, native iOS compilation, or cross-platform qualification.
+
+Mark native Chat payments as transitional pending RFC 0017 receivables, encrypted cheques, and deposits using the main
+purse. Preserve current native-peer payments and pending custody without claiming RFC 0017 support or introducing a
+separate Chat allocator. The Host no longer runs ordinary Chat receivers when the product is closed.
+
+Generate current request/success envelopes with compatible older domain-error envelopes instead of silently omitting the
+method. Preserve wire discriminants after retiring a version prefix, and exercise the generated client against V2 Chat
+responses and V1 errors.
+
+Add an optional Host-private native-wallet dependency, fixed at runtime construction. Absence uses Rust by default;
+registered native failures never switch owners. Remove the explicit wallet-selection callback and enum. Route reference
+iOS outgoing payments, incoming imports and status through the coordinator's existing Coinage service. Retain native
+outgoing custody atomically with native transaction registration before exposing a memo to trusted Host crypto, then
+track ciphertext acceptance, peer delivery and finality separately. Preserve per-payment and privacy review,
+source/idempotency bindings, activation fencing and authoritative native wallet balance. Native callback infrastructure
+errors are sanitized; product callback overrides cannot replace the runtime-wide wallet owner.
+
+Bind native incoming receipts to wallet root, chain and asset instance in the additive Core Data schema upgrade.
+Preserve unowned pre-upgrade incoming rows and source secrets without automatically claiming them into the current
+wallet. Pending imports without provable ownership require explicit ownership recovery before resumption.
+
+Add a host-private native Chat contacts snapshot for signing hosts, with authenticated readiness, authorization,
+wallet/network isolation, encrypted-state restoration, identity deduplication and conservative verified names. Persist
+the native product index when actors open; historical unindexed products need one open on the upgraded host. Fence reads
+against actor commits and session changes, invalidate contact handles on trusted mutations, and reject late worker
+responses. Preserve provider-scoped Contacts UI callbacks without replacing the shared owner directory. Pairing hosts
+remain unsupported; no product wire or SSO directory API is added.
diff --git a/.changeset/olive-schools-shave.md b/.changeset/olive-schools-shave.md
index aa7fda776..bcccfbef5 100644
--- a/.changeset/olive-schools-shave.md
+++ b/.changeset/olive-schools-shave.md
@@ -10,6 +10,10 @@ the statement-proof and preimage paths ask for without requesting an
allocation. A changed permission answer reaches the core, and product storage
is readable under the name `@parity/host-api-test-sdk` gives it.
+`ProductStatementStoreAllowance` withholds product-account statement
+allowances at every derivation index without withholding the separate
+`StatementStoreAllowance` resource.
+
Surface a migrating suite has to match:
- `PermissionLogEntry` carries `decision` and `timestamp` as required fields,
diff --git a/.changeset/pvm-host-runtime-authority.md b/.changeset/pvm-host-runtime-authority.md
index 9c2c4ead3..9a86eac72 100644
--- a/.changeset/pvm-host-runtime-authority.md
+++ b/.changeset/pvm-host-runtime-authority.md
@@ -14,3 +14,10 @@ requests its shared or static library artifacts.
Build each iOS XCFramework slice with its own `cargo rustc` invocation.
Explicit static-library output cannot be combined with multiple target triples
in one invocation.
+
+Qualify callback contracts through executable codec and WASM checks rather than
+full-source declaration snapshots, while retaining deterministic code generation.
+
+Preserve incoming-payment ownership and native Coinage ledger records when
+migrating either the historical Chat store or current main's iOS store to the
+combined model. Retain both historical model variants for migration detection.
diff --git a/.github/workflows/ios-pr.yml b/.github/workflows/ios-pr.yml
index 86bf414a1..a0f9930f3 100644
--- a/.github/workflows/ios-pr.yml
+++ b/.github/workflows/ios-pr.yml
@@ -101,8 +101,8 @@ env:
# DEBUG MODE: Set to 'true' to enable enhanced debugging for build failures
# What it does:
# - Enables verbose xcodebuild logging
- # - Collects and uploads build artifacts (logs, test results)
- # - Continues build even on failures to gather maximum information
+ # - Collects and uploads build artifacts
+ # Test results and diagnostics are uploaded regardless of this setting.
DEBUG_CI: false
jobs:
@@ -517,13 +517,27 @@ jobs:
bundle exec fastlane run_unit_tests
fi
- # Upload test artifacts for debugging - only when DEBUG_CI is enabled
+ - name: Export test diagnostics
+ if: always()
+ working-directory: hosts/ios
+ run: |
+ set -euo pipefail
+ result="fastlane/test_output/polkadot-app.xcresult"
+ if [[ -d "$result" ]]; then
+ xcrun xcresulttool get test-results summary --path "$result"
+ xcrun xcresulttool get test-results tests --path "$result"
+ xcrun xcresulttool export diagnostics --path "$result" \
+ --output-path fastlane/test_output/diagnostics
+ fi
+
- name: Upload test artifacts
- if: env.DEBUG_CI == 'true' && (failure() || success())
+ if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: test-artifacts-${{ github.run_number }}
+ name: test-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
path: |
hosts/ios/fastlane/test_output/
hosts/ios/fastlane/build_logs/
+ ~/Library/Logs/DiagnosticReports/*.ips
+ ~/Library/Logs/DiagnosticReports/*.crash
retention-days: 3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4c52d4ce..b9f460f77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,11 +14,19 @@ 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
+- migrate the generic runtime and Rust client to SDK 0.16's scoped wire codec 3;
+ the handshake requires an exact codec match, so product guests built against
+ an earlier codec must be rebuilt
### Added
+- Add shared native and browser Chat main-purse payment authority, explicit
+ payment review and durable receipt state; keep Chat cryptography inside the
+ Host rather than product or platform adapters (#709).
+
+- Add `account.deviceChat` for host-private Chat v2 identity binding and
+ identity-route sealing/opening through local or paired account authorities,
+ guarded by a dedicated, product-scoped Chat-authority permission.
- report local-wallet registration stages and retryable chain-read errors through
a request-scoped browser callback without resubmitting accepted claims
- expose local signing-wallet username registration and chain-verified identity
@@ -31,6 +39,15 @@ 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)
+- 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)
- report the immutable PolkaVM runtime revision actually pinned by the optional
diff --git a/Cargo.lock b/Cargo.lock
index a51e3d57d..ab5e69aa2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -764,13 +764,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]]
@@ -1394,6 +1421,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"
@@ -1700,7 +1742,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]]
@@ -2084,6 +2126,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -2401,6 +2444,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"
@@ -3676,6 +3728,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"
@@ -3689,6 +3752,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]]
@@ -4053,7 +4118,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4246,6 +4311,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"
@@ -4409,7 +4486,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4505,6 +4582,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"
@@ -4718,6 +4804,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"
@@ -5532,6 +5659,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"
@@ -5634,7 +5788,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6070,6 +6224,8 @@ dependencies = [
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
+ "truapi-chat-v2",
+ "truapi-coinage",
"truapi-macros",
"truapi-provider",
"truapi-verifiable",
@@ -6086,6 +6242,21 @@ dependencies = [
"zeroize",
]
+[[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"
@@ -6108,6 +6279,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.23.0"
@@ -6124,6 +6320,7 @@ dependencies = [
"fs2",
"futures",
"futures-util",
+ "getrandom 0.2.17",
"hex",
"image",
"parity-scale-codec",
@@ -7009,7 +7206,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 d0d77b8cb..08648888d 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,23 @@
# 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://github.com/paritytech/trinity-user-agents/actions/workflows/ci.yml)
[](https://paritytech.github.io/trinity-user-agents)
[](https://truapi-playground.paseo.li/)
-
## Documentation
- [TrUAPI reference](https://docs.polkadot.com/reference/apps/protocol/truapi/)
- [Rust API reference](https://paritytech.github.io/trinity-user-agents/)
+- [Draft: Host-owned native Chat and main-purse payments](docs/rfcs/native-chat-main-purse.md)
@@ -22,19 +25,29 @@ TrUAPI (Triangle User-Agent Programming Interface) is the API surface that hosts
Browse the published Rust API docs at [paritytech.github.io/trinity-user-agents](https://paritytech.github.io/trinity-user-agents).
-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/trinity-user-agents/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. `/script` opens a persistent TypeScript project with the Product SDK quickstart, pinned published dependencies, and editor types. Use `/script --run` to rerun it or `/script --edit` to edit without running. Projects survive session cleanup. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup and existing project scripts. Release checks install and typecheck the default SDK template against the public registry.
+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. `/script` opens a persistent TypeScript project with the Product SDK quickstart, pinned published
+dependencies, and editor types. Use `/script --run` to rerun it or `/script --edit` to edit without running. Projects
+survive session cleanup. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup and existing
+project scripts. Release checks install and typecheck the default SDK template against the public registry.
`truapi-host signing-host --session ` opens an interactive session and
restores or creates its signer. `/session ` switches to the saved account
@@ -65,7 +78,9 @@ If finality lags beyond that window, submission fails before broadcast rather
than silently signing against an expired checkpoint. Existing submission
deadlines and bounded rejection retries are unchanged.
-Product scripts and `truapi-host dev` use the same web API permission checks from `js/container`. Dev loads the container through a blocking script tag in your existing browser. Scripts run in Bun and retain filesystem, environment and process access.
+Product scripts and `truapi-host dev` use the same web API permission checks from `js/container`. Dev loads the
+container through a blocking script tag in your existing browser. Scripts run in Bun and retain filesystem, environment
+and process access.
To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with rustfmt, Node.js 22 or newer, and
Bun installed. The target installs missing workspace build tools and regenerates the Rust and TypeScript sources before
@@ -76,18 +91,16 @@ source checkout.
## 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);
@@ -97,14 +110,83 @@ 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.
-The [permission model](docs/rfcs/0002-permission-model.md) separates outbound domain access from `OpenUrl` external navigation and requires `Notifications` for push delivery. Hosts preserve the user's `AllowOnce`, `AllowAlways`, or `Deny` choice; Rust owns one-use grants for Rust-backed executions.
-Android permission prompts belong to one request and close when it finishes or is cancelled,
-including cancellation while the app is backgrounded.
+`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.
+
+Signing hosts can use the host-private `getNativeChatContacts()` directory for trusted Contacts UI. It restores
+authenticated, ready peers from authorized native Chat products, scopes them to the active wallet and People chain,
+and never exposes a product or SSO directory method. Actors opened on this version are durably indexed; historical
+unindexed Chat products must be opened once before their peers can appear. Pairing hosts do not supply this directory.
+
+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.
+
+The [permission model](docs/rfcs/0002-permission-model.md) separates outbound domain access from `OpenUrl` external
+navigation and requires `Notifications` for push delivery. Hosts preserve the user's `AllowOnce`, `AllowAlways`, or
+`Deny` choice; Rust owns one-use grants for Rust-backed executions. Android permission prompts belong to one request and
+close when it finishes or is cancelled, including cancellation while the app is backgrounded.
The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`,
on every supported network) only for device permissions and legacy-account signing.
@@ -163,28 +245,26 @@ composition crate; the base `truapi` 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` |
+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.
+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.
+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
UniFFI core) lives under [`ios/truapi-host/`](ios/truapi-host), with its SPM
@@ -220,14 +300,13 @@ 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.
`createWorkerHostRuntime` shares the native core while `createProvider(product, callbacks)` binds platform callbacks to
one product execution. The host UI disposes that execution's pending consent when its provider closes; wallet
@@ -252,21 +331,18 @@ 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.
+- [`@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.
### Wire debugger
@@ -292,12 +368,23 @@ so `make dev` leaves the board empty with no error.
## 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
@@ -311,8 +398,8 @@ make check # full suite: build, fmt, clippy, test, TS tests, playground build
make wasm # rebuild truapi 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.
CLI transcript tests share process-wide UI output. Match captured events by
request identity rather than queue position: other tests may emit unrelated
@@ -323,15 +410,12 @@ 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
@@ -343,32 +427,27 @@ 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 `".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..11ce8fa49
--- /dev/null
+++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIIdentityCandidatesTests.swift
@@ -0,0 +1,54 @@
+import Foundation
+import Testing
+import NovaCrypto
+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/TrUAPINativeCoinageTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPINativeCoinageTests.swift
new file mode 100644
index 000000000..216498ad8
--- /dev/null
+++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPINativeCoinageTests.swift
@@ -0,0 +1,440 @@
+import BigInt
+import Coinage
+import Foundation
+import NovaCrypto
+import Testing
+import TrUAPIHost
+@testable import polkadot_app
+
+struct TrUAPINativeCoinageTests {
+ private let scope = NativeCoinageScope(rootPublicKey: Data(repeating: 1, count: 32),
+ genesisHash: Data(repeating: 2, count: 32), coinageInstanceId: 7)
+
+ @Test func restartBeforeTransportCommitReplaysRetainedMemoWithoutAnotherDebit() async throws {
+ let harness = try NativeWalletHarness()
+ let journal = NativeRecordMemory()
+ let presenter = NativeReviewHarness()
+ let first = adapter(harness, journal, presenter)
+ let intent = intent()
+ let response = try await first.nativeCoinage(request: request(.preparePayment(intent: intent)))
+ guard case let .prepared(payment, memo) = response else { Issue.record("Expected prepared custody"); return }
+ #expect(payment.state == .preparing)
+ #expect(memo != nil)
+ // Crash after native custody, before Host ciphertext acceptance/CommitHandoff. New adapter, same stores.
+ let restarted = adapter(harness, journal, presenter)
+ let replay = try await restarted.nativeCoinage(request: request(.preparePayment(intent: intent)))
+ guard case let .prepared(replayed, replayMemo) = replay else { Issue.record("Expected replay"); return }
+ #expect(replayed == payment)
+ #expect(replayMemo == memo)
+ #expect(await harness.debits == 1)
+ #expect(await presenter.reviews.count == 1)
+ let beforeAccept = try await restarted.nativeCoinage(request: request(.pendingHandoffs(
+ productId: intent.productId, acceptedOperations: []
+ )))
+ #expect(beforeAccept == .payments(payments: []))
+ // Host's durable acceptance ledger repairs an ambiguous CommitHandoff without spending again.
+ let repaired = try await restarted.nativeCoinage(request: request(.pendingHandoffs(
+ productId: intent.productId, acceptedOperations: [intent.operationId]
+ )))
+ guard case let .payments(cards) = repaired else { Issue.record("Expected pending handoff"); return }
+ #expect(cards.map(\.state) == [.delivering])
+ _ = try await restarted.nativeCoinage(request: request(.noteDelivery(productId: intent.productId, operationId: intent.operationId)))
+ let delivered = try await restarted.nativeCoinage(request: request(.views(productId: intent.productId)))
+ guard case let .payments(deliveredCards) = delivered else { Issue.record("Expected public cards"); return }
+ #expect(deliveredCards.map(\.state) == [.delivered]) // A peer ACK is not chain finality.
+ let deliveredReplay = try await restarted.nativeCoinage(request: request(.preparePayment(intent: intent)))
+ guard case let .prepared(deliveredCard, deliveredMemo) = deliveredReplay else {
+ Issue.record("Expected delivered public replay"); return
+ }
+ #expect(deliveredCard.state == .delivered)
+ #expect(deliveredMemo == nil)
+ #expect(try await restarted.nativeCoinage(request: request(.readHandoff(
+ productId: intent.productId, operationId: intent.operationId
+ ))) == .failed(reason: .operationNotFound))
+ #expect(await harness.debits == 1)
+ }
+
+ @Test func peerAcknowledgmentRepairsLostCommitBeforePendingHandoffReplay() async throws {
+ let harness = try NativeWalletHarness()
+ let journal = NativeRecordMemory()
+ let original = adapter(harness, journal, NativeReviewHarness())
+ let intent = intent()
+ _ = try await original.nativeCoinage(request: request(.preparePayment(intent: intent)))
+ let restarted = adapter(harness, journal, NativeReviewHarness())
+ // Host replays authenticated acknowledgments before its pending-handoff reconciliation.
+ #expect(try await restarted.nativeCoinage(request: request(.noteDelivery(
+ productId: intent.productId, operationId: intent.operationId
+ ))) == .done)
+ #expect(try await restarted.nativeCoinage(request: request(.pendingHandoffs(
+ productId: intent.productId, acceptedOperations: [intent.operationId]
+ ))) == .payments(payments: []))
+ let replay = try await restarted.nativeCoinage(request: request(.preparePayment(intent: intent)))
+ guard case let .prepared(card, memo) = replay else {
+ Issue.record("Expected delivered operation replay"); return
+ }
+ #expect(card.state == .delivered)
+ #expect(memo == nil)
+ #expect(await harness.debits == 1)
+ }
+
+ @Test func mutatedOperationAndMutatedRequestIdentityCannotSpendAgain() async throws {
+ let harness = try NativeWalletHarness()
+ let service = adapter(harness, NativeRecordMemory(), NativeReviewHarness())
+ let original = intent()
+ _ = try await service.nativeCoinage(request: request(.preparePayment(intent: original)))
+ var changed = original
+ changed.amountCents = 2
+ #expect(try await service.nativeCoinage(request: request(.preparePayment(intent: changed))) == .failed(reason: .operationConflict))
+ changed = original
+ changed.productId = "other.product"
+ #expect(try await service.nativeCoinage(request: request(.preparePayment(intent: changed))) == .failed(reason: .operationConflict))
+ changed = original
+ changed.operationId = Data(repeating: 9, count: 32)
+ #expect(try await service.nativeCoinage(request: request(.preparePayment(intent: changed))) == .failed(reason: .operationConflict))
+ #expect(await harness.debits == 1)
+ }
+
+ @Test func denialIsDurableAndPrivacyConsentIsExplicit() async throws {
+ let harness = try NativeWalletHarness(privacy: true)
+ let journal = NativeRecordMemory()
+ let presenter = NativeReviewHarness(approved: false)
+ let first = adapter(harness, journal, presenter)
+ #expect(try await first.nativeCoinage(request: request(.preparePayment(intent: intent()))) == .failed(reason: .userRejected))
+ let restarted = adapter(harness, journal, NativeReviewHarness(approved: true))
+ #expect(try await restarted.nativeCoinage(request: request(.preparePayment(intent: intent()))) == .failed(reason: .userRejected))
+ #expect(await harness.debits == 0)
+ let reviews = await presenter.reviews
+ #expect(reviews.count == 1)
+ #expect(reviews.first?.1 == true)
+ #expect(reviews.first?.0.maxDebitCents == 1)
+ #expect(reviews.first?.0.recipientIdentity == intent().peerIdentity)
+ }
+
+ @Test func logoutDuringReviewCannotDebitAndUnavailableDoesNotInspectWallet() async throws {
+ let harness = try NativeWalletHarness()
+ let presenter = NativeReviewHarness(suspended: true)
+ let service = adapter(harness, NativeRecordMemory(), presenter)
+ let task = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent()))) }
+ await presenter.waitForReview()
+ service.setAvailable(false)
+ service.setAvailable(true) // Same root, different activation: old review is still invalid.
+ await presenter.answer(true)
+ #expect(try await task.value == .failed(reason: .unavailable))
+ #expect(await harness.debits == 0)
+ service.setAvailable(false)
+ let calls = await harness.previews
+ #expect(try await service.nativeCoinage(request: request(.preparePayment(intent: intent()))) == .failed(reason: .unavailable))
+ #expect(await harness.previews == calls)
+ }
+
+ @Test func pendingPaymentReviewDoesNotBlockOtherOperations() async throws {
+ let harness = try NativeWalletHarness()
+ let presenter = NativeReviewHarness(suspended: true)
+ let service = adapter(harness, NativeRecordMemory(), presenter)
+ let intent = intent()
+ let payment = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent))) }
+ await presenter.waitForReview()
+ // Chat reads payment views after every operation; an open sheet must not hold them.
+ let views = try await service.nativeCoinage(request: request(.views(productId: intent.productId)))
+ guard case let .payments(cards) = views else { Issue.record("Expected cards"); return }
+ #expect(cards.map(\.state) == [.preparing])
+ #expect(try await service.nativeCoinage(request: request(.denomination)) == .denomination(centsUnitRaw: "1000"))
+ #expect(await harness.debits == 0)
+ await presenter.answer(true)
+ guard case let .prepared(card, memo) = try await payment.value else {
+ Issue.record("Expected prepared custody"); return
+ }
+ #expect(card.state == .preparing)
+ #expect(memo != nil)
+ #expect(await harness.debits == 1)
+ #expect(await presenter.reviews.count == 1)
+ // Coins are reselected after review, since another operation could have spent the first selection.
+ #expect(await harness.previews == 2)
+ }
+
+ @Test(.timeLimit(.minutes(1)))
+ func cancellingOneWaiterKeepsTheSharedReviewForTheOthers() async throws {
+ let harness = try NativeWalletHarness()
+ let presenter = NativeReviewHarness(suspended: true)
+ let service = adapter(harness, NativeRecordMemory(), presenter)
+ let intent = intent()
+ let first = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent))) }
+ await presenter.waitForReview()
+ let retry = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent))) }
+ try await Task.sleep(for: .milliseconds(200))
+ first.cancel()
+ // The cancelled caller detaches at once; the sheet stays open for the retry.
+ #expect(try await first.value == .failed(reason: .unavailable))
+ await presenter.answer(true)
+ guard case let .prepared(_, memo) = try await retry.value else {
+ Issue.record("Expected prepared custody"); return
+ }
+ #expect(memo != nil)
+ #expect(await presenter.reviews.count == 1)
+ #expect(await harness.debits == 1)
+ }
+
+ @Test(.timeLimit(.minutes(1)))
+ func aRetryQueuedBeforeTheDecisionIsSavedJoinsItInsteadOfPromptingAgain() async throws {
+ let harness = try NativeWalletHarness()
+ let presenter = NativeReviewHarness(suspended: true)
+ let service = adapter(harness, NativeRecordMemory(), presenter)
+ let intent = intent()
+ let first = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent))) }
+ await presenter.waitForReview()
+ // Hold the queue so the retry's preparation runs after the answer but before it is saved.
+ await harness.holdNextDenomination()
+ let blocker = Task { try await service.nativeCoinage(request: request(.denomination)) }
+ await harness.waitForHeldDenomination()
+ let retry = Task { try await service.nativeCoinage(request: request(.preparePayment(intent: intent))) }
+ try await Task.sleep(for: .milliseconds(200))
+ await presenter.answer(true)
+ try await Task.sleep(for: .milliseconds(200))
+ await harness.releaseDenomination()
+ #expect(try await blocker.value == .denomination(centsUnitRaw: "1000"))
+ for payment in [first, retry] {
+ guard case let .prepared(_, memo) = try await payment.value else {
+ Issue.record("Expected prepared custody"); return
+ }
+ #expect(memo != nil)
+ }
+ #expect(await presenter.reviews.count == 1)
+ #expect(await harness.debits == 1)
+ }
+
+ @Test func wrongRootGenesisAndInstanceAreRejectedBeforeSelection() async throws {
+ let harness = try NativeWalletHarness()
+ let service = adapter(harness, NativeRecordMemory(), NativeReviewHarness())
+ var wrong = scope
+ wrong.rootPublicKey = Data(repeating: 8, count: 32)
+ #expect(try await service.nativeCoinage(request: NativeCoinageRequest(scope: wrong, operation: .denomination)) == .failed(reason: .invalidRequest))
+ wrong = scope
+ wrong.genesisHash = Data(repeating: 8, count: 32)
+ #expect(try await service.nativeCoinage(request: NativeCoinageRequest(scope: wrong, operation: .denomination)) == .failed(reason: .invalidRequest))
+ wrong = scope
+ wrong.coinageInstanceId = nil
+ #expect(try await service.nativeCoinage(request: NativeCoinageRequest(scope: wrong, operation: .denomination)) == .failed(reason: .invalidRequest))
+ #expect(await harness.previews == 0)
+ #expect(await harness.debits == 0)
+ }
+
+ @Test func reconcileAndReadNeverInitializeAnApprovedButUnregisteredPlan() async throws {
+ let harness = try NativeWalletHarness()
+ let journal = NativeRecordMemory()
+ let record = NativeCoinageOutgoing(binding: NativeCoinageBinding(scope), intent: NativeCoinageIntent(intent()),
+ timestamp: 123, centsUnit: "1000", approval: .approved,
+ privacyApproved: false, accepted: false, delivered: false)
+ try await journal.save(.outgoing(record), authorization: {})
+ let service = adapter(harness, journal, NativeReviewHarness())
+ #expect(try await service.nativeCoinage(request: request(.reconcile)) == .done)
+ #expect(try await service.nativeCoinage(request: request(.readHandoff(productId: "chat.product", operationId: intent().operationId))) == .failed(reason: .operationNotFound))
+ #expect(await harness.previews == 0)
+ #expect(await harness.debits == 0)
+ }
+
+ @Test func incomingRequiresFinalityAndPreservesMinimumAndSourceIdentityAcrossRestart() async throws {
+ let harness = try NativeWalletHarness()
+ let journal = NativeRecordMemory()
+ let first = adapter(harness, journal, NativeReviewHarness())
+ let keys = try [UInt8(10), 11].map { try SNKeyFactory().createKeypair(fromSeed: Data(repeating: $0, count: 32)).privateKey().rawData() }
+ let operation = Data(repeating: 13, count: 32)
+ let topup = NativeCoinageOperation.topUp(productId: "chat.product", operationId: operation,
+ minimumAmountRaw: "2000", secretKeys: keys)
+ await harness.setIncoming(.claimed(finalized: false))
+ #expect(try await first.nativeCoinage(request: request(topup)) == .topUp(outcome: .pending))
+ let restarted = adapter(harness, journal, NativeReviewHarness())
+ await harness.setIncoming(.claimed(finalized: true))
+ let reordered = NativeCoinageOperation.topUp(productId: "chat.product", operationId: operation,
+ minimumAmountRaw: "2000", secretKeys: Array(keys.reversed()))
+ #expect(try await restarted.nativeCoinage(request: request(reordered)) == .topUp(outcome: .cleared))
+ let changed = NativeCoinageOperation.topUp(productId: "chat.product", operationId: operation,
+ minimumAmountRaw: "1999", secretKeys: keys)
+ #expect(try await restarted.nativeCoinage(request: request(changed)) == .failed(reason: .operationConflict))
+ let overlap = NativeCoinageOperation.topUp(productId: "different.product", operationId: Data(repeating: 14, count: 32),
+ minimumAmountRaw: "1", secretKeys: [keys[0]])
+ #expect(try await restarted.nativeCoinage(request: request(overlap)) == .failed(reason: .operationConflict))
+ #expect(await harness.claims == 1)
+ }
+
+ @Test func incomingPartialKeepsRawPrecisionAndUnfinalizedStatusesStayPending() {
+ let credited: BigUInt = "340282366920938463463374607431768211"
+ #expect(TrUAPINativeCoinage.topUpOutcome(.claimedPartially(actualClaimed: credited)) == .partial(creditedAmountRaw: String(credited)))
+ #expect(TrUAPINativeCoinage.topUpOutcome(.detecting) == .pending)
+ #expect(TrUAPINativeCoinage.topUpOutcome(.claiming) == .pending)
+ #expect(TrUAPINativeCoinage.topUpOutcome(.claimed(finalized: false)) == .pending)
+ #expect(TrUAPINativeCoinage.topUpOutcome(.notClaimed) == .notClaimed)
+ }
+
+ private func adapter(_ harness: NativeWalletHarness, _ journal: NativeRecordMemory, _ presenter: NativeReviewHarness) -> TrUAPINativeCoinage {
+ let service = TrUAPINativeCoinage(wallet: harness.wallet, store: journal, scope: { scope }, confirmationPresenter: presenter)
+ service.setAvailable(true)
+ return service
+ }
+
+ @Test func zeroMinimumClaimsAllButDoesNotSucceedBeforeNativeFinality() async throws {
+ let harness = try NativeWalletHarness()
+ let service = adapter(harness, NativeRecordMemory(), NativeReviewHarness())
+ let key = try SNKeyFactory().createKeypair(fromSeed: Data(repeating: 55, count: 32)).privateKey().rawData()
+ let operation = NativeCoinageOperation.topUp(
+ productId: "chat.product", operationId: Data(repeating: 21, count: 32),
+ minimumAmountRaw: "0", secretKeys: [key]
+ )
+ await harness.setIncoming(.claimed(finalized: false))
+ #expect(try await service.nativeCoinage(request: request(operation)) == .topUp(outcome: .pending))
+ await harness.setIncoming(.claimed(finalized: true))
+ #expect(try await service.nativeCoinage(request: request(operation)) == .topUp(outcome: .cleared))
+ #expect(await harness.claims == 1)
+ }
+
+ @Test func onlyFinalizedNativeStatusClearsTheActualMemoCoin() async throws {
+ let harness = try NativeWalletHarness()
+ let service = adapter(harness, NativeRecordMemory(), NativeReviewHarness())
+ _ = try await service.nativeCoinage(request: request(.preparePayment(intent: intent())))
+ await harness.setTransfer(.claimed(finalized: false))
+ let best = try await service.nativeCoinage(request: request(.views(productId: "chat.product")))
+ guard case let .payments(bestCards) = best else { Issue.record("Expected cards"); return }
+ #expect(bestCards.map(\.state) == [.preparing])
+ await harness.setTransfer(.claimed(finalized: true))
+ let finalized = try await service.nativeCoinage(request: request(.views(productId: "chat.product")))
+ guard case let .payments(finalCards) = finalized else { Issue.record("Expected cards"); return }
+ #expect(finalCards.map(\.state) == [.cleared])
+ #expect(await harness.debits == 1)
+ }
+
+ private func request(_ operation: NativeCoinageOperation) -> NativeCoinageRequest {
+ NativeCoinageRequest(scope: scope, operation: operation)
+ }
+
+ private func intent() -> NativeCoinagePaymentIntent {
+ NativeCoinagePaymentIntent(operationId: Data(repeating: 3, count: 32), productId: "chat.product", requestId: "one",
+ peerIdentity: Data(repeating: 4, count: 32), recipientUsername: "peer", amountCents: 1)
+ }
+}
+
+private actor NativeRecordMemory: NativeCoinageRecordStoring {
+ private var values: [String: Data] = [:]
+ func records(binding: NativeCoinageBinding) throws -> [NativeCoinageRecord] {
+ try values.values.map { try JSONDecoder().decode(NativeCoinageRecord.self, from: $0) }.filter { $0.binding == binding }
+ }
+ func save(_ record: NativeCoinageRecord, authorization: @escaping @Sendable () throws -> Void) throws {
+ try authorization()
+ values[record.key] = try JSONEncoder().encode(record)
+ }
+}
+
+private actor NativeWalletHarness {
+ let secret: Data
+ let coin: Coin
+ let privacy: Bool
+ private(set) var debits = 0
+ private(set) var previews = 0
+ private(set) var claims = 0
+ private var custody: [String: TransferMemo] = [:]
+ private var accepted: Set = []
+ private var incoming: IncomingPaymentStatus = .detecting
+ private var transfer: CoinageTransferStatus = .awaitingClaim
+ private var holdDenomination = false
+ private var heldDenomination: CheckedContinuation?
+ private var heldWaiter: CheckedContinuation?
+
+ init(privacy: Bool = false) throws {
+ let keys = try SNKeyFactory().createKeypair(fromSeed: Data(repeating: 42, count: 32))
+ secret = keys.privateKey().rawData()
+ coin = Coin(exponent: 0, derivationIndex: 1, age: 1, isOnchain: true, publicKey: keys.publicKey().rawData())
+ self.privacy = privacy
+ }
+
+ nonisolated var wallet: TrUAPINativeCoinage.Wallet {
+ TrUAPINativeCoinage.Wallet(
+ denomination: {
+ await self.denominationGate()
+ return DenominationBreakdownContext(unit: 1000, precision: 5, maxExponent: 20, minExponent: -2)
+ },
+ preview: { try await self.preview($0) },
+ prepare: { _, id, authorization in try await self.prepare(id, authorization: authorization) },
+ retained: { await self.custody[$0] },
+ statuses: { try await self.statuses($0) },
+ accept: { _, _, id, _ in try await self.accept(id) },
+ incomingStatus: { _, _ in await self.incoming }
+ )
+ }
+
+ private func preview(_ amount: BigUInt) throws -> TransferPreview {
+ previews += 1
+ guard amount == 1000 else { throw CoinSelectionError.insufficientFunds }
+ return TransferPreview(selectionResult: .exactMatch(coins: [coin]), fullAmount: amount,
+ scope: privacy ? .withConfirmation : .spendable)
+ }
+ private func prepare(_ id: String, authorization: @Sendable () throws -> Void) throws -> TransferMemo {
+ if let prior = custody[id] { return prior }
+ try authorization()
+ debits += 1
+ let memo = TransferMemo(entries: [secret], totalValue: 1000)
+ custody[id] = memo
+ return memo
+ }
+ private func statuses(_ secretKeys: [Data]) throws -> [Data: CoinageTransferState] {
+ // Mirror the real service's secret-input/public-output contract; wrong or unrelated keys cannot clear.
+ let publicKeys = try secretKeys.map { try SNKeyFactory().createPublicKey(fromSecret: $0).rawData() }
+ return publicKeys.contains(coin.publicKey)
+ ? [coin.publicKey: CoinageTransferState(coin: coin, status: transfer)]
+ : [:]
+ }
+ private func accept(_ id: String) throws {
+ guard accepted.insert(id).inserted else { throw IncomingPaymentError.alreadyExists }
+ claims += 1
+ }
+ /// Suspend the next denomination read, holding whatever queued operation made it.
+ func holdNextDenomination() { holdDenomination = true }
+ func waitForHeldDenomination() async {
+ if heldDenomination != nil { return }
+ await withCheckedContinuation { heldWaiter = $0 }
+ }
+ func releaseDenomination() {
+ heldDenomination?.resume()
+ heldDenomination = nil
+ }
+ private func denominationGate() async {
+ guard holdDenomination else { return }
+ holdDenomination = false
+ await withCheckedContinuation { continuation in
+ heldDenomination = continuation
+ heldWaiter?.resume()
+ heldWaiter = nil
+ }
+ }
+ func setIncoming(_ value: IncomingPaymentStatus) { incoming = value }
+ func setTransfer(_ value: CoinageTransferStatus) { transfer = value }
+}
+
+private actor NativeReviewHarness: TrUAPIConfirmationPresenting {
+ private let approved: Bool
+ private let suspended: Bool
+ private var answerContinuation: CheckedContinuation?
+ private var reviewWaiter: CheckedContinuation?
+ private(set) var reviews: [(MainPurseChatPaymentReview, Bool)] = []
+
+ init(approved: Bool = true, suspended: Bool = false) {
+ self.approved = approved
+ self.suspended = suspended
+ }
+ func confirm(review _: UserConfirmationReview, from _: String) -> Bool { approved }
+ func confirmPermission(review _: UserConfirmationReview, from _: String) async -> TrUAPIPermissionDecision {
+ approved ? .allowAlways : .deny
+ }
+ func confirmNativeCoinage(review: MainPurseChatPaymentReview, requiresPrivacyConfirmation: Bool) async -> Bool {
+ reviews.append((review, requiresPrivacyConfirmation))
+ reviewWaiter?.resume()
+ reviewWaiter = nil
+ if suspended { return await withCheckedContinuation { answerContinuation = $0 } }
+ return approved
+ }
+ func waitForReview() async {
+ if !reviews.isEmpty { return }
+ await withCheckedContinuation { reviewWaiter = $0 }
+ }
+ func answer(_ value: Bool) {
+ answerContinuation?.resume(returning: value)
+ answerContinuation = nil
+ }
+}
diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift
index 7b51c44d2..fa4d8c83a 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 mapsPreimageSubmitToActionWithRequesterAndSize() {
let request = mapper.makeActionRequest(
@@ -106,7 +118,8 @@ struct TrUAPIReviewPromptMapperTests {
.statementStoreAllowance,
.bulletinAllowance,
.smartContractAllowance(.index(4)),
- .autoSigning
+ .autoSigning,
+ .productStatementStoreAllowance(.index(7))
]
))
@@ -116,7 +129,8 @@ struct TrUAPIReviewPromptMapperTests {
.statementStoreAllowance,
.bulletInAllowance,
.smartContractAllowance(dest: .index(4)),
- .autoSigning
+ .autoSigning,
+ .productStatementStoreAllowance(dest: .index(7))
]
))
}
diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIStorageTests.swift
index 9c034cf59..b446bfe42 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
@@ -120,4 +121,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 62b8c0bb4..e79ff1f0b 100644
--- a/ios/truapi-host/README.md
+++ b/ios/truapi-host/README.md
@@ -1,8 +1,11 @@
# TrUAPI iOS host adapter
-_Thin Swift shell over the Rust TrUAPI core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in the Rust core; products connect through the localhost WebSocket bridge._
+_Thin Swift shell over the Rust TrUAPI core (UniFFI). Wire decoding, request routing, and subscription lifecycle stay in
+the Rust core; products connect through the localhost WebSocket bridge._
-The package lives in the truapi repo next to the Rust core it wraps. `Package.swift` sits at the **repo root** (SPM requires that for git-URL dependencies), with all target paths pointing into `ios/truapi-host/`; the build scripts regenerate those target paths from this repo's workspace, because none of them are committed.
+The package lives in the truapi repo next to the Rust core it wraps. `Package.swift` sits at the **repo root** (SPM
+requires that for git-URL dependencies), with all target paths pointing into `ios/truapi-host/`; the build scripts
+regenerate those target paths from this repo's workspace, because none of them are committed.
## What this package is for
@@ -16,7 +19,9 @@ The `TrUAPIHost` SPM package an iOS host app imports directly. It carries:
- `Tests/` contains WS-bridge and WebKit network tests that boot the real Rust core.
- `TestHost/` provides the UIKit app and XcodeGen project for simulator tests.
-The generated bindings, the container bundle and the xcframework are all **gitignored** build outputs, so a fresh checkout has no Swift sources for the package's targets. Run `rebuild.sh` before opening it. The xcframework is additionally distributed as a GitHub release asset. Two scripts split the lifecycle:
+The generated bindings, the container bundle and the xcframework are all **gitignored** build outputs, so a fresh
+checkout has no Swift sources for the package's targets. Run `rebuild.sh` before opening it. The xcframework is
+additionally distributed as a GitHub release asset. Two scripts split the lifecycle:
```bash
./scripts/rebuild.sh # regenerate xcframework + bindings + container
@@ -31,19 +36,17 @@ The generated bindings, the container bundle and the xcframework are all **gitig
# SwiftPM consumer resolves
```
-A consumer pins the plain semver tag, not the `@parity/ios-host@` one,
-which SwiftPM cannot see:
+A consumer pins the plain semver tag, not the `@parity/ios-host@` one, which SwiftPM cannot see:
```swift
.package(url: "https://github.com/paritytech/trinity-user-agents", exact: "0.12.0")
```
-`release-ios.yml` runs all three in order and clones and compiles the tag
-before pushing it. Run them by hand only as a fallback.
+`release-ios.yml` runs all three in order and clones and compiles the tag before pushing it. Run them by hand only as a
+fallback.
-When only the bindings need refreshing — a Rust surface change with no container
-or xcframework impact — skip the full rebuild, which needs Xcode and the iOS
-targets:
+When only the bindings need refreshing — a Rust surface change with no container or xcframework impact — skip the full
+rebuild, which needs Xcode and the iOS targets:
```bash
# from the repo root
@@ -73,9 +76,16 @@ the separate iOS CI workflow builds and tests the embedding app.
Run `rebuild.sh` after changing anything host-visible — the `NativeTrUApiHostRuntime` or `NativeProductExecution` methods, `HostCallbacks`, the native mirror types in `rust/crates/truapi/src/native*`, or `js/container/src` — to refresh your local build outputs. Nothing to commit: CI regenerates them. To publish from a release PR, add `@parity/ios-host ` to its `release:` title. After the release commit passes CI, the release workflow rebuilds and simulator-tests the XCFramework on macOS, uploads it, cuts the `` tag, and opens the `Package.swift` follow-up pull request only after the asset is live. `publish.sh` remains available for an ad hoc manual release.
-For local iteration without publishing, set `TRUAPI_USE_LOCAL_BINARY=1` so the root `Package.swift` builds against `Binaries/` directly.
+For local iteration without publishing, set `TRUAPI_USE_LOCAL_BINARY=1` so the root `Package.swift` builds against
+`Binaries/` directly.
-The embedding app implements `HostBridge` (defined in `TrUAPIHost.swift`): navigation, push, permissions, auth state, scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, feature support, and the served chain set. UI-decision callbacks are `async` and awaited by the Rust core. `HostCallbackAdapter` translates it to the UniFFI-generated `HostCallbacks` protocol; `TrUAPIHostRuntime` and each product execution retain their own adapter. Conform to `HostBridge` rather than to the generated protocol: its extension defaults the optional callbacks, so a newly added one does not break the build. Storage arrives as the `storage` and `coreStorage` sub-objects, which the adapter flattens.
+The embedding app implements `HostBridge` (defined in `TrUAPIHost.swift`): navigation, push, permissions, auth state,
+scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, feature support, and the served chain set.
+UI-decision callbacks are `async` and awaited by the Rust core. `HostCallbackAdapter` translates it to the
+UniFFI-generated `HostCallbacks` protocol; `TrUAPIHostRuntime` and each product execution retain their own adapter.
+Conform to `HostBridge` rather than to the generated protocol: its extension defaults the optional callbacks, so a newly
+added one does not break the build. Storage arrives as the `storage` and `coreStorage` sub-objects, which the adapter
+flattens.
The default `currentLocale` includes the system BCP 47 language tag and actual time-zone identifier. `localizeTimestamps` uses Foundation to format each instant in the requested language and zone, including historical daylight-saving offsets; grouping keys are always Gregorian `YYYY-MM-DD`. Product executions observe system locale and time-zone changes and remove those observers on close. Hosts with an in-app language picker override `currentLocale` and call `notifyLocaleChanged` when that selection changes, preserving the actual time zone. Direct users of generated callbacks must implement `localizeTimestamps`, either supplying a formatter or throwing `HostRejection.Rejected` when conversion is unavailable.
@@ -91,29 +101,41 @@ Add the package as an SPM dependency and link the `TrUAPIHost` product into the
.product(name: "TrUAPIHost", package: "trinity-user-agents")
```
-The release workflow publishes the asset under `@parity/ios-host@`,
-creates a bare `` tag from a manifest containing its URL and checksum,
-and builds that tag from a clean clone before pushing it. It also opens a
-manifest PR to keep `main` current. SPM pins the resolved revision in the app's
-`Package.resolved`; update it with File > Packages > Update in Xcode or
-`xcodebuild -resolvePackageDependencies` after the tag is published.
-
-`HostRuntimeConfig.networkSuffix` is required. Supply the bare TLD (`dot`,
-`paseo`, or `testnet`) from the same network configuration used by onboarding
-and the People/Bulletin genesis hashes. It must match the People chain's
-`NetworkSuffix.NetworkSuffix`. Include this configuration update in the
+The release workflow publishes the asset under `@parity/ios-host@`, creates a bare `` tag from a
+manifest containing its URL and checksum, and builds that tag from a clean clone before pushing it. It also opens a
+manifest PR to keep `main` current. SPM pins the resolved revision in the app's `Package.resolved`; update it with
+File > Packages > Update in Xcode or `xcodebuild -resolvePackageDependencies` after the tag is published.
+
+`HostRuntimeConfig.networkSuffix` is required. Supply the bare TLD (`dot`, `paseo`, or `testnet`) from the same network
+configuration used by onboarding 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.
+
+Pass an existing native wallet as `nativeWallet:` when constructing `TrUAPIHostRuntime`. The separate
+`NativeCoinageHost` protocol contains only `nativeCoinage(request:)`; it is not part of `HostBridge` or product
+callbacks. Omitting the optional dependency selects built-in Rust custody. Once registered, a locked, unavailable or
+failing native wallet never falls back to Rust, and product executions cannot replace it. The reference iOS integration
+always supplies its Coinage service adapter directly, delegates to the coordinator's `CoinageService`, and retains the
+guard against opening Rust purse storage.
+
+This callback is Host-private. Validate the request's wallet root, **Coinage/People** genesis and asset instance against
+the service, preserve durable native custody before returning an outgoing memo, and never expose memo secrets or native
+exception text to a product. Regenerate bindings and rebuild the XCFramework together after changing this callback
+surface; generating Swift alone is not an iOS build or funded-payment qualification.
+
+`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
+`trustedProducts` grant resolvable: without a usable value no manifest resolves, so every cross-product grant not
+already cached is refused, and the refusal is indistinguishable from the other product having granted nothing. Pass 32
+zero bytes only to declare deliberately that this host has no Asset Hub. Include this configuration update in the
embedding app's package upgrade.
-`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
-`trustedProducts` grant resolvable: without a usable value no manifest resolves,
-so every cross-product grant not already cached is refused, and the refusal is
-indistinguishable from the other product having granted nothing. Pass 32 zero
-bytes only to declare deliberately that this host has no Asset Hub. Include this
-configuration update in the embedding app's package upgrade.
-
-Run the package tests in their UIKit host on an iOS simulator (the xcframework has no macOS slice). The helper installs pinned XcodeGen under `.agent/tools`, generates the project, and selects an available simulator:
+Run the package tests in their UIKit host on an iOS simulator (the xcframework has no macOS slice). The helper installs
+pinned XcodeGen under `.agent/tools`, generates the project, and selects an available simulator:
```bash
# from the repo root
@@ -122,9 +144,8 @@ Run the package tests in their UIKit host on an iOS simulator (the xcframework h
## Chat
-A host serving the Chat modality implements `ChatHostBridge` and opens the
-execution with `ProductExecutionKind.chat`. Hosts without it pass nothing and
-Chat calls answer unsupported.
+A host serving the Chat modality implements `ChatHostBridge` and opens the execution with `ProductExecutionKind.chat`.
+Hosts without it pass nothing and Chat calls answer unsupported.
```swift
// Called from a shared dispatch pool, so the backing store must be
@@ -181,24 +202,21 @@ let execution = try runtime.openProductExecution(
let endpoint = try execution.startWsBridge()
```
-The core bounds and screens the product-supplied fields it forwards — ids,
-names, icons, message bodies, URLs, and the action and media counts. Ids and
-names are also normalized; a message body is bounded and screened but passed
-through byte-for-byte, and `ChatFile.sizeBytes` is product-asserted and
-unverified. Contextual output escaping is the host's job.
+The core bounds and screens the product-supplied fields it forwards — ids, names, icons, message bodies, URLs, and the
+action and media counts. Ids and names are also normalized; a message body is bounded and screened but passed through
+byte-for-byte, and `ChatFile.sizeBytes` is product-asserted and unverified. Contextual output escaping is the host's
+job.
-The id `postMessage` returns is the correlation key `ActionTrigger.messageId`
-carries back, so it must name that message for as long as the host stores it.
-Ids arriving _in_ a `Reaction` or `ReactionRemoved` are product-chosen and
-untrusted: they may name a message in another room, or one that never existed.
+The id `postMessage` returns is the correlation key `ActionTrigger.messageId` carries back, so it must name that message
+for as long as the host stores it. Ids arriving _in_ a `Reaction` or `ReactionRemoved` are product-chosen and untrusted:
+they may name a message in another room, or one that never existed.
## Pocket
-A host with a Pocket surface owns the card collection and implements
-`PocketHostBridge`, passed as `pocket:` to `openProductExecution`. Pocket is
-reachable only from a Worker execution with an active session, so a product
-on a signed-out host is denied before the bridge is consulted. Hosts without
-the bridge pass nothing and Pocket calls answer unsupported.
+A host with a Pocket surface owns the card collection and implements `PocketHostBridge`, passed as `pocket:` to
+`openProductExecution`. Pocket is reachable only from a Worker execution with an active session, so a product on a
+signed-out host is denied before the bridge is consulted. Hosts without the bridge pass nothing and Pocket calls answer
+unsupported.
```swift
final class MyPocketBridge: PocketHostBridge, @unchecked Sendable {
@@ -229,70 +247,51 @@ let execution = try runtime.openProductExecution(
execution.notifyPocketCardsChanged(cards: pocketStore.cards())
```
-A card's face does not cross this bridge. The host keeps each card's newest
-face itself: that is what the card shows while the worker is down, and at cold
-start before the worker answers.
-
-On the execution: `publishChatAction` delivers a user's action back to the
-product, buffering up to 64 before it subscribes; `notifyChatRoomsChanged`
-republishes the room list; `render` returns a stream of `RendererNode` trees
-for one render context; `publishRendererAction` delivers a renderer action
-back to the product; and `sessionChatIdentityKey` reads the session's X25519
-chat identity private key, which must not be logged or persisted. An open
-render stream is one worker reference the core holds on the product's behalf;
-the transition it causes arrives on the runtime bridge's
-`workerDemandChanged`, never on the execution's. Two rules the core
-cannot check are the host's to keep: send a render context only for a surface
-the product's manifest `includes`, and publish a renderer action only from the
-current tree of an open render stream.
-
-The runtime answers other devices pairing with it:
-`notifyPairingAllowanceAllocation(deeplink:)` and
-`notifyPairingFailed(announced:reason:)` are the two notices a peer gets before
-the answer, `establishPairing(deeplink:)` is the answer,
-`resumePairing(peer:)` serves the session for its whole life and belongs in its
-own task, and `disconnectPairedHost(peer:)` ends it. Only
-`.peerDisconnected` from `resumePairing` authorises dropping the stored
-pairing. The host persists the peer between answering and serving, which is why
-those are separate calls.
-
-Two steps around them are the host's. `establishPairing` signs its answer with
-this host's own SSO statement identity, so `.walletSso` has to be allocated
-before it runs, and the peer's device statement account has to be tracked
-alongside it for the peer to author into the session:
-`parsePairingDeeplink(deeplink:)` reads that account out of the deeplink before
-any notice goes out, and a pairing that then fails untracks it again unless the
-device was already paired. `disconnectPairedHost` submits the notice and nothing more, so ending
-a pairing also means cancelling that peer's `resumePairing` task and untracking
-its renewal account; dropping the stored pairing alone leaves both running.
-
-Which undo a failure owes is the thrown case, not the message: `.rejected`
-means the peer may already have been reached and its target tracked, while
-`.undecodableDeeplink` is refused before either happens and leaves nothing to
-undo.
-
-The core prompts for nothing along the way, so asking the user is the host's
-too. `parsePairingDeeplink` returns the peer's `metadata` alongside it for that
-prompt: the host name, version, icon and platform the peer put in its QR,
-trimmed and stripped of the control characters and bidirectional overrides that
-would otherwise rewrite the prompt's own text around them, capped at 512
-characters, and `nil` where nothing renderable was sent. Safe to render is not
-verified: nothing signs that metadata, so a prompt built from it says what the
-peer calls itself, never who it is.
-
-The handle `notifyPairingAllowanceAllocation` returns holds the responder
-statement secret its notice was signed with, and nothing consumes it, so drop
-the last reference once the pairing settles rather than holding it for the life
-of the session.
-
-`devicePaired` on the runtime bridge reports a device that finished pairing
-with this signing host, carrying the `PairedSsoPeer` the pairing produced. The
-core has no chat of its own, so announcing the new device to the user's
-existing contacts is the host's to do. It fires at least once per pairing, so
-a device that pairs again reports again; a resumed pairing reports nothing, so
-the host keeps its own record of which devices it has already seen. It arrives
-on the thread answering the handshake, so hand the device off rather than
-announcing it inline. Defaults to a no-op for a host that answers no pairing.
+A card's face does not cross this bridge. The host keeps each card's newest face itself: that is what the card shows
+while the worker is down, and at cold start before the worker answers.
+
+On the execution: `publishChatAction` delivers a user's action back to the product, buffering up to 64 before it
+subscribes; `notifyChatRoomsChanged` republishes the room list; `render` returns a stream of `RendererNode` trees for
+one render context; `publishRendererAction` delivers a renderer action back to the product; and `sessionChatIdentityKey`
+reads the session's X25519 chat identity private key, which must not be logged or persisted. An open render stream is
+one worker reference the core holds on the product's behalf; the transition it causes arrives on the runtime bridge's
+`workerDemandChanged`, never on the execution's. Two rules the core cannot check are the host's to keep: send a render
+context only for a surface the product's manifest `includes`, and publish a renderer action only from the current tree
+of an open render stream.
+
+The runtime answers other devices pairing with it: `notifyPairingAllowanceAllocation(deeplink:)` and
+`notifyPairingFailed(announced:reason:)` are the two notices a peer gets before the answer,
+`establishPairing(deeplink:)` is the answer, `resumePairing(peer:)` serves the session for its whole life and belongs in
+its own task, and `disconnectPairedHost(peer:)` ends it. Only `.peerDisconnected` from `resumePairing` authorises
+dropping the stored pairing. The host persists the peer between answering and serving, which is why those are separate
+calls.
+
+Two steps around them are the host's. `establishPairing` signs its answer with this host's own SSO statement identity,
+so `.walletSso` has to be allocated before it runs, and the peer's device statement account has to be tracked alongside
+it for the peer to author into the session: `parsePairingDeeplink(deeplink:)` reads that account out of the deeplink
+before any notice goes out, and a pairing that then fails untracks it again unless the device was already paired.
+`disconnectPairedHost` submits the notice and nothing more, so ending a pairing also means cancelling that peer's
+`resumePairing` task and untracking its renewal account; dropping the stored pairing alone leaves both running.
+
+Which undo a failure owes is the thrown case, not the message: `.rejected` means the peer may already have been reached
+and its target tracked, while `.undecodableDeeplink` is refused before either happens and leaves nothing to undo.
+
+The core prompts for nothing along the way, so asking the user is the host's too. `parsePairingDeeplink` returns the
+peer's `metadata` alongside it for that prompt: the host name, version, icon and platform the peer put in its QR,
+trimmed and stripped of the control characters and bidirectional overrides that would otherwise rewrite the prompt's own
+text around them, capped at 512 characters, and `nil` where nothing renderable was sent. Safe to render is not verified:
+nothing signs that metadata, so a prompt built from it says what the peer calls itself, never who it is.
+
+The handle `notifyPairingAllowanceAllocation` returns holds the responder statement secret its notice was signed with,
+and nothing consumes it, so drop the last reference once the pairing settles rather than holding it for the life of the
+session.
+
+`devicePaired` on the runtime bridge reports a device that finished pairing with this signing host, carrying the
+`PairedSsoPeer` the pairing produced. The core has no chat of its own, so announcing the new device to the user's
+existing contacts is the host's to do. It fires at least once per pairing, so a device that pairs again reports again; a
+resumed pairing reports nothing, so the host keeps its own record of which devices it has already seen. It arrives on
+the thread answering the handshake, so hand the device off rather than announcing it inline. Defaults to a no-op for a
+host that answers no pairing.
## Architecture
@@ -312,49 +311,78 @@ announcing it inline. Defaults to a no-op for a host that answers no pairing.
Product execution
```
-The bootstrap supplies the execution endpoint to the shared container, which consumes and removes `window.__truapi_localhost` before product scripts run. The container creates one SDK connection for public calls and private permission checks, then exposes its public client through `window.__HOST_API_CLIENT__`. The Rust core handles the wire protocol directly. Outbound responses and host-side capability callbacks (`navigateTo`, `pushNotification`, `cancelNotification`, `devicePermission`, `remotePermission`, `authStateChanged`, core storage, chain JSON-RPC, confirmations, preimage, theme, `featureSupported`, `storage`) reach the embedder through `HostCallbacks`.
+The bootstrap supplies the execution endpoint to the shared container, which consumes and removes
+`window.__truapi_localhost` before product scripts run. The container creates one SDK connection for public calls and
+private permission checks, then exposes its public client through `window.__HOST_API_CLIENT__`. The Rust core handles
+the wire protocol directly. Outbound responses and host-side capability callbacks (`navigateTo`, `pushNotification`,
+`cancelNotification`, `devicePermission`, `remotePermission`, `authStateChanged`, core storage, chain JSON-RPC,
+confirmations, preimage, theme, `featureSupported`, `storage`) reach the embedder through `HostCallbacks`.
## Permissions split
The core's `Permissions` platform trait has two methods, and so does `HostCallbacks`:
-- `devicePermission(product:request:)` - product consent for device capabilities (camera, mic, location, push). `request` is a typed `HostDevicePermissionRequest`.
+- `devicePermission(product:request:)` - product consent for device capabilities (camera, mic, location, push).
+ `request` is a typed `HostDevicePermissionRequest`.
- `remotePermission(product:request:)` - per-product capabilities. `request` is a typed `RemotePermission`.
`product` is the requesting execution's `ProductExecutionConfig`.
-Both return `PermissionDecision`: `.allowOnce`, `.allowAlways`, or `.deny`. Preserve the user’s choice; the core keeps one-use grants in memory and consumes them at the authorized operation. OS refusal after app consent should throw instead of returning `.deny`, which records a product denial. The same typed values drive the `TrUAPIProductExecution` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting.
+Both return `PermissionDecision`: `.allowOnce`, `.allowAlways`, or `.deny`. Preserve the user’s choice; the core keeps
+one-use grants in memory and consumes them at the authorized operation. OS refusal after app consent should throw
+instead of returning `.deny`, which records a product denial. The same typed values drive the `TrUAPIProductExecution`
+permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the
+persisted decisions without prompting.
-Identity and account access reviews use `confirmPermission(review:)`, which also returns `PermissionDecision`. Override it to preserve Allow once. Its compatibility default maps `confirmUserAction`'s Boolean approval to `.allowAlways`; signing and other single-action reviews continue to use that Boolean callback.
+Identity and account access reviews use `confirmPermission(review:)`, which also returns `PermissionDecision`. Override
+it to preserve Allow once. Its compatibility default maps `confirmUserAction`'s Boolean approval to `.allowAlways`;
+signing and other single-action reviews continue to use that Boolean callback.
-Fetch, XHR, WebSocket connections, notification scheduling, external navigation and existing remote-operation gates consume temporary grants. The shared container authorizes each `getUserMedia` call through `authorize_device_permission`, camera before microphone. Each approval consumes its one-use grant for that attempt: a later microphone denial or native capture failure does not restore the camera grant. The returned stream remains usable until stopped; another capture requires new authorization.
+Fetch, XHR, WebSocket connections, notification scheduling, external navigation and existing remote-operation gates
+consume temporary grants. The shared container authorizes each `getUserMedia` call through
+`authorize_device_permission`, camera before microphone. Each approval consumes its one-use grant for that attempt: a
+later microphone denial or native capture failure does not restore the camera grant. The returned stream remains usable
+until stopped; another capture requires new authorization.
-The container enforces product consent, while native media delegates resolve OS permission without consuming product consent again. An OS grant does not establish product consent. This boundary requires the container to run before product code in every frame, with its native methods and prototypes locked. SPA and Chat install it at document start. Authorization uses a private transport and response handler with captured browser primitives, so replacing public SDK replies, collection methods or Promise methods cannot approve a pending capture.
+The container enforces product consent, while native media delegates resolve OS permission without consuming product
+consent again. An OS grant does not establish product consent. This boundary requires the container to run before
+product code in every frame, with its native methods and prototypes locked. SPA and Chat install it at document start.
+Authorization uses a private transport and response handler with captured browser primitives, so replacing public SDK
+replies, collection methods or Promise methods cannot approve a pending capture.
## SSO session handling
-`TrUAPIHostRuntime` exposes two methods for wallet-owned SSO sessions. Meaningful request answering requires `activateLocalSession` to have been called first; `prepareDisconnectRequest` needs no session.
+`TrUAPIHostRuntime` exposes two methods for wallet-owned SSO sessions. Meaningful request answering requires
+`activateLocalSession` to have been called first; `prepareDisconnectRequest` needs no session.
```swift
func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome
func prepareDisconnectRequest() -> Data
```
-`handleSsoRequest(message:)` takes one SCALE-encoded `RemoteMessage` exactly as decrypted from the statement-store session and routes it through the Rust core. The returned `SsoRequestOutcome` is the generated UniFFI enum (no Swift mirror):
+`handleSsoRequest(message:)` takes one SCALE-encoded `RemoteMessage` exactly as decrypted from the statement-store
+session and routes it through the Rust core. The returned `SsoRequestOutcome` is the generated UniFFI enum (no Swift
+mirror):
- `.response(message:)` — SCALE-encoded reply; post it back over the same session.
- `.disconnected` — the peer ended the session; tear down the transport and records on the wallet side.
- `.ignored` — the message was not a request; nothing to post.
-Confirmation-gated requests suspend on `confirmUserAction` or `confirmPermission`, so `handleSsoRequest` can take arbitrarily long. Always call it from a `Task`, never the main thread.
+Confirmation-gated requests suspend on `confirmUserAction` or `confirmPermission`, so `handleSsoRequest` can take
+arbitrarily long. Always call it from a `Task`, never the main thread.
-`prepareDisconnectRequest()` returns the SCALE-encoded `Disconnected` message to post when the wallet is ending the session. Posting and record cleanup (host entry, device record, device-removed broadcast) stay with the wallet.
+`prepareDisconnectRequest()` returns the SCALE-encoded `Disconnected` message to post when the wallet is ending the
+session. Posting and record cleanup (host entry, device record, device-removed broadcast) stay with the wallet.
## Statement-store allowance renewal
-Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The runtime owns the ledger and the registration; the app owns only the schedule.
+Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing.
+They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances
+active until cleanup catches up, 48 hours on `paseo-next-v2`. The runtime owns the ledger and the registration; the app
+owns only the schedule.
-Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after pairing, not at construction:
+Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after
+pairing, not at construction:
```swift
try runtime.trackStatementRenewalTargets([
@@ -363,15 +391,32 @@ try runtime.trackStatementRenewalTargets([
])
```
-The ledger persists across launches, and an entry is dropped when the identity that promised it changes. `.walletSso` and `.productStatementAllowance` are derivation recipes, so they survive that; `.account` carries a fixed account id and does not. A dropped target is listed in `report.pruned`, which is how a host learns to re-track one and keep renewal covering it. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather than trying to reason about what survived.
-
-`statementRenewalTargets()` lists what the ledger holds, in the order it was tracked. It needs no active session, so a `BGTaskScheduler` wake can read it on a cold start before deciding whether the pass is worth running. Each entry carries an `owner`: a recipe has none and resolves under whichever identity is active, while a fixed account records the root key that promised it. `statementRenewalOwnerKey()` returns that key for the active identity, and needs a session. An entry whose owner is that key, or which has no owner, is one the next pass will renew; any other is one it will prune.
-
-`untrackStatementRenewalAccount(accountId:)` drops one fixed account and reports whether the ledger held it. It is scoped to the active identity and so needs a session, and it never removes an entry another identity promised. A stale entry does not deny you a slot forever, since registration replaces the oldest slot past its cooldown once a period is full, but it does cost an allocation attempt every period and keeps churning the slot table, which is what untracking it saves.
-
-Only `.account` can be untracked. `.walletSso` and `.productStatementAllowance` are recipes with no removal path, so a product you no longer run keeps being resolved and renewed until the promising identity changes.
-
-Then run a pass from a background task, off the main thread. It needs an active session too, which is the whole difficulty here: a `BGTaskScheduler` wake on a cold start has none until you restore one, and the pass then fails with the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and retries.
+The ledger persists across launches, and an entry is dropped when the identity that promised it changes. `.walletSso`
+and `.productStatementAllowance` are derivation recipes, so they survive that; `.account` carries a fixed account id and
+does not. A dropped target is listed in `report.pruned`, which is how a host learns to re-track one and keep renewal
+covering it. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather
+than trying to reason about what survived.
+
+`statementRenewalTargets()` lists what the ledger holds, in the order it was tracked. It needs no active session, so a
+`BGTaskScheduler` wake can read it on a cold start before deciding whether the pass is worth running. Each entry carries
+an `owner`: a recipe has none and resolves under whichever identity is active, while a fixed account records the root
+key that promised it. `statementRenewalOwnerKey()` returns that key for the active identity, and needs a session. An
+entry whose owner is that key, or which has no owner, is one the next pass will renew; any other is one it will prune.
+
+`untrackStatementRenewalAccount(accountId:)` drops one fixed account and reports whether the ledger held it. It is
+scoped to the active identity and so needs a session, and it never removes an entry another identity promised. A stale
+entry does not deny you a slot forever, since registration replaces the oldest slot past its cooldown once a period is
+full, but it does cost an allocation attempt every period and keeps churning the slot table, which is what untracking it
+saves.
+
+Only `.account` can be untracked. `.walletSso` and `.productStatementAllowance` are recipes with no removal path, so a
+product you no longer run keeps being resolved and renewed until the promising identity changes.
+
+Then run a pass from a background task, off the main thread. It needs an active session too, which is the whole
+difficulty here: a `BGTaskScheduler` wake on a cold start has none until you restore one, and the pass then fails with
+the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal
+failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and
+retries.
```swift
let report = try runtime.renewStatementAllowances()
@@ -387,43 +432,56 @@ if report.slotsExhausted {
}
```
-One scheduled pass per period is enough, with room to spare: an allowance stays usable for `Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed wake-up is recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an hour; a `BGTaskScheduler` host should read a value under an hour as the boundary approaching rather than requesting a wake-up every hour for a pass that will almost always report `alreadyAllocated`.
+One scheduled pass per period is enough, with room to spare: an allowance stays usable for
+`Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed wake-up is
+recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an
+hour; a `BGTaskScheduler` host should read a value under an hour as the boundary approaching rather than requesting a
+wake-up every hour for a pass that will almost always report `alreadyAllocated`.
### Answering the scheduler
-A pass reports per target and only throws when it could not run at all, so decide from the report rather than from the absence of an error:
+A pass reports per target and only throws when it could not run at all, so decide from the report rather than from the
+absence of an error:
- every status `Registered` or `AlreadyAllocated`: completed successfully.
-- any status `Failed`: complete unsuccessfully and submit a fresh request, since iOS does not reschedule one for you. The grace window means that request can wait for the next opportunistic wake rather than a tight loop.
-- any status `SkippedExhausted`, or `report.slotsExhausted`: completed successfully. Retrying cannot free a slot, only time or a replacement can, so a retry here only burns background budget. It does mean an allowance went unrenewed, so tell the person rather than only logging it.
-- a throw carrying `Disconnected` before a session is restored: not ready rather than failed. Restore a session and let the next wake run the pass.
+- any status `Failed`: complete unsuccessfully and submit a fresh request, since iOS does not reschedule one for you.
+ The grace window means that request can wait for the next opportunistic wake rather than a tight loop.
+- any status `SkippedExhausted`, or `report.slotsExhausted`: completed successfully. Retrying cannot free a slot, only
+ time or a replacement can, so a retry here only burns background budget. It does mean an allowance went unrenewed, so
+ tell the person rather than only logging it.
+- a throw carrying `Disconnected` before a session is restored: not ready rather than failed. Restore a session and let
+ the next wake run the pass.
Scheduling is one of three layers, and only the first needs the OS:
1. a `BGTaskScheduler` wake, which is the only one that covers an app nobody opens.
2. a pass on session activation, which covers an app somebody does.
-3. on-demand allocation, which registers a product's own account for the current period when that product asks for a statement-store allowance and none is held. That covers the asking product, not the rest of the ledger, so it narrows the window rather than closing it.
+3. on-demand allocation, which registers a product's own account for the current period when that product asks for a
+ statement-store allowance and none is held. That covers the asking product, not the rest of the ledger, so it narrows
+ the window rather than closing it.
-`lastStatementRenewalReport()` returns the most recent pass the in-process loop ran, or `nil` if none has, which is "not yet" rather than healthy. The loop returns nothing to its caller, so this is where a host driving it reads what it achieved; checking on resume is enough to catch an exhausted period. A direct `renewStatementAllowances()` hands back its own report and does not write here.
+`lastStatementRenewalReport()` returns the most recent pass the in-process loop ran, or `nil` if none has, which is "not
+yet" rather than healthy. The loop returns nothing to its caller, so this is where a host driving it reads what it
+achieved; checking on resume is enough to catch an exhausted period. A direct `renewStatementAllowances()` hands back
+its own report and does not write here.
-`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead. It suits a host that stays resident; on iOS a suspended app stops ticking, so prefer `BGTaskScheduler` driving the one-shot call. A pass has no cancellation, so several targets can outlast a short background budget; targets registered before the process is killed are not lost, and read back as already allocated next time.
+`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead. It suits a host that stays
+resident; on iOS a suspended app stops ticking, so prefer `BGTaskScheduler` driving the one-shot call. A pass has no
+cancellation, so several targets can outlast a short background budget; targets registered before the process is killed
+are not lost, and read back as already allocated next time.
An account id must be exactly 32 bytes. Anything else is rejected where the bindings convert it, before any chain work happens.
## Example
-> **Threading:** the Rust core invokes every `HostCallbacks` method on a
-> background thread it owns, never the main thread. Hop to the main thread
-> (`MainActor` / `DispatchQueue.main`) before touching UIKit, WebKit, or the
-> `WKWebView`. The `async` callbacks (`navigateTo`, `pushNotification`,
-> `devicePermission`, `remotePermission`, `featureSupported`,
-> `confirmUserAction`, `confirmPermission`, `lookupPreimage`) are awaited by the core, so an
-> implementation may suspend for as long as the user takes to decide (e.g.
-> `await MainActor.run { ... }` or an `withCheckedContinuation` around a
-> prompt); other TrUAPI traffic keeps flowing while you wait. The remaining
-> sync callbacks (auth state, storage, core storage, chain, theme,
-> `cancelNotification`) run inline on the dispatcher thread and must return
-> promptly without blocking.
+> **Threading:** the Rust core invokes every `HostCallbacks` method on a background thread it owns, never the main
+> thread. Hop to the main thread (`MainActor` / `DispatchQueue.main`) before touching UIKit, WebKit, or the `WKWebView`.
+> The `async` callbacks (`navigateTo`, `pushNotification`, `devicePermission`, `remotePermission`, `featureSupported`,
+> `confirmUserAction`, `confirmPermission`, `lookupPreimage`) are awaited by the core, so an implementation may suspend
+> for as long as the user takes to decide (e.g. `await MainActor.run { ... }` or an `withCheckedContinuation` around a
+> prompt); other TrUAPI traffic keeps flowing while you wait. The remaining sync callbacks (auth state, storage, core
+> storage, chain, theme, `cancelNotification`) run inline on the dispatcher thread and must return promptly without
+> blocking.
```swift
import Foundation
@@ -586,30 +644,59 @@ execution.close()
runtime.disconnect()
```
-The updated `@parity/truapi` SDK keeps the same client across connection loss. The SDK replaces the socket; interrupted operations fail with `ConnectionResetError` and are never replayed. Recreate read/watch subscriptions in the provider that owns them. SDKs 0.16.0 and 0.18.0 can still start through the minimal `__HOST_API_PORT__` adapter, but require a page reload after a disconnect. Remove that adapter once deployed products adopt the injected client.
+The updated `@parity/truapi` SDK keeps the same client across connection loss. The SDK replaces the socket; interrupted
+operations fail with `ConnectionResetError` and are never replayed. Recreate read/watch subscriptions in the provider
+that owns them. SDKs 0.16.0 and 0.18.0 can still start through the minimal `__HOST_API_PORT__` adapter, but require a
+page reload after a disconnect. Remove that adapter once deployed products adopt the injected client.
-The shared container uses the same WebSocket as SDK calls and asks Rust to authorize each fetch or XHR before sending it, and each remote WebSocket before connecting. It parses the URL with captured browser primitives and sends its hostname to `authorize_remote_permission`; Rust normalizes and checks the domain. Swift supplies the endpoint and handles native permission prompts; it does not relay individual network permission messages. An upfront permission request and a network operation are separate, so an Allow once decision is consumed by the next permitted operation rather than persisted.
+The shared container uses the same WebSocket as SDK calls and asks Rust to authorize each fetch or XHR before sending
+it, and each remote WebSocket before connecting. It parses the URL with captured browser primitives and sends its
+hostname to `authorize_remote_permission`; Rust normalizes and checks the domain. Swift supplies the endpoint and
+handles native permission prompts; it does not relay individual network permission messages. An upfront permission
+request and a network operation are separate, so an Allow once decision is consumed by the next permitted operation
+rather than persisted.
-XHR keeps native request headers, response types and browser CORS behavior. `open()` configures the request synchronously; `send()` waits for permission before sending. Aborting or reopening during that wait cancels the pending send. Synchronous XHR is unsupported because it cannot wait for an asynchronous permission decision.
+XHR keeps native request headers, response types and browser CORS behavior. `open()` configures the request
+synchronously; `send()` waits for permission before sending. Aborting or reopening during that wait cancels the pending
+send. Synchronous XHR is unsupported because it cannot wait for an asynchronous permission decision.
-A remote `WebSocket` starts in `CONNECTING` while Rust checks the same domain permission. Allow once permits that connection and all its messages; a new connection checks again. Closing while permission is pending prevents the connection from opening. Text, binary messages and subprotocols use the native socket after approval. The private host connection uses the browser constructor captured before these gates are installed. Product-created sockets receive no endpoint exemption.
+A remote `WebSocket` starts in `CONNECTING` while Rust checks the same domain permission. Allow once permits that
+connection and all its messages; a new connection checks again. Closing while permission is pending prevents the
+connection from opening. Text, binary messages and subprotocols use the native socket after approval. The private host
+connection uses the browser constructor captured before these gates are installed. Product-created sockets receive no
+endpoint exemption.
Forwarded WebSocket events and XHR failures before sending are synthetic, with `isTrusted` set to `false`.
-WebRTC uses the same private transport. Each peer connection asks Rust for permission at its first network method, such as `createOffer`, and shares that decision across later methods on the connection. Allow once permits one connection. New connections check the current permission without requiring a page reload.
-
-To disable WebRTC, call `execution.setPermissionAuthorizationStatus` with a remote `.webRtc` request and `.denied` before loading each product. This overrides saved grants and trusted-product auto-grants, which otherwise skip `remotePermission` callbacks.
+WebRTC uses the same private transport. Each peer connection asks Rust for permission at its first network method, such
+as `createOffer`, and shares that decision across later methods on the connection. Allow once permits one connection.
+New connections check the current permission without requiring a page reload.
-The installer adds the bootstrap and container scripts before loading. It preserves the host's website data store and navigation delegate. Hosts that assemble their own script lists can keep using `LocalhostBridgeBootstrap.script` followed by `ContainerScriptBundle.load()`, with the container injected into every frame.
+To disable WebRTC, call `execution.setPermissionAuthorizationStatus` with a remote `.webRtc` request and `.denied`
+before loading each product. This overrides saved grants and trusted-product auto-grants, which otherwise skip
+`remotePermission` callbacks.
-`Worker`, `WebTransport` and `getDisplayMedia` screen capture are unavailable. Workers would provide a separate realm with unguarded network APIs; WebTransport has no permission wrapper, and screen capture has no product permission.
+The installer adds the bootstrap and container scripts before loading. It preserves the host's website data store and
+navigation delegate. Hosts that assemble their own script lists can keep using `LocalhostBridgeBootstrap.script`
+followed by `ContainerScriptBundle.load()`, with the container injected into every frame.
-Redirects and stylesheet/font loads retain native WebKit behavior. Redirect destinations are not separately authorized by the fetch/XHR wrappers; direct DOM resource loads remain outside those wrappers. There is no content-rule registration, global settings refresh or installation disposal requirement. Close the execution when its product stops, and maintain the host's existing web-view navigation and teardown behavior.
+`Worker`, `WebTransport` and `getDisplayMedia` screen capture are unavailable. Workers would provide a separate realm
+with unguarded network APIs; WebTransport has no permission wrapper, and screen capture has no product permission.
-Build the generated JavaScript SDK before the container: from the repository root, run `npm ci --ignore-scripts`, `npm run build --prefix js/packages/truapi`, then `npm run build --prefix js/container`. A protocol change also requires regenerating the SDK through the repository's normal build pipeline.
+Redirects and stylesheet/font loads retain native WebKit behavior. Redirect destinations are not separately authorized
+by the fetch/XHR wrappers; direct DOM resource loads remain outside those wrappers. There is no content-rule
+registration, global settings refresh or installation disposal requirement. Close the execution when its product stops,
+and maintain the host's existing web-view navigation and teardown behavior.
-`ProductNetworkAccessTests` exercises grant/deny/revocation, one-use fetch, WebRTC and media authorization, native redirects, stylesheet/font requests, and preserving a persistent store and existing navigation delegate. Media coverage uses a capture stub with the actual private Rust permission transport; it does not require simulator camera hardware. The tests require the built container, current Rust bindings and a real WKWebView in the UIKit test host. These Apple-only tests cannot run on Linux.
+Build the generated JavaScript SDK before the container: from the repository root, run `npm ci --ignore-scripts`,
+`npm run build --prefix js/packages/truapi`, then `npm run build --prefix js/container`. A protocol change also requires
+regenerating the SDK through the repository's normal build pipeline.
+`ProductNetworkAccessTests` exercises grant/deny/revocation, one-use fetch, WebRTC and media authorization, native
+redirects, stylesheet/font requests, and preserving a persistent store and existing navigation delegate. Media coverage
+uses a capture stub with the actual private Rust permission transport; it does not require simulator camera hardware.
+The tests require the built container, current Rust bindings and a real WKWebView in the UIKit test host. These
+Apple-only tests cannot run on Linux.
## Build outputs in detail
diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
index 7ff83c5cb..2a40c7cc9 100644
--- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
+++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
@@ -46,6 +46,52 @@ 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")
+ }
+}
+
+/// Optional process-wide native wallet custody, supplied at runtime construction.
+/// Omit only when the built-in Rust wallet owns custody. Keep a registered native
+/// wallet installed while locked or unavailable; failures never permit fallback.
+public protocol NativeCoinageHost: AnyObject, Sendable {
+ /// Host-private operation. Never expose requests, bearer memos, or raw native
+ /// errors to products or logs. Return sanitized operation failures as values.
+ func nativeCoinage(request: NativeCoinageRequest) async throws -> NativeCoinageResponse
+}
+
/// Host-side callback bundle that the Rust core invokes for capabilities the
/// native shell owns. The permission split mirrors the Rust `Permissions`
/// trait:
@@ -59,7 +105,7 @@ public protocol HostCoreStorageBackend: AnyObject, Sendable {
/// Async callbacks must suspend while waiting for a decision; blocking their
/// thread stalls other TrUAPI traffic. Synchronous callbacks must return promptly.
/// Run UI work on the main actor, for example with `await MainActor.run { ... }`.
-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)
@@ -117,6 +163,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
@@ -132,6 +187,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
@@ -288,6 +347,8 @@ 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 }
@@ -295,6 +356,9 @@ public extension HostBridge {
try await confirmUserAction(review: review) ? .allowAlways : .deny
}
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)
}
@@ -362,6 +426,26 @@ public extension HostBridge {
func endOperation(productId: String, id: UInt32) async throws {}
}
+/// Kept separate from product callbacks so executions cannot replace custody.
+private final class NativeCoinageCallbackAdapter: NativeCoinageCallbacks, @unchecked Sendable {
+ private let bridge: NativeCoinageHost
+
+ init(bridge: NativeCoinageHost) {
+ self.bridge = bridge
+ }
+
+ func nativeCoinage(request: NativeCoinageRequest) async throws -> NativeCoinageCallbackResult {
+ do {
+ return NativeCoinageCallbackResult(response: try await bridge.nativeCoinage(request: request))
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ // Even typed HostRejection values may embed secret-bearing native errors.
+ throw HostRejection.Rejected(reason: "Native Coinage wallet operation failed")
+ }
+ }
+}
+
/// Ids handed out by the default `beginOperation`, distinct for the life of
/// the process.
private final class DefaultOperationIds: @unchecked Sendable {
@@ -589,6 +673,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)
@@ -601,6 +697,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)
@@ -619,6 +758,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()
@@ -736,24 +883,39 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable {
public final class TrUAPIHostRuntime: @unchecked Sendable {
private let inner: NativeTrUApiHostRuntime
private let callbackRetainer: HostCallbacks
+ private let nativeWalletRetainer: NativeCoinageCallbacks?
private let notificationCenter: NotificationCenter
private let foregroundObserver: NSObjectProtocol
private var contactsRetainer: NativeContactsCallbacks?
- public convenience init(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) throws {
- try self.init(bridge: bridge, runtimeConfig: runtimeConfig, notificationCenter: .default)
+ /// Register native custody once; nil selects the built-in Rust wallet.
+ public convenience init(
+ bridge: HostBridge,
+ runtimeConfig: HostRuntimeConfig,
+ nativeWallet: NativeCoinageHost? = nil
+ ) throws {
+ try self.init(
+ bridge: bridge,
+ runtimeConfig: runtimeConfig,
+ nativeWallet: nativeWallet,
+ notificationCenter: .default
+ )
}
init(
bridge: HostBridge,
runtimeConfig: HostRuntimeConfig,
+ nativeWallet: NativeCoinageHost? = nil,
notificationCenter: NotificationCenter
) throws {
let adapter = HostCallbackAdapter(bridge: bridge)
callbackRetainer = adapter
+ let walletAdapter = nativeWallet.map { NativeCoinageCallbackAdapter(bridge: $0) }
+ nativeWalletRetainer = walletAdapter
let inner = try NativeTrUApiHostRuntime.withRuntimeConfig(
callbacks: adapter,
- runtimeConfig: runtimeConfig
+ runtimeConfig: runtimeConfig,
+ nativeWallet: walletAdapter
)
self.inner = inner
self.notificationCenter = notificationCenter
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..9e88567ea
--- /dev/null
+++ b/js/packages/truapi-host/NOTICE
@@ -0,0 +1,18 @@
+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 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.
+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 eaf1d70ad..bf128f90e 100644
--- a/js/packages/truapi-host/README.md
+++ b/js/packages/truapi-host/README.md
@@ -21,6 +21,8 @@ The package exposes tree-shakeable subpath exports — import only what your env
| `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. |
| `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. |
+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.
`scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. `wasm/web` is the production browser
host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features,
which is what lets the test host hold keys and answer resource allocation as granted without allocating anything. A real
@@ -53,6 +55,19 @@ optional bundle does not replace them. Raw Wasm consumers can use
routing isolation, not per-call `AbortSignal` cancellation: hosts must still retire their connection-owned interactive
UI explicitly.
+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.
+
+The optional runtime-wide `callbacks.coinageWallet` group registers an existing native main-purse service through
+`nativeCoinage(request)`. Omitting the group uses Core's built-in Rust wallet; no explicit backend selector is needed. A
+registered native wallet remains authoritative when unavailable, locked, or failing: none of those states enables Rust
+custody. Registration is captured when the runtime is constructed and cannot be replaced by product callbacks. Malformed
+native groups reject initialization. Native request/response payloads, especially outgoing memos, are Host-private;
+infrastructure exceptions are sanitized at the adapter boundary.
+
`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 from the dotNS
contracts deployed there, so it is what makes a `trustedProducts` grant resolvable: without a usable value no manifest
@@ -78,7 +93,6 @@ The optional callback receives `LocalIdentityProgress` (exported from `@parity/t
elapsed-time estimates. `confirming` means the backend accepted the request, not that the username is owned yet; only
the resolved promise confirms ownership. A retry does not submit another registration. Observer exceptions do not
interrupt the operation, and settled or disposed requests receive no further progress.
-
Both methods return `LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed
`identityAccountId` and an optional verified `liteUsername`. The backend must allow the worker's origin, or the host
must provide an approved same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the
@@ -309,6 +323,19 @@ The core re-checks every account returned. It caches what it resolves, so call
`notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked
contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`).
+Browser signing hosts can back this UI with `runtime.getNativeChatContacts()`. It returns
+`{ walletPublicKey, genesisHash, contacts: [{ peerIdentity, username? }] }` to trusted host code only.
+The directory restores encrypted native Chat actors, checks their current authorization, includes only authenticated
+ready peers, and deduplicates identities. Conflicting verified names are omitted. It is not a product or SSO API;
+pairing hosts reject it. Bind the result to the active signing public key and People genesis, never to a username.
+Native departures/revocations remove readiness; product-private block lists are not a separate directory source.
+
+Actors are indexed when opened. Historical unindexed products must be opened once on the upgraded host; private storage
+has no enumeration API. Directory reads share native commit gates, and native state/session/permission changes invalidate
+cached handles. A browser adapter must also cancel pending picker/lookup work when its wallet, network, provider or
+directory generation changes. Provider-scoped `contacts` callbacks control that provider's UI; absent overrides inherit
+the runtime-wide Contacts adapter. Keep the runtime-wide source alive for host-owned rendering until the owner closes.
+
## Generated WASM artefacts
The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. Hosts wire their JSON-RPC provider
@@ -361,6 +388,20 @@ const secondProvider = await runtime.createProvider({
`@parity/truapi-host/web` also exports `createIframeHost` for the 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 authority, or private core storage. Wallet callbacks always use the
+runtime-wide bundle, even when a provider supplies overrides. Disposing a provider does not dispose the owner runtime. A
+signing host keeps one owner alive for wallet recovery, not ordinary Chat 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 the `AuthSession` slot on its own and
diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json
index d8405abf8..2dfd9f2ae 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.23.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",
@@ -76,7 +76,9 @@
"!dist/**/*.wasm.gz",
"!dist/**/*.wasm.br",
"README.md",
- "LICENSE"
+ "LICENSE",
+ "LICENSE-AGPL-3.0",
+ "NOTICE"
],
"scripts": {
"build": "tsc -b && node scripts/build-cjs.mjs",
diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts
index 45ddfbc0d..bea241656 100644
--- a/js/packages/truapi-host/src/adapter-support.ts
+++ b/js/packages/truapi-host/src/adapter-support.ts
@@ -8,8 +8,14 @@ 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,
+ CoinageWalletHost,
+ HopProvider,
+ JsonRpcConnection,
+ NativeChatFilesHost,
+} from "./generated/host-callbacks.js";
type WireResult =
| { success: true; value: T }
@@ -66,23 +72,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 +131,144 @@ 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");
+ },
+};
+
+/** Native exceptions may contain bearer material; preserve only typed failure values. */
+export function coinageWalletHostAdapter(
+ host: Required | undefined,
+): Required | undefined {
+ if (host === undefined) return undefined;
+ let nativeCoinage: Required["nativeCoinage"];
+ try {
+ nativeCoinage = host.nativeCoinage.bind(host);
+ } catch {
+ throw new Error("Native Coinage wallet callback is unavailable");
+ }
+ return {
+ async nativeCoinage(request) {
+ try {
+ return await nativeCoinage(request);
+ } catch {
+ throw new Error("Native Coinage wallet operation failed");
+ }
+ },
+ };
+}
+
+/** 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 a195eae4a..3a8aa17b2 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,
@@ -23,6 +24,11 @@ import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js";
import {
AuthState,
CoreStorageKey,
+ NativeChatFilePickRequest,
+ NativeChatFileExportRequest,
+ NativeChatPickedFile,
+ NativeCoinageRequest,
+ NativeCoinageResponse,
PermissionDecision,
ProductContext,
ProductExecutionKind,
@@ -102,6 +108,175 @@ const SIGN_PAYLOAD: HostSignPayloadData = {
};
describe("createWasmRawCallbacks", () => {
+ it("leaves the native callback absent for the built-in Rust wallet", () => {
+ const raw = createWasmRawCallbacks(makeHostCallbacks());
+ expect(raw.nativeCoinage).toBeUndefined();
+ });
+
+ it("rejects malformed native registration rather than enabling the Rust wallet", () => {
+ for (const coinageWallet of [null, false, {}, { nativeCoinage: 1 }]) {
+ expect(() =>
+ createWasmRawCallbacks({
+ ...makeHostCallbacks(),
+ coinageWallet: coinageWallet as never,
+ }),
+ ).toThrow();
+ }
+ });
+
+ it("keeps the registered native wallet after rejection or infrastructure failure", async () => {
+ const host = makeHostCallbacks({
+ coinageWallet: {
+ nativeCoinage: async (request) => {
+ if (request.operation.tag === "Reconcile")
+ throw new Error("private native memo bearer material");
+ return { tag: "Failed", value: { reason: "Unavailable" } };
+ },
+ },
+ });
+ const native = createWasmRawCallbacks(host);
+ const request = NativeCoinageRequest.enc({
+ scope: {
+ rootPublicKey: new Uint8Array(32),
+ genesisHash: new Uint8Array(32),
+ },
+ operation: { tag: "Denomination" },
+ });
+ expect(
+ NativeCoinageResponse.dec(await native.nativeCoinage!(request)),
+ ).toEqual({
+ tag: "Failed",
+ value: { reason: "Unavailable" },
+ });
+ const infrastructureFailure = native.nativeCoinage!(
+ NativeCoinageRequest.enc({
+ scope: {
+ rootPublicKey: new Uint8Array(32),
+ genesisHash: new Uint8Array(32),
+ },
+ operation: { tag: "Reconcile" },
+ }),
+ );
+ await expect(infrastructureFailure).rejects.toThrow();
+ await infrastructureFailure.catch((error: Error) => {
+ expect(error.message).not.toContain("bearer material");
+ });
+ // Neither mutation of the source group nor failure changes the captured service.
+ host.coinageWallet!.nativeCoinage = async () => ({ tag: "Done" });
+ host.coinageWallet = undefined;
+ expect(
+ NativeCoinageResponse.dec(await native.nativeCoinage!(request)),
+ ).toEqual({
+ tag: "Failed",
+ value: { reason: "Unavailable" },
+ });
+ });
+
+ 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[] = [];
@@ -613,6 +788,125 @@ 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 f452c1cad..fe6553fd1 100644
--- a/js/packages/truapi-host/src/runtime.ts
+++ b/js/packages/truapi-host/src/runtime.ts
@@ -19,15 +19,31 @@ import type {
// SCALE bytes. The web worker pairing-host runtime adapts this typed surface
// into the byte-oriented callback bridge consumed by the WASM core.
export * from "./generated/host-callbacks.js";
-export type {
- JsonRpcConnection as PlatformJsonRpcConnection,
-} from "./generated/host-callbacks.js";
+export type { JsonRpcConnection as PlatformJsonRpcConnection } from "./generated/host-callbacks.js";
/** Encode a typed core-storage slot for hosts that need an opaque backing key. */
export function encodeCoreStorageKey(key: CoreStorageKey): Uint8Array {
return GeneratedCoreStorageKey.enc(key);
}
+/** Authenticated, ready native Chat peer. Host-private; never a product directory. */
+export interface NativeChatContact {
+ /** Canonical lowercase 0x-prefixed People identity account. */
+ peerIdentity: string;
+ /** Verified roster name, absent when authorized products disagree. */
+ username?: string;
+}
+
+/** Current signing wallet's authenticated native Chat contacts, restored from storage. */
+export interface NativeChatContactsSnapshot {
+ /** Canonical lowercase 0x-prefixed root wallet public key. */
+ walletPublicKey: string;
+ /** Canonical lowercase 0x-prefixed People chain genesis hash. */
+ genesisHash: string;
+ /** Deterministically ordered, deduplicated ready peers. */
+ contacts: NativeChatContact[];
+}
+
/**
* Async-or-sync return. Synchronous hosts (e.g. the dotli main-thread
* shell hitting localStorage) can return a plain value; the WASM bridge
@@ -39,15 +55,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 9aee31201..76a69d38e 100644
--- a/js/packages/truapi-host/src/test-support.ts
+++ b/js/packages/truapi-host/src/test-support.ts
@@ -1,11 +1,17 @@
import type { RequiredHostCallbacks } from "./generated/host-callbacks.js";
+import {
+ unavailableHopProvider,
+ unavailableNativeChatFilesHost,
+} from "./adapter-support.js";
import { localizeTimestamps } from "./locale.js";
/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */
export type CompleteHostCallbacks = RequiredHostCallbacks;
type HostCallbackOverrides = {
- [K in keyof RequiredHostCallbacks]?: Partial;
+ [K in keyof RequiredHostCallbacks]?: K extends "coinageWallet"
+ ? RequiredHostCallbacks[K]
+ : Partial;
};
/** Default no-op host callbacks with optional per-test overrides. */
@@ -96,6 +102,20 @@ export function makeHostCallbacks(
theme: { ...defaults.theme, ...overrides.theme },
locale: { ...defaults.locale, ...overrides.locale },
chain: { ...defaults.chain, ...overrides.chain },
+ ...(overrides.hop
+ ? { hop: { ...unavailableHopProvider, ...overrides.hop } }
+ : {}),
+ ...(overrides.coinageWallet === undefined
+ ? {}
+ : { coinageWallet: overrides.coinageWallet }),
+ ...(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
@@ -130,6 +150,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 1267c53ca..c7c8b3c53 100644
--- a/js/packages/truapi-host/src/wasm-module.ts
+++ b/js/packages/truapi-host/src/wasm-module.ts
@@ -6,6 +6,7 @@
import type { PermissionAuthorizationRuntime } from "./worker-permission-authorization.js";
import type { LocalIdentity } from "./worker-protocol.js";
import type { WalletAllowanceSnapshot } from "./wallet-allowances.js";
+import type { NativeChatContactsSnapshot } from "./runtime.js";
/** Cancellable handle on one live render stream inside the core. */
export interface WorkerRendererSubscription {
@@ -78,6 +79,7 @@ export interface WorkerHostRuntime extends PermissionAuthorizationRuntime {
/** The long-lived pairing-host runtime product cores are created from. */
export interface WorkerPairingHostRuntime extends WorkerHostRuntime {
+ getNativeChatContacts(): Promise;
cancelPairing(): void;
notifySessionStoreChanged(): void;
activateStoredSession(): Promise;
@@ -90,8 +92,8 @@ export interface WorkerPairingHostRuntime extends WorkerHostRuntime {
*
* A signing host owns the user's keys and establishes sessions from local
* entropy rather than by pairing with a wallet. It is present only in a core
- * built with `wasm-signing-host`; the production `web` bundle is built without
- * it, which is why the constructor is optional on {@link WasmModuleShape}.
+ * built with `wasm-signing-host` (including `web` built with `--signing-host`),
+ * which is why the constructor is optional on {@link WasmModuleShape}.
*/
export interface WorkerSigningHostRuntime extends WorkerHostRuntime {
activateLocalSession(secret: Uint8Array): Promise;
@@ -103,7 +105,7 @@ export interface WorkerSigningHostRuntime extends WorkerHostRuntime {
secret: Uint8Array,
liteUsername?: string,
): Promise;
- /** Only on a core built with `wasm-signing-host`. */
+ /** Only on a core built with the non-production `test-host` feature. */
setGrantAllowancesUnchecked?(granted: boolean): void;
localIdentityContext(): { activationId: string; identityAccountId: string };
localIdentityAuthProof(
@@ -120,6 +122,7 @@ export interface WorkerSigningHostRuntime extends WorkerHostRuntime {
activationId: string,
productIds: string[],
): Promise;
+ getNativeChatContacts(): Promise;
/** Only on a core built with `wasm-signing-host`. */
setWithheldResources?(tags: string[]): void;
}
@@ -131,7 +134,7 @@ export interface WasmModuleShape {
callbacks: unknown,
hostConfig: unknown,
) => WorkerPairingHostRuntime;
- /** Only in the `testing` bundle; see {@link WorkerSigningHostRuntime}. */
+ /** Only in bundles built with `wasm-signing-host`. */
WasmSigningHostRuntime?: new (
callbacks: unknown,
hostConfig: unknown,
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 5f94e4916..263dea78e 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
@@ -2,6 +2,7 @@ import type {
ChainConnection,
ProductRuntimeConfig,
LogLevel,
+ NativeChatContactsSnapshot,
PermissionAuthorizationRequest,
PermissionAuthorizationStatus,
ProductExecutionKind,
@@ -24,6 +25,7 @@ import {
RendererNode as RendererNodeCodec,
} from "@parity/truapi";
import {
+ NativeChatPickedFile,
PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec,
ProductContext as ProductContextCodec,
} from "../generated/host-callbacks.js";
@@ -39,9 +41,14 @@ import type {
SubscriptionName,
WorkerToMain,
} from "../worker-protocol.js";
-import { bytesToHex } from "@parity/truapi/scale";
+import {
+ COINAGE_WALLET_CALLBACKS,
+ 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";
import {
validateAllowanceProductIds,
type WalletAllowanceSnapshot,
@@ -54,6 +61,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 {
@@ -82,6 +91,8 @@ export interface WorkerPairingHostRuntime {
* removed or blocked, so a contact handle the core cached stops resolving.
*/
notifyContactsChanged(): void;
+ /** Host-only native roster. Pairing hosts reject as unsupported. */
+ getNativeChatContacts(): Promise;
/**
* Restore the session persisted in the core's `AuthSession` slot. Resolves
* once product frames may use it, so a host can await this at boot before
@@ -209,6 +220,11 @@ interface RenderEntry {
onError: (error: Error) => void;
}
+interface RpcConnectionEntry {
+ connection: ChainConnection | null;
+ closed: boolean;
+}
+
interface RuntimeState {
worker: Worker;
role: HostRole;
@@ -216,6 +232,10 @@ interface RuntimeState {
identityAccountId: string | null;
identityGeneration: number;
pendingAllowanceSnapshots: Map>;
+ pendingNativeChatContacts: Map<
+ number,
+ PendingEntry
+ >;
rawCallbacks: RawCallbacks;
coreCallbacks: Map;
cores: Map;
@@ -228,6 +248,8 @@ interface RuntimeState {
}
>;
subscriptionDisposers: Map void>;
+ chatFileExports: Set;
+ disposeNativeChatFiles: () => void;
/**
* Open `worker.beginOperation` holds. A non-empty set defers `dispose()`.
* Worker-wide rather than per-core: operation holds can outlive a product
@@ -246,7 +268,7 @@ interface RuntimeState {
disposeGraceTimer: ReturnType | undefined;
/** How long `dispose()` waits for open operations before forcing teardown. */
operationGraceMs: number;
- chainConnections: Map;
+ chainConnections: Map;
pendingDisconnects: Map<
number,
{ resolve: () => void; reject: (error: Error) => void }
@@ -334,6 +356,7 @@ let nextProductSubtreePublicKeyRequestId = 0;
let nextSessionActivationRequestId = 0;
let nextLocalIdentityRequestId = 0;
let nextAllowanceSnapshotRequestId = 0;
+let nextNativeChatContactsRequestId = 0;
let nextActionRequestId = 0;
let nextRenderId = 0;
@@ -550,12 +573,38 @@ interface TrUApiDevConsole {
getLogLevel(): LogLevel | null;
}
-/**
- * Key one pending-operation hold. `OperationId` is unique per product, not per
- * worker, so the product a `beginOperation`/`endOperation` arrived for has to be
- * part of the key. Returns null if the encoded product will not decode, which
- * drops the hold rather than letting it pin the worker forever.
- */
+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.
+ }
+}
+
/**
* Read the host-assigned id out of a `beginOperation` response. Returns null if
* the response will not decode, so a hold that cannot be keyed is dropped
@@ -595,8 +644,9 @@ function handleCallbackRequest(
},
): void {
if (state.disposed) return;
+ const hostWalletCallback = COINAGE_WALLET_CALLBACKS[msg.name] === true;
const callbacks =
- msg.coreId === undefined
+ msg.coreId === undefined || hostWalletCallback
? state.rawCallbacks
: state.coreCallbacks.get(msg.coreId);
const fn =
@@ -620,12 +670,20 @@ function handleCallbackRequest(
Promise.resolve()
.then(() => {
if (state.disposed) throw new Error("Host runtime is unavailable");
- if (msg.coreId !== undefined && !state.coreCallbacks.has(msg.coreId))
+ if (
+ !hostWalletCallback &&
+ msg.coreId !== undefined &&
+ !state.coreCallbacks.has(msg.coreId)
+ )
throw new Error("Product callbacks are unavailable");
return fn(...msg.args);
})
.then(
- (value) => {
+ async (value) => {
+ if (state.disposed) {
+ await discardChatFileCallback(state, msg.name, value);
+ return;
+ }
// Tracked in the success arm only: a rejected begin must not leave a
// hold that nothing will ever release.
if (msg.name === "beginOperation") {
@@ -643,20 +701,60 @@ function handleCallbackRequest(
teardown(state, new Error("runtime disposed"), false);
}
}
- state.worker.postMessage({
- kind: "callbackResponse",
- requestId: msg.requestId,
- ok: true,
- value,
- } satisfies MainToWorker);
+ 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: hostWalletCallback
+ ? "Native Coinage wallet operation failed"
+ : 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,
+ );
+ }
},
);
}
@@ -725,41 +823,81 @@ 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);
+ }
+ }
}
}
@@ -767,26 +905,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");
}
}
@@ -946,6 +1096,7 @@ function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void {
rejectAll(state.pendingSessionActivations, error);
rejectAll(state.pendingLocalIdentities, error);
rejectAll(state.pendingAllowanceSnapshots, error);
+ rejectAll(state.pendingNativeChatContacts, error);
rejectAll(state.pendingPermissionAuthorizationStatuses, error);
rejectAll(state.pendingPermissionAuthorizationStatusBatches, error);
rejectAll(state.pendingSetPermissionAuthorizationStatuses, error);
@@ -1047,6 +1198,10 @@ function invalidateAllowanceIdentity(state: RuntimeState): void {
state.pendingAllowanceSnapshots,
new Error("local identity activation changed"),
);
+ rejectAll(
+ state.pendingNativeChatContacts,
+ new Error("local identity activation changed"),
+ );
}
async function getWalletAllowanceSnapshot(
@@ -1113,6 +1268,49 @@ async function getWalletAllowanceSnapshot(
return promise;
}
+async function getNativeChatContacts(
+ state: RuntimeState,
+): Promise {
+ if (state.disposed || state.disposePending) {
+ throw state.closedError ?? new Error("runtime disposed");
+ }
+ if (
+ state.role !== "signing" ||
+ state.pendingSessionActivations.size > 0 ||
+ state.pendingDisconnects.size > 0
+ ) {
+ throw new Error(
+ "native Chat contacts require a current local signing session",
+ );
+ }
+ const generation = state.identityGeneration;
+ const requestId = ++nextNativeChatContactsRequestId;
+ const { promise, resolve, reject } =
+ Promise.withResolvers();
+ state.pendingNativeChatContacts.set(requestId, {
+ resolve(snapshot) {
+ if (generation !== state.identityGeneration || state.disposePending) {
+ reject(new Error("local identity activation changed"));
+ } else {
+ resolve(snapshot);
+ }
+ },
+ reject,
+ });
+ try {
+ state.worker.postMessage({
+ kind: "getNativeChatContacts",
+ requestId,
+ } satisfies MainToWorker);
+ } catch (error) {
+ settlePending(state.pendingNativeChatContacts, requestId, {
+ ok: false,
+ error: errorMessage(error),
+ });
+ }
+ return promise;
+}
+
function closeCoreState(core: CoreState, error: Error): void {
if (core.disposed) return;
core.disposed = true;
@@ -1148,14 +1346,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);
@@ -1250,7 +1454,13 @@ 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 = {
@@ -1263,6 +1473,7 @@ function createWebWorkerHostRuntime(
identityAccountId: null,
identityGeneration: 0,
pendingAllowanceSnapshots: new Map(),
+ pendingNativeChatContacts: new Map(),
rawCallbacks: callbacks,
coreCallbacks: new Map(),
cores: new Map(),
@@ -1273,6 +1484,8 @@ function createWebWorkerHostRuntime(
disposeGraceTimer: undefined,
operationGraceMs: options.operationGraceMs ?? 30_000,
chainConnections: new Map(),
+ chatFileExports: new Set(),
+ disposeNativeChatFiles: () => browserFiles?.dispose(),
pendingDisconnects: new Map(),
pendingSessionActivations: new Map(),
pendingLocalIdentities: new Map(),
@@ -1366,6 +1579,15 @@ function createWebWorkerHostRuntime(
: { ok: false, error: msg.error },
);
break;
+ case "nativeChatContactsResponse":
+ settlePending(
+ state.pendingNativeChatContacts,
+ msg.requestId,
+ msg.ok
+ ? { ok: true, value: msg.snapshot }
+ : { ok: false, error: msg.error },
+ );
+ break;
case "permissionAuthorizationStatusResponse":
handlePermissionAuthorizationStatusResponse(state, msg);
break;
@@ -1447,8 +1669,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;
@@ -1521,6 +1744,8 @@ function createWebWorkerHostRuntime(
chat: host.chat !== undefined,
permissionStatus: host.permissionStatus !== undefined,
pocket: host.pocket !== undefined,
+ identityBackend: host.identityBackend !== undefined,
+ coinageWallet: callbacks.nativeCoinage !== undefined,
contacts: host.contacts !== undefined,
},
debuggerUrl: debuggerDial,
@@ -1636,6 +1861,8 @@ 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,
@@ -1656,6 +1883,9 @@ function buildRuntime(
contacts: callbacks.contacts !== undefined,
permissionStatus: callbacks.permissionStatus !== undefined,
pocket: callbacks.pocket !== undefined,
+ identityBackend: callbacks.identityBackend !== undefined,
+ coinageWallet:
+ state.rawCallbacks.nativeCoinage !== undefined,
},
}),
} satisfies MainToWorker);
@@ -1739,6 +1969,10 @@ function buildRuntime(
} satisfies MainToWorker);
},
notifyContactsChanged(): void {
+ rejectAll(
+ state.pendingNativeChatContacts,
+ new Error("native Chat contacts changed"),
+ );
postUnlessDisposed(state, { kind: "notifyContactsChanged" });
},
acquireWorker(productId: string): void {
@@ -1827,6 +2061,9 @@ function buildRuntime(
getWalletAllowanceSnapshot(productIds): Promise {
return getWalletAllowanceSnapshot(state, productIds);
},
+ getNativeChatContacts(): Promise {
+ return getNativeChatContacts(state);
+ },
registerLocalLiteUsername(
baseUsername,
identityBackendBaseUrl,
@@ -2097,7 +2334,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,
diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts
index 7db4a1087..7df438b6a 100644
--- a/js/packages/truapi-host/src/web/index.ts
+++ b/js/packages/truapi-host/src/web/index.ts
@@ -17,6 +17,15 @@ export type {
LocalIdentity,
LocalIdentityProgress,
} from "../worker-protocol.js";
+export type {
+ NativeChatContact,
+ NativeChatContactsSnapshot,
+} from "../runtime.js";
+export { createBrowserNativeChatFilesHost } from "./native-chat-files.js";
+export type {
+ BrowserNativeChatFilesHost,
+ BrowserNativeChatFileSourceStore,
+} from "./native-chat-files.js";
export type {
AllowanceCollection,
AllowanceObservation,
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..9a850b86f
--- /dev/null
+++ b/js/packages/truapi-host/src/web/native-chat-files.ts
@@ -0,0 +1,573 @@
+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..c3b9bba8e
--- /dev/null
+++ b/js/packages/truapi-host/src/web/native-chat-media.test.ts
@@ -0,0 +1,291 @@
+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..8cb2b9877
--- /dev/null
+++ b/js/packages/truapi-host/src/web/native-chat-media.ts
@@ -0,0 +1,375 @@
+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 b008cd2c9..5b31655a1 100644
--- a/js/packages/truapi-host/src/web/worker-provider.test.ts
+++ b/js/packages/truapi-host/src/web/worker-provider.test.ts
@@ -15,21 +15,34 @@ import type {
} from "@parity/truapi";
import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.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,
+ NativeCoinageRequest,
+ NativeCoinageResponse,
ProductContext,
} 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 {
asWorker,
FakeWorker,
@@ -265,6 +278,8 @@ describe("createWebWorkerPairingHostRuntime", () => {
chat: false,
permissionStatus: false,
pocket: false,
+ identityBackend: false,
+ coinageWallet: false,
contacts: false,
},
// Null under `bun test`: the `import.meta.env.DEV` gate reads undefined,
@@ -440,6 +455,8 @@ describe("createWebWorkerPairingHostRuntime", () => {
chat: true,
permissionStatus: false,
pocket: false,
+ identityBackend: false,
+ coinageWallet: false,
contacts: false,
});
});
@@ -462,10 +479,266 @@ describe("createWebWorkerPairingHostRuntime", () => {
chat: false,
permissionStatus: false,
pocket: true,
+ identityBackend: false,
+ coinageWallet: false,
contacts: 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("does not register a product wallet when the runtime uses the Rust wallet", async () => {
+ const worker = new FakeWorker();
+ const runtimePromise = createWebWorkerSigningHostRuntime(
+ asWorker(worker),
+ makeHostCallbacks(),
+ {
+ 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 productWalletCalls = 0;
+ try {
+ const providerPromise = runtime.createProvider(
+ { productId: "chat.dot" },
+ makeHostCallbacks({
+ coinageWallet: {
+ nativeCoinage: async () => {
+ productWalletCalls++;
+ return { tag: "Done" };
+ },
+ },
+ }),
+ );
+ const productCapabilities = lastMessageOfKind(worker, "createCore")
+ .capabilities as OptionalCapabilities;
+ worker.emit({ kind: "coreReady", coreId: 1 });
+ await providerPromise;
+ const bridge = {
+ async callbackRequest() {
+ throw new Error("An absent wallet must not issue a callback");
+ },
+ } satisfies Pick;
+ for (const registration of [capabilities, productCapabilities]) {
+ const callbacks = createWorkerRawCallbacks(
+ bridge as WorkerCallbackBridge,
+ registration,
+ ) as unknown as RawCallbacks;
+ expect(callbacks.nativeCoinage).toBeUndefined();
+ }
+ // Even a stale or forged product-scoped request cannot invoke its wallet.
+ worker.emit({
+ kind: "callbackRequest",
+ requestId: 1,
+ coreId: 1,
+ name: "nativeCoinage",
+ args: [],
+ });
+ await settle();
+ expect(lastMessageOfKind(worker, "callbackResponse").ok).toBe(false);
+ expect(productWalletCalls).toBe(0);
+ } finally {
+ runtime.dispose();
+ }
+ });
+
+ it("keeps native wallet ownership and secrets on the runtime callback route", async () => {
+ const worker = new FakeWorker();
+ const runtimePromise = createWebWorkerSigningHostRuntime(
+ asWorker(worker),
+ makeHostCallbacks({
+ coinageWallet: {
+ nativeCoinage: async (request) => {
+ if (request.operation.tag === "Denomination")
+ return { tag: "Failed", value: { reason: "Unavailable" } };
+ throw new Error("private native memo bearer material");
+ },
+ },
+ }),
+ {
+ 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 productWalletCalls = 0;
+ const providerPromise = runtime.createProvider(
+ { productId: "chat.dot" },
+ makeHostCallbacks({
+ coinageWallet: {
+ nativeCoinage: async () => {
+ productWalletCalls++;
+ return { tag: "Done" };
+ },
+ },
+ }),
+ );
+ worker.emit({ kind: "coreReady", coreId: 1 });
+ await providerPromise;
+ const scope = {
+ rootPublicKey: new Uint8Array(32),
+ genesisHash: new Uint8Array(32),
+ };
+ try {
+ let coreId = 1;
+ const bridge = {
+ async callbackRequest(name, args) {
+ worker.emit({
+ kind: "callbackRequest",
+ requestId: 1,
+ coreId,
+ name,
+ args,
+ });
+ await settle();
+ const response = lastMessageOfKind(worker, "callbackResponse");
+ if (!response.ok) throw new Error(String(response.error));
+ return response.value;
+ },
+ } satisfies Pick;
+ const native = createWorkerRawCallbacks(
+ bridge as WorkerCallbackBridge,
+ capabilities,
+ ) as unknown as RawCallbacks;
+ await expect(
+ native.nativeCoinage!(
+ NativeCoinageRequest.enc({ scope, operation: { tag: "Reconcile" } }),
+ ),
+ ).rejects.toThrow();
+ worker.emit({
+ kind: "callbackRequest",
+ requestId: 2,
+ coreId: 1,
+ name: "nativeCoinage",
+ args: [
+ NativeCoinageRequest.enc({
+ scope,
+ operation: { tag: "Denomination" },
+ }),
+ ],
+ });
+ await settle();
+ expect(
+ NativeCoinageResponse.dec(
+ lastMessageOfKind(worker, "callbackResponse").value as Uint8Array,
+ ),
+ ).toEqual({ tag: "Failed", value: { reason: "Unavailable" } });
+ worker.emit({
+ kind: "callbackRequest",
+ requestId: 3,
+ name: "nativeCoinage",
+ args: [
+ NativeCoinageRequest.enc({ scope, operation: { tag: "Reconcile" } }),
+ ],
+ });
+ await settle();
+ const failure = lastMessageOfKind(worker, "callbackResponse");
+ expect(failure.ok).toBe(false);
+ expect(failure.error).not.toContain("bearer material");
+ // Recreating product execution callbacks after failure keeps the runtime owner.
+ const recreatedProvider = runtime.createProvider(
+ { productId: "chat.dot" },
+ makeHostCallbacks(),
+ );
+ const productCapabilities = lastMessageOfKind(worker, "createCore")
+ .capabilities as OptionalCapabilities;
+ coreId = 2;
+ worker.emit({ kind: "coreReady", coreId });
+ await recreatedProvider;
+ const recreated = createWorkerRawCallbacks(
+ bridge as WorkerCallbackBridge,
+ productCapabilities,
+ ) as unknown as RawCallbacks;
+ expect(
+ NativeCoinageResponse.dec(
+ await recreated.nativeCoinage!(
+ NativeCoinageRequest.enc({
+ scope,
+ operation: { tag: "Denomination" },
+ }),
+ ),
+ ),
+ ).toEqual({ tag: "Failed", value: { reason: "Unavailable" } });
+ expect(productWalletCalls).toBe(0);
+ } finally {
+ runtime.dispose();
+ }
+ });
+
it("creates multiple product cores on one worker runtime", async () => {
const worker = new FakeWorker();
const config = runtimeConfig();
@@ -832,6 +1105,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);
@@ -1072,6 +1358,367 @@ 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("posts notifyContactsChanged to the worker", async () => {
const worker = new FakeWorker();
const config = runtimeConfig();
@@ -2227,3 +2874,55 @@ describe("wallet allowance inspection isolation", () => {
runtime.dispose();
});
});
+
+describe("native Chat directory lifetime", () => {
+ it("refuses a pairing host instead of returning an empty directory", async () => {
+ const runtime = await readyRuntime(new FakeWorker());
+ await expect(runtime.getNativeChatContacts()).rejects.toThrow();
+ runtime.dispose();
+ });
+
+ it.each(["activation", "contacts", "dispose"] as const)(
+ "rejects a pending directory after %s invalidation, including a late reply",
+ async (change) => {
+ const worker = new FakeWorker();
+ const runtime = await readySigningRuntime(worker);
+ const directory = runtime.getNativeChatContacts();
+ const outcome = directory.catch((error: unknown) => error);
+ const requestId = lastMessageOfKind(
+ worker,
+ "getNativeChatContacts",
+ ).requestId;
+ let activation: Promise | undefined;
+ if (change === "activation") {
+ activation = runtime.activateLocalSession(new Uint8Array(32));
+ await expect(runtime.getNativeChatContacts()).rejects.toThrow();
+ } else if (change === "contacts") {
+ runtime.notifyContactsChanged();
+ } else {
+ runtime.dispose();
+ }
+ worker.emit({
+ kind: "nativeChatContactsResponse",
+ requestId,
+ ok: true,
+ snapshot: {
+ walletPublicKey: `0x${"11".repeat(32)}`,
+ genesisHash: `0x${"22".repeat(32)}`,
+ contacts: [{ peerIdentity: `0x${"33".repeat(32)}` }],
+ },
+ });
+ expect(await outcome).toBeInstanceOf(Error);
+ if (activation) {
+ worker.emit({
+ kind: "sessionActivationResponse",
+ requestId: lastMessageOfKind(worker, "activateLocalSession")
+ .requestId,
+ ok: true,
+ });
+ await activation;
+ }
+ runtime.dispose();
+ },
+ );
+});
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 c78efb9ca..603301c0d 100644
--- a/js/packages/truapi-host/src/worker-protocol.ts
+++ b/js/packages/truapi-host/src/worker-protocol.ts
@@ -30,7 +30,11 @@
// safe choice.
import type { OptionalCapabilities } from "./generated/worker-callbacks.js";
-import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js";
+import type {
+ LogLevel,
+ NativeChatContactsSnapshot,
+ PermissionAuthorizationStatus,
+} from "./runtime.js";
import type { WalletAllowanceSnapshot } from "./wallet-allowances.js";
import type {
CallbackName,
@@ -45,6 +49,16 @@ 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;
+
+/** Wallet custody belongs to the runtime, never a product-specific callback bundle. */
+export const COINAGE_WALLET_CALLBACKS: Readonly<
+ Partial>
+> = {
+ nativeCoinage: true,
+};
+
/**
* Positional arguments for a callback. The wasm core calls each callback
* at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple.
@@ -151,6 +165,7 @@ export type MainToWorker =
requestId: number;
productIds: string[];
}
+ | { kind: "getNativeChatContacts"; requestId: number }
| {
kind: "registerLocalLiteUsername";
requestId: number;
@@ -214,6 +229,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" };
/**
@@ -286,6 +302,18 @@ export type WorkerToMain =
ok: false;
error: string;
}
+ | {
+ kind: "nativeChatContactsResponse";
+ requestId: number;
+ ok: true;
+ snapshot: NativeChatContactsSnapshot;
+ }
+ | {
+ kind: "nativeChatContactsResponse";
+ requestId: number;
+ ok: false;
+ error: string;
+ }
| {
kind: "permissionAuthorizationStatusResponse";
requestId: number;
@@ -409,6 +437,12 @@ export type WorkerToMain =
}
| { 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 eaa862440..df1509c5d 100644
--- a/js/packages/truapi-host/src/worker-runtime.ts
+++ b/js/packages/truapi-host/src/worker-runtime.ts
@@ -10,6 +10,10 @@ import type {
SubscriptionName,
WorkerToMain,
} from "./worker-protocol.js";
+import {
+ COINAGE_WALLET_CALLBACKS,
+ MAX_JSON_RPC_CONNECTIONS,
+} from "./worker-protocol.js";
import type { GenericError } from "@parity/truapi";
import { TRUAPI_CODEC_VERSION } from "@parity/truapi";
import {
@@ -84,25 +88,34 @@ 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,
- ...(coreId === undefined ? {} : { coreId }),
- });
+ try {
+ postToMain({
+ kind: "callbackRequest",
+ requestId,
+ name,
+ args,
+ ...(coreId === undefined ? {} : { coreId }),
+ });
+ } catch {
+ pendingCallbacks.delete(requestId);
+ reject(new Error("Host callback transport is unavailable"));
+ }
});
}
@@ -113,20 +126,30 @@ function startSubscription(
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,
- ...(coreId === undefined ? {} : { coreId }),
- });
- 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 });
};
}
@@ -166,28 +189,102 @@ interface WorkerChainConnection {
function chainConnect(
genesisHash: string,
onResponse: (json: string) => void,
+ onClosed?: () => void,
): Promise {
- 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 });
- },
+ 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;
+ 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. */
@@ -198,10 +295,16 @@ function buildRawCallbacks(
return {
...createWorkerRawCallbacks(
{
- callbackRequest: (name, args) => callbackRequest(name, args, coreId),
+ callbackRequest: (name, args) =>
+ callbackRequest(
+ name,
+ args,
+ COINAGE_WALLET_CALLBACKS[name] ? undefined : coreId,
+ ),
startSubscription: (name, payload, sendItem, sendError) =>
startSubscription(name, payload, sendItem, sendError, coreId),
chainConnect,
+ hopConnect,
},
capabilities,
),
@@ -628,6 +731,45 @@ const identityOperations = new Set>();
let allowanceNetworkSuffix: string | null = null;
let allowanceGeneration = 0;
const allowanceOperations = new Set>();
+const nativeChatContactsOperations = new Set>();
+
+function handleNativeChatContacts(requestId: number): void {
+ const rt = runtime;
+ const generation = allowanceGeneration;
+ const operation = (async () => {
+ try {
+ if (!rt || !isSigningRuntime(rt)) {
+ throw new Error(
+ "native Chat contacts are unsupported on a pairing host",
+ );
+ }
+ const activation = rt.localIdentityContext().activationId;
+ const snapshot = await rt.getNativeChatContacts();
+ if (
+ runtime !== rt ||
+ generation !== allowanceGeneration ||
+ rt.localIdentityContext().activationId !== activation
+ ) {
+ throw new Error("local identity activation changed");
+ }
+ postToMain({
+ kind: "nativeChatContactsResponse",
+ requestId,
+ ok: true,
+ snapshot,
+ });
+ } catch (error) {
+ postToMain({
+ kind: "nativeChatContactsResponse",
+ requestId,
+ ok: false,
+ error: errorMessage(error),
+ });
+ }
+ })();
+ nativeChatContactsOperations.add(operation);
+ void operation.finally(() => nativeChatContactsOperations.delete(operation));
+}
function handleWalletAllowanceSnapshot(
requestId: number,
@@ -911,6 +1053,7 @@ ctx.addEventListener("message", (ev: MessageEvent) => {
break;
}
case "activateLocalSession": {
+ identityAbort?.abort(new Error("local identity activation changed"));
const { secret, liteUsername } = msg;
void handleSessionActivation(
msg.requestId,
@@ -1011,6 +1154,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => {
case "getWalletAllowanceSnapshot":
handleWalletAllowanceSnapshot(msg.requestId, msg.productIds);
break;
+ case "getNativeChatContacts":
+ handleNativeChatContacts(msg.requestId);
+ break;
case "getPermissionAuthorizationStatus":
void handleGetPermissionAuthorizationStatus(
runtime,
@@ -1074,6 +1220,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;
}
@@ -1086,6 +1234,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => {
);
break;
}
+ case "chainClosed":
+ closeRpcConnection(msg.connId);
+ break;
case "publishChatAction":
handlePublishAction(
CHAT_ACTION_ENTRY_POINT,
@@ -1130,12 +1281,22 @@ ctx.addEventListener("message", (ev: MessageEvent) => {
runtime = null;
allowanceGeneration++;
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))
await disposing.disconnectSession();
await Promise.allSettled(identityOperations);
await Promise.allSettled(allowanceOperations);
+ await Promise.allSettled(nativeChatContactsOperations);
await Promise.all(
[...cores.keys()].map((coreId) => disposeCore(coreId)),
);
diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts
index f63dee2c0..99f6d650e 100644
--- a/js/packages/truapi/src/client.test.ts
+++ b/js/packages/truapi/src/client.test.ts
@@ -1,5 +1,5 @@
import type { Result } from "neverthrow";
-import { describe, expect, it, jest } from "bun:test";
+import { describe, expect, it, jest, spyOn } from "bun:test";
import { createTransport, RequestTimeoutError } from "./client.js";
import * as S from "./scale.js";
@@ -131,17 +131,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,
@@ -176,9 +169,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]),
);
}
@@ -224,11 +215,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]));
}
@@ -268,23 +255,68 @@ describe("generated client transport", () => {
expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame));
});
+ it("keeps V2 methods callable with V1 domain errors", async () => {
+ const fixture = providerFixture();
+ const client = createClient(createTransport(fixture.provider));
+ const ids = { trait: 2, method: 12 };
+ const responseCodec = S.Result(
+ T.VersionedHostProductDeviceChatResponse,
+ S.CallError(T.VersionedHostProductDeviceChatError),
+ );
+ const denied = client.account.deviceChat({ tag: "PaymentDenomination" });
+ expect(fixture.sent[0]).toEqual(
+ wireFrame("p:1", ids, MESSAGE_TYPE_REQUEST, new Uint8Array([1, 12])),
+ );
+ const reason = { tag: "V1", value: "StorageUnavailable" } as const;
+ fixture.receive(
+ wireFrame(
+ "p:1",
+ ids,
+ MESSAGE_TYPE_RESPONSE,
+ responseCodec.enc({
+ success: false,
+ value: { tag: "Domain", value: reason },
+ }),
+ ),
+ );
+ expect((await denied)._unsafeUnwrapErr()).toEqual({ tag: "Domain", value: reason });
+
+ void client.system.handshake();
+
+ const expectedPayload = T.VersionedHostHandshakeRequest.enc({
+ tag: "V1",
+ value: { codecVersion: TRUAPI_CODEC_VERSION },
+ });
+ // Second request on this client, so the handshake carries id `p:2`.
+ const expectedFrame = new Uint8Array(str.enc("p:2").length + 3 + expectedPayload.length);
+ expectedFrame.set(str.enc("p:2"), 0);
+ expectedFrame[str.enc("p:2").length] = 1; // system trait
+ expectedFrame[str.enc("p:2").length + 1] = 0; // handshake
+ expectedFrame[str.enc("p:2").length + 2] = MESSAGE_TYPE_REQUEST;
+ expectedFrame.set(expectedPayload, str.enc("p:2").length + 3);
+
+ expect(toHex(fixture.sent[fixture.sent.length - 1])).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);
+ expect(TRUAPI_CODEC_VERSION).toBe(3);
void client.system.handshake();
const expectedPayload = T.VersionedHostHandshakeRequest.enc({
tag: "V1",
- value: { codecVersion: TRUAPI_CODEC_VERSION },
+ value: { codecVersion: 3 },
});
- 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);
+ const requestId = str.enc("p:1");
+ const expectedFrame = new Uint8Array(requestId.length + 3 + expectedPayload.length);
+ expectedFrame.set(requestId, 0);
+ expectedFrame[requestId.length] = 1; // system trait
+ expectedFrame[requestId.length + 1] = 0; // handshake
+ expectedFrame[requestId.length + 2] = MESSAGE_TYPE_REQUEST;
+ expectedFrame.set(expectedPayload, requestId.length + 3);
expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame));
});
@@ -565,13 +597,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)),
]);
});
@@ -633,27 +659,59 @@ describe("generated client transport", () => {
expect(subscriptionFixture.sent).toHaveLength(2);
});
- it("logs a protocol violation for a known pair's out-of-range message type", () => {
+ it("logs 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);
- };
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
fixture.receive(wireFrame("unrelated:1", W.LOCAL_STORAGE_READ, 99));
+ expect(
+ warn.mock.calls.some((args) =>
+ String(args[0]).includes("unexpected messageType 99"),
+ ),
+ ).toBe(true);
} finally {
- console.warn = originalWarn;
+ warn.mockRestore();
}
expect(fixture.sent).toHaveLength(0);
- expect(
- warnings.some((args) =>
- String(args[0]).includes("unexpected messageType 99"),
- ),
- ).toBe(true);
+ });
+
+ it("ignores a late response to a timed-out request but logs one never sent", async () => {
+ jest.useFakeTimers();
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const fixture = providerFixture();
+ const transport = createTransport(fixture.provider, { requestTimeoutMs: 25 });
+ const outcome = Promise.resolve(
+ transport.request>({
+ ids: { ...W.LOCAL_STORAGE_READ, kind: "request" },
+ payload: new Uint8Array(),
+ decodeResponse: () => ({ success: true, value: undefined }),
+ }),
+ );
+ jest.advanceTimersByTime(26);
+ await expect(outcome).rejects.toBeInstanceOf(RequestTimeoutError);
+
+ fixture.receive(wireFrame("p:1", W.LOCAL_STORAGE_READ, MESSAGE_TYPE_RESPONSE));
+ expect(warn).not.toHaveBeenCalled();
+
+ // Another transport on the same connection owns this id.
+ fixture.receive(wireFrame("host:3", W.LOCAL_STORAGE_READ, MESSAGE_TYPE_RESPONSE));
+ expect(warn).not.toHaveBeenCalled();
+
+ fixture.receive(wireFrame("p:1", W.LOCAL_STORAGE_READ, MESSAGE_TYPE_RESPONSE));
+ fixture.receive(wireFrame("p:99", W.LOCAL_STORAGE_READ, MESSAGE_TYPE_RESPONSE));
+ expect(
+ warn.mock.calls.filter((args) =>
+ String(args[0]).includes("no such request was sent"),
+ ),
+ ).toHaveLength(2);
+ } finally {
+ warn.mockRestore();
+ jest.useRealTimers();
+ }
});
it("auto-responds to an inbound handshake with the versioned-result shape", () => {
@@ -662,7 +720,7 @@ describe("generated client transport", () => {
const requestPayload = T.VersionedHostHandshakeRequest.enc({
tag: "V1",
- value: { codecVersion: TRUAPI_CODEC_VERSION },
+ value: { codecVersion: 3 },
});
const requestFrame = wireFrame(
"h:1",
@@ -846,9 +904,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 () => {
@@ -863,9 +921,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();
}
@@ -1129,7 +1185,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",
}),
@@ -1303,9 +1363,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);
@@ -1335,9 +1393,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/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts
index a51208ed4..2cdf10184 100644
--- a/js/packages/truapi/src/client.ts
+++ b/js/packages/truapi/src/client.ts
@@ -35,11 +35,12 @@ export type { Subscription, TrUApiTransport };
// Every method's request/response (or start/stop/interrupt/receive) frames
// share one (trait, method) address: which leg of the exchange a frame
// carries is the wire's own `messageType` byte, not part of the address. A
-// late or duplicate *answer*-leg frame (Response/Stop/Interrupt/Receive) for
-// a known method can legitimately arrive with no matching pending call or
-// subscription (e.g. after a request already timed out, or after
-// `unsubscribe`), so those are ignored rather than reported as a protocol
-// violation. A *request*-leg frame (Request/Start) with nothing to route to
+// late or duplicate *answer*-leg frame for a known method can legitimately
+// arrive with no matching pending call or subscription: a Stop/Interrupt/
+// Receive after `unsubscribe`, or a Response to a request that already timed
+// out. Those are ignored rather than reported as a protocol violation; a
+// Response to a request this side never sent is still reported. A
+// *request*-leg frame (Request/Start) with nothing to route to
// is never expected — it means this build genuinely doesn't implement the
// pair (no client was ever created, or the specific host-initiated method
// has no registration) — so it still earns the same reply as an unknown
@@ -231,6 +232,10 @@ export function createTransport(
detachAbort: () => void;
};
const pending = new Map();
+ // Sent requests abandoned at their deadline, oldest first. A response for
+ // one of these is a normal late answer; any other unmatched response is not.
+ const abandoned = new Set();
+ const MAX_ABANDONED = 256;
const subscriptions = new Map<
string,
{
@@ -563,14 +568,27 @@ export function createTransport(
}
if (KNOWN_WIRE_IDS.has(`${payload.traitId}:${payload.methodId}`)) {
+ if (payload.messageType === MESSAGE_TYPE_RESPONSE) {
+ // A late answer to a request this side abandoned at its deadline is
+ // normal, as is one for another transport sharing the connection or a
+ // pending request whose mismatch is reported above. A response to a
+ // request this transport never sent is not.
+ const ours = requestId.startsWith(requestIdPrefix);
+ if (ours && !pending.has(requestId) && !abandoned.delete(requestId)) {
+ reportProtocolViolation(
+ `ignoring response for request ${requestId} on (${payload.traitId}, ${payload.methodId}): no such request was sent`,
+ );
+ }
+ return;
+ }
if (
payload.messageType === MESSAGE_TYPE_STOP ||
payload.messageType === MESSAGE_TYPE_INTERRUPT ||
payload.messageType === MESSAGE_TYPE_RECEIVE
) {
- // A known method's answer-leg frame (Response/Stop/Interrupt/
- // Receive) with nothing to route to: a normal late/stale frame, not
- // a protocol violation.
+ // A known method's subscription-leg frame (Stop/Interrupt/Receive)
+ // with nothing to route to: a normal late/stale frame, not a
+ // protocol violation.
return;
}
if (payload.messageType !== MESSAGE_TYPE_REQUEST) {
@@ -802,7 +820,13 @@ export function createTransport(
// The host is told even though this side has stopped waiting: a
// deadline that only rejects locally is exactly the leak the
// `Cancel` leg exists to close.
- if (entry.sent) sendCancel(requestId, ids);
+ if (entry.sent) {
+ sendCancel(requestId, ids);
+ abandoned.add(requestId);
+ if (abandoned.size > MAX_ABANDONED) {
+ abandoned.delete(abandoned.values().next().value as string);
+ }
+ }
reject(
isHandshake
? new Error(
diff --git a/package-lock.json b/package-lock.json
index 35d273a5b..2e674ae19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -60,7 +60,7 @@
"js/packages/truapi-host": {
"name": "@parity/truapi-host",
"version": "0.23.0",
- "license": "MIT",
+ "license": "MIT AND AGPL-3.0-only",
"dependencies": {
"@noble/hashes": "^2.2.0",
"@parity/truapi": "^0.23.0",
diff --git a/playground/tests/e2e/login-modal.spec.ts b/playground/tests/e2e/login-modal.spec.ts
index 0b97fb124..6af343273 100644
--- a/playground/tests/e2e/login-modal.spec.ts
+++ b/playground/tests/e2e/login-modal.spec.ts
@@ -15,26 +15,9 @@ test.describe("login pairing modal", () => {
"dotli host UI; the CLI host has no login modal",
);
- test("stays open while pairing, cancels on close, reopens on retry", async ({
+ test("stays open while pairing, closes, and reopens on retry", async ({
page,
}) => {
- const subscribeSends: number[] = [];
- page.on("console", (msg) => {
- const text = msg.text();
- if (
- text.includes("chainSend") &&
- text.includes("statement_subscribeStatement")
- ) {
- subscribeSends.push(Date.now());
- }
- });
- await page.addInitScript(() => {
- try {
- localStorage.setItem("truapi:logLevel", "debug");
- } catch {
- /* storage unavailable */
- }
- });
const frame = await openPlaygroundInDotli(page);
await waitForOnline(frame);
@@ -49,17 +32,11 @@ test.describe("login pairing modal", () => {
await expect(page.locator("#auth-modal-backdrop.open")).toBeVisible();
await expect(page.locator("#auth-modal-qr canvas")).toBeVisible();
- // While pairing, the core polls the statement store with ~2s
- // snapshot queries.
- expect(subscribeSends.length).toBeGreaterThanOrEqual(2);
-
- // Closing the modal cancels the login in the core: polling stops.
+ // Dismissing the pairing flow must close the modal and keep it closed.
await page.locator("#auth-modal-close").click();
await expect(page.locator("#auth-modal-backdrop.open")).toBeHidden();
- await page.waitForTimeout(1_000); // grace for an in-flight tick
- const sendsAtCancel = subscribeSends.length;
- await page.waitForTimeout(6_000);
- expect(subscribeSends.length).toBe(sendsAtCancel);
+ await page.waitForTimeout(3_000);
+ await expect(page.locator("#auth-modal-backdrop.open")).toBeHidden();
// Retry opens a fresh pairing modal.
await openPairingModal(page);
diff --git a/rust/crates/truapi-chat-v2/Cargo.toml b/rust/crates/truapi-chat-v2/Cargo.toml
new file mode 100644
index 000000000..eb494078d
--- /dev/null
+++ b/rust/crates/truapi-chat-v2/Cargo.toml
@@ -0,0 +1,34 @@
+[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"]
+
+[lints]
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..72317d31a
--- /dev/null
+++ b/rust/crates/truapi-chat-v2/src/lib.rs
@@ -0,0 +1,3799 @@
+#![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.
+#[allow(clippy::too_many_arguments)]
+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