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://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/trinity-user-agents/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/trinity-user-agents/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-rustdoc-blue?style=flat-square)](https://paritytech.github.io/trinity-user-agents) [![Playground](https://img.shields.io/badge/playground-live-success?style=flat-square)](https://truapi-playground.paseo.li/) - ## Documentation - [TrUAPI reference](https://docs.polkadot.com/reference/apps/protocol/truapi/) - [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, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(17); + encode_bytes(out, statement_account_id)?; + encode_bytes(out, encryption_public_key) + }) +} + +/// Encode a v2 device-removed message (content index 18). +pub fn encode_device_removed_message( + message_id: &str, + timestamp: u64, + statement_account_id: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(18); + encode_bytes(out, statement_account_id) + }) +} + +/// Encode a v2 compacted-messages reference (content index 19). +pub fn encode_compacted_messages_message( + message_id: &str, + timestamp: u64, + claim_identifier: &[u8], + claim_ticket: &[u8], + node: &V2NodeEndpoint, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(19); + encode_bytes(out, claim_identifier)?; + encode_bytes(out, claim_ticket)?; + match node { + V2NodeEndpoint::WssUrl(url) => { + out.push(0); + encode_string(out, url) + } + } + }) +} + +/// Encode a v2 multi-device chat acceptance (wire content index 20). +pub fn encode_multi_chat_accepted_message( + message_id: &str, + timestamp: u64, + request_id: &str, + device: &V2PeerDevice, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(20); + encode_string(out, request_id)?; + out.extend_from_slice(&device.statement_account_id); + out.extend_from_slice(&device.encryption_public_key); + Ok(()) + }) +} + +/// Encode a transport-neutral call signal into the v2 chat message wire format. +pub fn encode_call_signal_message( + message_id: &str, + timestamp: u64, + signal: &V2CallSignal, +) -> Result, ChatError> { + match signal { + V2CallSignal::Offer { sdp, purpose, .. } => { + encode_data_channel_offer_message(message_id, timestamp, sdp, *purpose) + } + V2CallSignal::Answer { offer_id, sdp } => { + encode_data_channel_answer_message(message_id, timestamp, offer_id, sdp) + } + V2CallSignal::Candidates { offer_id, sdp } => { + encode_data_channel_candidates_message(message_id, timestamp, offer_id, sdp) + } + V2CallSignal::Closed { offer_id } => { + encode_data_channel_closed_message(message_id, timestamp, offer_id) + } + } +} + +/// Encode a v2 data-channel-offer message statement payload. +pub fn encode_data_channel_offer_message( + message_id: &str, + timestamp: u64, + sdp: &[u8], + purpose: V2DataChannelPurpose, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(8); + encode_bytes(out, sdp)?; + out.push(match purpose { + V2DataChannelPurpose::Audio => 0, + V2DataChannelPurpose::Video => 1, + }); + Ok(()) + }) +} + +/// Encode a v2 data-channel-answer message statement payload. +pub fn encode_data_channel_answer_message( + message_id: &str, + timestamp: u64, + offer_id: &str, + sdp: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(9); + encode_string(out, offer_id)?; + encode_bytes(out, sdp) + }) +} + +/// Encode a v2 data-channel-candidates message statement payload. +pub fn encode_data_channel_candidates_message( + message_id: &str, + timestamp: u64, + offer_id: &str, + sdp: &[u8], +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(10); + encode_string(out, offer_id)?; + encode_bytes(out, sdp) + }) +} + +/// Encode a v2 data-channel-closed message statement payload. +pub fn encode_data_channel_closed_message( + message_id: &str, + timestamp: u64, + offer_id: &str, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(11); + encode_string(out, offer_id) + }) +} + +/// Decode a v2 chat message statement payload. +/// +/// The decoder preserves `message_id` and `timestamp` for unsupported content +/// variants so host apps can store/display an unsupported placeholder. +pub fn decode_message(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Ok(V2ChatMessage { + message_id, + timestamp, + content: V2ChatMessageContent::UnsupportedVersion { version_index }, + }); + } + + let content_index = cursor.read_u8("content_index")?; + let content = match content_index { + 0 => { + let text = cursor.read_string("text")?; + cursor.finish()?; + V2ChatMessageContent::Text(text) + } + 1 => { + let token = cursor.read_bytes("push_token")?; + let platform = decode_push_platform(cursor.read_u8("push_platform")?)?; + cursor.finish()?; + V2ChatMessageContent::Token { token, platform } + } + 2 => { + let amount = cursor.read_balance("amount")?; + let block_hash = cursor.read_exact(32, "block_hash")?.to_vec(); + let extrinsic_hash = cursor.read_exact(32, "extrinsic_hash")?.to_vec(); + cursor.finish()?; + V2ChatMessageContent::SendLegacy { + amount, + block_hash, + extrinsic_hash, + } + } + 3 => { + cursor.finish()?; + V2ChatMessageContent::ContactAdded + } + 4 => { + let message_id = cursor.read_string("referenced_message_id")?; + let emoji = cursor.read_string("emoji")?; + cursor.finish()?; + V2ChatMessageContent::Reacted { message_id, emoji } + } + 5 => { + let message_id = cursor.read_string("referenced_message_id")?; + let emoji = cursor.read_string("emoji")?; + cursor.finish()?; + V2ChatMessageContent::ReactionRemoved { message_id, emoji } + } + 7 => { + let message_id = cursor.read_string("referenced_message_id")?; + let (text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::Reply { + message_id, + text, + attachments, + } + } + 8 => { + let sdp = cursor.read_bytes("sdp")?; + let purpose = match cursor.read_u8("purpose")? { + 0 => V2DataChannelPurpose::Audio, + 1 => V2DataChannelPurpose::Video, + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported data channel purpose {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::DataChannelOffer { sdp, purpose } + } + 9 => { + let offer_id = cursor.read_string("offer_id")?; + let sdp = cursor.read_bytes("sdp")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelAnswer { offer_id, sdp } + } + 10 => { + let offer_id = cursor.read_string("offer_id")?; + let sdp = cursor.read_bytes("sdp")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelCandidates { offer_id, sdp } + } + 11 => { + let offer_id = cursor.read_string("offer_id")?; + cursor.finish()?; + V2ChatMessageContent::DataChannelClosed { offer_id } + } + 12 => { + let message_id = cursor.read_string("referenced_message_id")?; + let (new_text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::Edited { + message_id, + new_text, + attachments, + } + } + 13 => { + cursor.finish()?; + V2ChatMessageContent::LeftChat + } + 14 => { + let request_id = cursor.read_string("request_id")?; + cursor.finish()?; + V2ChatMessageContent::ChatAccepted { request_id } + } + 15 => { + let (text, attachments) = decode_rich_text(&mut cursor)?; + cursor.finish()?; + V2ChatMessageContent::RichText { text, attachments } + } + 16 => { + let total_value = cursor.read_balance("total_value")?; + let coin_keys = cursor.read_vec_of_bytes("coin_keys")?; + cursor.finish()?; + V2ChatMessageContent::CoinageSend { + total_value, + coin_keys, + } + } + 17 => { + let statement_account_id = cursor.read_bytes("statement_account_id")?; + let encryption_public_key = cursor.read_bytes("encryption_public_key")?; + cursor.finish()?; + V2ChatMessageContent::DeviceAdded { + statement_account_id, + encryption_public_key, + } + } + 18 => { + let statement_account_id = cursor.read_bytes("statement_account_id")?; + cursor.finish()?; + V2ChatMessageContent::DeviceRemoved { + statement_account_id, + } + } + 19 => { + let claim_identifier = cursor.read_bytes("claim_identifier")?; + let claim_ticket = cursor.read_bytes("claim_ticket")?; + let node = match cursor.read_u8("node_endpoint")? { + 0 => V2NodeEndpoint::WssUrl(cursor.read_string("wss_url")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported node endpoint {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::CompactedMessages { + claim_identifier, + claim_ticket, + node, + } + } + 20 => { + let request_id = cursor.read_string("request_id")?; + let statement_account_id = cursor.read_array_32("device_statement_account_id")?; + let encryption_public_key = cursor.read_array_32("device_encryption_public_key")?; + cursor.finish()?; + V2ChatMessageContent::MultiChatAccepted { + request_id, + device: V2PeerDevice { + statement_account_id, + encryption_public_key, + }, + } + } + index => V2ChatMessageContent::UnsupportedContent { + content_index: index, + }, + }; + + Ok(V2ChatMessage { + message_id, + timestamp, + content, + }) +} + +/// Encode a first-contact chat request message. +pub fn encode_chat_request_message( + message_id: &str, + timestamp: u64, + push_token: Option<&V2PushToken>, + welcome_text: Option<&str>, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, message_id)?; + out.extend_from_slice(×tamp.to_le_bytes()); + out.push(0); // VersionedRequestContent::V1 + + encode_optional_push_token(&mut out, push_token)?; + encode_optional_rich_text(&mut out, welcome_text)?; + + Ok(out) +} + +/// Decode a first-contact chat request message. +pub fn decode_chat_request_message(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 chat request version index {version_index}" + ))); + } + + let push_token = decode_optional_push_token(&mut cursor)?; + let welcome_text = decode_optional_rich_text(&mut cursor)?; + cursor.finish()?; + + Ok(V2ChatRequestMessage { + message_id, + timestamp, + push_token, + welcome_text, + }) +} + +/// Encode the proof payload that the requester signs for a first-contact chat request. +pub fn encode_chat_request_proof_payload( + message: &V2ChatRequestMessage, + acceptor_account_id: &[u8; 32], +) -> Result, ChatError> { + let mut out = encode_chat_request_message( + &message.message_id, + message.timestamp, + message.push_token.as_ref(), + message.welcome_text.as_deref(), + )?; + encode_bytes(&mut out, acceptor_account_id)?; + Ok(out) +} + +/// Encode a decrypted first-contact chat request payload. +pub fn encode_chat_request(request: &V2ChatRequest) -> Result, ChatError> { + if request.proof.signature.len() != 64 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signature must be 64 bytes, got {}", + request.proof.signature.len() + ))); + } + if request.proof.signer.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signer must be 32 bytes, got {}", + request.proof.signer.len() + ))); + } + + let mut out = encode_chat_request_message( + &request.message.message_id, + request.message.timestamp, + request.message.push_token.as_ref(), + request.message.welcome_text.as_deref(), + )?; + out.push(0); // StatementProof::sr25519 + out.extend_from_slice(&request.proof.signature); + out.extend_from_slice(&request.proof.signer); + Ok(out) +} +/// Encode a first-contact request carrying current multi-device content V2. +pub fn encode_chat_request_message_v2( + message: &V2ChatRequestMessageV2, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, &message.message_id)?; + out.extend_from_slice(&message.timestamp.to_le_bytes()); + out.push(1); + out.extend_from_slice(&message.content.identity_proof.identity_account_id); + out.extend_from_slice(&message.content.identity_proof.proof); + out.extend_from_slice(&message.content.device_enc_pub_key); + encode_optional_push_token(&mut out, message.content.push_token.as_ref())?; + encode_optional_rich_text(&mut out, message.content.welcome_text.as_deref())?; + Ok(out) +} + +/// Decode a first-contact request carrying current multi-device content V2. +pub fn decode_chat_request_message_v2(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_v2_cursor(&mut cursor)?; + cursor.finish()?; + Ok(message) +} + +/// Encode the sr25519 proof payload for request content V2. +pub fn encode_chat_request_v2_proof_payload( + message: &V2ChatRequestMessageV2, + acceptor_account_id: &[u8; 32], +) -> Result, ChatError> { + let mut out = encode_chat_request_message_v2(message)?; + encode_bytes(&mut out, acceptor_account_id)?; + Ok(out) +} + +/// Encode a decrypted first-contact request carrying request content V2. +pub fn encode_chat_request_v2(request: &V2ChatRequestV2) -> Result, ChatError> { + validate_chat_request_proof(&request.proof)?; + let mut out = encode_chat_request_message_v2(&request.message)?; + out.push(0); // StatementProof::sr25519 + out.extend_from_slice(&request.proof.signature); + out.extend_from_slice(&request.proof.signer); + Ok(out) +} + +/// Decode a decrypted first-contact request carrying request content V2. +pub fn decode_chat_request_v2(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_v2_cursor(&mut cursor)?; + let proof = decode_chat_request_proof(&mut cursor)?; + cursor.finish()?; + Ok(V2ChatRequestV2 { message, proof }) +} + +/// Decode a decrypted first-contact chat request payload. +pub fn decode_chat_request(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let message = decode_chat_request_message_cursor(&mut cursor)?; + let proof_index = cursor.read_u8("proof_index")?; + if proof_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported chat request proof index {proof_index}" + ))); + } + let signature = cursor.read_exact(64, "proof_signature")?.to_vec(); + let signer = cursor.read_exact(32, "proof_signer")?.to_vec(); + cursor.finish()?; + + Ok(V2ChatRequest { + message, + proof: V2ChatRequestProof { signature, signer }, + }) +} + +/// Encode the encrypted outer wrapper for a first-contact chat request. +pub fn encode_encrypted_chat_request( + request: &V2EncryptedChatRequest, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_bytes(&mut out, &request.encryption_pubkey)?; + encode_bytes(&mut out, &request.encrypted_request)?; + Ok(out) +} + +/// Decode the encrypted outer wrapper for a first-contact chat request. +pub fn decode_encrypted_chat_request(data: &[u8]) -> Result { + let mut cursor = Cursor::new(data); + let encryption_pubkey = cursor.read_bytes("encryption_pubkey")?; + let encrypted_request = cursor.read_bytes("encrypted_request")?; + cursor.finish()?; + Ok(V2EncryptedChatRequest { + encryption_pubkey, + encrypted_request, + }) +} + +/// Encode and seal request content V2 with a caller-provided fresh ephemeral +/// private key and unique nonce. +/// +/// The output is `SCALE Data(ephemeral_public_key) || SCALE +/// Data(nonce || ciphertext || tag)`. +pub fn seal_chat_request_v2_with_nonce( + ephemeral_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + request: &V2ChatRequestV2, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_chat_request_v2(request)?; + let key = x25519_hkdf_sha256_key(ephemeral_private_key, peer_public_key)?; + let encrypted_request = chacha20poly1305_encrypt_with_nonce(&key, &plaintext, nonce)?; + encode_encrypted_chat_request(&V2EncryptedChatRequest { + encryption_pubkey: x25519_public_key(ephemeral_private_key).to_vec(), + encrypted_request, + }) +} + +/// Encode and seal request content V2 with OS-generated ephemeral key material +/// and nonce. +#[cfg(feature = "std")] +pub fn seal_chat_request_v2( + peer_public_key: &[u8; 32], + request: &V2ChatRequestV2, +) -> Result, ChatError> { + let ephemeral_private_key = random_bytes_32()?; + seal_chat_request_v2_with_nonce( + &ephemeral_private_key, + peer_public_key, + request, + random_nonce()?, + ) +} + +/// Open and decode a request-content-V2 first-contact wrapper. +pub fn open_chat_request_v2( + static_private_key: &[u8; 32], + encoded_wrapper: &[u8], +) -> Result { + let wrapper = decode_encrypted_chat_request(encoded_wrapper)?; + let ephemeral_public_key: [u8; 32] = + wrapper + .encryption_pubkey + .try_into() + .map_err(|key: Vec| { + ChatError::InvalidEncoding(format!( + "first-contact ephemeral public key must be 32 bytes, got {}", + key.len() + )) + })?; + let key = x25519_hkdf_sha256_key(static_private_key, &ephemeral_public_key)?; + let plaintext = chacha20poly1305_decrypt(&key, &wrapper.encrypted_request)?; + decode_chat_request_v2(&plaintext) +} +/// Seal a first-contact request with explicit product, account, route, and +/// direction binding. The clear header is authenticated as AEAD context. +#[allow(clippy::too_many_arguments)] +pub fn seal_context_bound_chat_request_v2_with_nonce( + ephemeral_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + product_id: &str, + sender_account_id: &[u8; 32], + recipient_account_id: &[u8; 32], + channel_id: &[u8; 32], + request: &V2ChatRequestV2, + nonce: [u8; 12], +) -> Result, ChatError> { + if request.message.content.identity_proof.identity_account_id != *sender_account_id { + return Err(ChatError::InvalidEncoding( + "context-bound invite sender does not match its identity proof".into(), + )); + } + let ephemeral_public_key = x25519_public_key(ephemeral_private_key); + let encrypted = x25519_context_bound_encrypt_with_nonce( + ephemeral_private_key, + peer_public_key, + product_id, + sender_account_id, + recipient_account_id, + channel_id, + &encode_chat_request_v2(request)?, + nonce, + )?; + let mut out = Vec::with_capacity(CONTEXT_BOUND_INVITE_MAGIC.len() + 128 + encrypted.len()); + out.extend_from_slice(CONTEXT_BOUND_INVITE_MAGIC); + out.extend_from_slice(sender_account_id); + out.extend_from_slice(recipient_account_id); + out.extend_from_slice(channel_id); + out.extend_from_slice(&ephemeral_public_key); + out.extend_from_slice(&encrypted); + Ok(out) +} + +/// Clear first-contact header. Fields remain untrusted until AEAD opening and +/// verification of the decrypted request identity and proof. +pub struct V2ContextBoundChatRequest<'a> { + pub sender_account_id: [u8; 32], + pub recipient_account_id: [u8; 32], + pub channel_id: [u8; 32], + pub ephemeral_public_key: [u8; 32], + pub encrypted_request: &'a [u8], +} + +/// Recognize the context-bound format family, including unsupported versions. +/// Invalid members of this family must never be retried as legacy invites. +pub fn is_context_bound_chat_request_v2(encoded_wrapper: &[u8]) -> bool { + encoded_wrapper.starts_with(&CONTEXT_BOUND_INVITE_MAGIC[..5]) +} + +/// Decode and check the clear header without accessing identity secrets. +/// The caller must authenticate ciphertext and verify the decrypted sender. +pub fn decode_context_bound_chat_request_v2<'a>( + expected_recipient_account_id: &[u8; 32], + expected_channel_id: &[u8; 32], + encoded_wrapper: &'a [u8], +) -> Result, ChatError> { + const HEADER_LEN: usize = 8 + 32 + 32 + 32 + 32; + if encoded_wrapper.len() < HEADER_LEN + 28 + || &encoded_wrapper[..CONTEXT_BOUND_INVITE_MAGIC.len()] != CONTEXT_BOUND_INVITE_MAGIC + { + return Err(ChatError::InvalidEncoding( + "invalid context-bound invite header".into(), + )); + } + let sender_account_id: [u8; 32] = encoded_wrapper[8..40] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite sender".into()))?; + let recipient_account_id: [u8; 32] = encoded_wrapper[40..72] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite recipient".into()))?; + let channel_id: [u8; 32] = encoded_wrapper[72..104] + .try_into() + .map_err(|_| ChatError::InvalidEncoding("invalid context-bound invite route".into()))?; + let ephemeral_public_key: [u8; 32] = + encoded_wrapper[104..HEADER_LEN].try_into().map_err(|_| { + ChatError::InvalidEncoding("invalid context-bound invite ephemeral key".into()) + })?; + if recipient_account_id != *expected_recipient_account_id || channel_id != *expected_channel_id + { + return Err(ChatError::InvalidEncoding( + "context-bound invite recipient or route mismatch".into(), + )); + } + Ok(V2ContextBoundChatRequest { + sender_account_id, + recipient_account_id, + channel_id, + ephemeral_public_key, + encrypted_request: &encoded_wrapper[HEADER_LEN..], + }) +} + +/// Open a context-bound first-contact request without legacy fallback. +pub fn open_context_bound_chat_request_v2( + static_private_key: &[u8; 32], + product_id: &str, + expected_recipient_account_id: &[u8; 32], + expected_channel_id: &[u8; 32], + encoded_wrapper: &[u8], +) -> Result { + let V2ContextBoundChatRequest { + sender_account_id, + recipient_account_id, + channel_id, + ephemeral_public_key, + encrypted_request, + } = decode_context_bound_chat_request_v2( + expected_recipient_account_id, + expected_channel_id, + encoded_wrapper, + )?; + let plaintext = x25519_context_bound_decrypt( + static_private_key, + &ephemeral_public_key, + product_id, + &sender_account_id, + &recipient_account_id, + &channel_id, + encrypted_request, + )?; + let request = decode_chat_request_v2(&plaintext)?; + if request.message.content.identity_proof.identity_account_id != sender_account_id { + return Err(ChatError::InvalidEncoding( + "context-bound invite identity proof does not match sender".into(), + )); + } + Ok(request) +} + +/// Encode the plaintext StatementData::request payload without applying AEAD. +pub fn encode_transport_request_plaintext( + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(0); + encode_string(&mut plaintext, request_id)?; + encode_vec_of_bytes(&mut plaintext, messages)?; + Ok(plaintext) +} + +/// Encode the plaintext StatementData::response payload without applying AEAD. +pub fn encode_transport_response_plaintext( + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(1); + encode_string(&mut plaintext, request_id)?; + plaintext.push(response_code); + Ok(plaintext) +} + +/// Encode bare MessageExchange::Request plaintext for the encryptedRequest +/// field of StatementData::MultiRequest. +pub fn encode_message_exchange_request_plaintext( + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + encode_string(&mut plaintext, request_id)?; + encode_vec_of_bytes(&mut plaintext, messages)?; + Ok(plaintext) +} + +/// Decode bare MessageExchange::Request plaintext. +pub fn decode_message_exchange_request_plaintext( + plaintext: &[u8], +) -> Result { + let mut cursor = Cursor::new(plaintext); + let request_id = cursor.read_string("request_id")?; + let messages = cursor.read_vec_of_bytes("messages")?; + cursor.finish()?; + Ok(V2MessageExchangeRequest { + request_id, + messages, + }) +} + +/// Encode bare MessageExchange::Response plaintext for the encryptedResponse +/// field of StatementData::MultiResponse. +pub fn encode_message_exchange_response_plaintext( + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + encode_string(&mut plaintext, request_id)?; + plaintext.push(response_code); + Ok(plaintext) +} + +/// Decode bare MessageExchange::Response plaintext. +pub fn decode_message_exchange_response_plaintext( + plaintext: &[u8], +) -> Result { + let mut cursor = Cursor::new(plaintext); + let request_id = cursor.read_string("request_id")?; + let response_code = cursor.read_u8("response_code")?; + cursor.finish()?; + Ok(V2MessageExchangeResponse { + request_id, + response_code, + }) +} + +/// Encode the plaintext StatementData::multirequest payload without applying AEAD. +pub fn encode_transport_multi_request_plaintext( + request: &V2MultiDeviceRequest, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(2); + encode_bytes(&mut plaintext, &request.encrypted_request)?; + encode_request_device_infos(&mut plaintext, &request.devices_info)?; + Ok(plaintext) +} + +/// Encode the plaintext StatementData::multiresponse payload without applying AEAD. +pub fn encode_transport_multi_response_plaintext( + response: &V2MultiDeviceResponse, +) -> Result, ChatError> { + let mut plaintext = Vec::new(); + plaintext.push(3); + encode_bytes(&mut plaintext, &response.encrypted_response)?; + encode_request_device_infos(&mut plaintext, &response.devices_info)?; + Ok(plaintext) +} + +/// Encode and encrypt an ongoing v2 statement-store request payload with OS +/// randomness. Guests must use [`encode_transport_request_with_nonce`]. +#[cfg(feature = "std")] +pub fn encode_transport_request( + aead_key: &[u8; 32], + request_id: &str, + messages: &[Vec], +) -> Result, ChatError> { + encode_transport_request_with_nonce(aead_key, request_id, messages, random_nonce()?) +} + +/// Encode and encrypt a request with a caller-supplied unique nonce. +pub fn encode_transport_request_with_nonce( + aead_key: &[u8; 32], + request_id: &str, + messages: &[Vec], + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_request_plaintext(request_id, messages)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt an ongoing v2 response with OS randomness. Guests must +/// use [`encode_transport_response_with_nonce`]. +#[cfg(feature = "std")] +pub fn encode_transport_response( + aead_key: &[u8; 32], + request_id: &str, + response_code: u8, +) -> Result, ChatError> { + encode_transport_response_with_nonce(aead_key, request_id, response_code, random_nonce()?) +} + +/// Encode and encrypt a response with a caller-supplied unique nonce. +pub fn encode_transport_response_with_nonce( + aead_key: &[u8; 32], + request_id: &str, + response_code: u8, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_response_plaintext(request_id, response_code)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt StatementData::multirequest with OS randomness. +#[cfg(feature = "std")] +pub fn encode_transport_multi_request( + aead_key: &[u8; 32], + request: &V2MultiDeviceRequest, +) -> Result, ChatError> { + encode_transport_multi_request_with_nonce(aead_key, request, random_nonce()?) +} + +/// Encode and encrypt StatementData::multiresponse with OS randomness. +#[cfg(feature = "std")] +pub fn encode_transport_multi_response( + aead_key: &[u8; 32], + response: &V2MultiDeviceResponse, +) -> Result, ChatError> { + encode_transport_multi_response_with_nonce(aead_key, response, random_nonce()?) +} + +/// Wrap a one-shot key with an OS-random nonce. +#[cfg(feature = "std")] +pub fn wrap_multi_device_key( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + one_shot_key: &[u8; 32], +) -> Result, ChatError> { + wrap_multi_device_key_with_nonce( + own_private_key, + peer_public_key, + one_shot_key, + random_nonce()?, + ) +} + +/// Encrypt an inner multi-device payload with an OS-random nonce. +#[cfg(feature = "std")] +pub fn encrypt_multi_device_payload( + one_shot_key: &[u8; 32], + plaintext: &[u8], +) -> Result, ChatError> { + encrypt_multi_device_payload_with_nonce(one_shot_key, plaintext, random_nonce()?) +} + +/// Encode and encrypt StatementData::multirequest with a caller-supplied nonce. +pub fn encode_transport_multi_request_with_nonce( + aead_key: &[u8; 32], + request: &V2MultiDeviceRequest, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_multi_request_plaintext(request)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encode and encrypt StatementData::multiresponse with a caller-supplied nonce. +pub fn encode_transport_multi_response_with_nonce( + aead_key: &[u8; 32], + response: &V2MultiDeviceResponse, + nonce: [u8; 12], +) -> Result, ChatError> { + let plaintext = encode_transport_multi_response_plaintext(response)?; + chacha20poly1305_encrypt_with_nonce(aead_key, &plaintext, nonce) +} + +/// Encrypt a one-shot key for one recipient device using the X25519-derived +/// CryptoKit-compatible key and a caller-supplied unique nonce. +pub fn wrap_multi_device_key_with_nonce( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + one_shot_key: &[u8; 32], + nonce: [u8; 12], +) -> Result, ChatError> { + let wrapping_key = x25519_hkdf_sha256_key(own_private_key, peer_public_key)?; + chacha20poly1305_encrypt_with_nonce(&wrapping_key, one_shot_key, nonce) +} + +/// Unwrap a one-shot key received from a peer device. +pub fn unwrap_multi_device_key( + own_private_key: &[u8; 32], + peer_public_key: &[u8; 32], + encrypted_key: &[u8], +) -> Result<[u8; 32], ChatError> { + let wrapping_key = x25519_hkdf_sha256_key(own_private_key, peer_public_key)?; + let plaintext = chacha20poly1305_decrypt(&wrapping_key, encrypted_key)?; + plaintext.try_into().map_err(|plaintext: Vec| { + ChatError::InvalidEncoding(format!( + "unwrapped multi-device key must be 32 bytes, got {}", + plaintext.len() + )) + }) +} + +/// Encrypt an inner multi-device request/response using its one-shot key and a +/// caller-supplied unique nonce. +pub fn encrypt_multi_device_payload_with_nonce( + one_shot_key: &[u8; 32], + plaintext: &[u8], + nonce: [u8; 12], +) -> Result, ChatError> { + chacha20poly1305_encrypt_with_nonce(one_shot_key, plaintext, nonce) +} + +/// Decrypt an inner multi-device request/response. +pub fn decrypt_multi_device_payload( + one_shot_key: &[u8; 32], + ciphertext: &[u8], +) -> Result, ChatError> { + chacha20poly1305_decrypt(one_shot_key, ciphertext) +} + +/// Decode and decrypt an ongoing v2 statement-store transport payload. +pub fn decode_transport( + data: &[u8], + aead_key: &[u8; 32], +) -> Result { + let plaintext = match chacha20poly1305_decrypt(aead_key, data) { + Ok(plaintext) => plaintext, + Err(raw_error) => { + let mut outer = Cursor::new(data); + let encrypted = outer.read_bytes("encrypted_transport")?; + outer.finish()?; + chacha20poly1305_decrypt(aead_key, &encrypted).map_err(|_| raw_error)? + } + }; + decode_transport_plaintext(&plaintext) +} + +/// Decode plaintext StatementData after a Host has opened the identity route. +pub fn decode_transport_plaintext(plaintext: &[u8]) -> Result { + let mut cursor = Cursor::new(plaintext); + let kind = cursor.read_u8("transport_kind")?; + let decoded = match kind { + 0 => { + let request_id = cursor.read_string("request_id")?; + let messages = cursor.read_vec_of_bytes("messages")?; + cursor.finish()?; + V2StatementTransportData::Request { + request_id, + messages, + } + } + 1 => { + let request_id = cursor.read_string("request_id")?; + let response_code = cursor.read_u8("response_code")?; + cursor.finish()?; + V2StatementTransportData::Response { + request_id, + response_code, + } + } + 2 => { + let encrypted_request = cursor.read_bytes("encrypted_request")?; + let devices_info = cursor.read_request_device_infos("devices_info")?; + cursor.finish()?; + V2StatementTransportData::MultiRequest(V2MultiDeviceRequest { + encrypted_request, + devices_info, + }) + } + 3 => { + let encrypted_response = cursor.read_bytes("encrypted_response")?; + let devices_info = cursor.read_request_device_infos("devices_info")?; + cursor.finish()?; + V2StatementTransportData::MultiResponse(V2MultiDeviceResponse { + encrypted_response, + devices_info, + }) + } + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 transport kind {value}" + ))); + } + }; + Ok(decoded) +} + +fn encode_message( + message_id: &str, + timestamp: u64, + encode_content: impl FnOnce(&mut Vec) -> Result<(), ChatError>, +) -> Result, ChatError> { + let mut out = Vec::new(); + encode_string(&mut out, message_id)?; + out.extend_from_slice(×tamp.to_le_bytes()); + out.push(0); // VersionedChatMessage.V1 + encode_content(&mut out)?; + Ok(out) +} + +fn encode_string(out: &mut Vec, value: &str) -> Result<(), ChatError> { + let len = u32::try_from(value.len()).map_err(|_| { + ChatError::InvalidEncoding("string is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + out.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn encode_bytes(out: &mut Vec, value: &[u8]) -> Result<(), ChatError> { + let len = u32::try_from(value.len()).map_err(|_| { + ChatError::InvalidEncoding("byte array is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + out.extend_from_slice(value); + Ok(()) +} + +fn encode_balance(out: &mut Vec, value: &str) -> Result<(), ChatError> { + let value = value + .parse::() + .map_err(|_| ChatError::InvalidEncoding(format!("invalid balance value: {value}")))?; + out.extend_from_slice(&encode_compact_u128(value)); + Ok(()) +} + +fn encode_compact_u128(value: u128) -> Vec { + if value < 1 << 6 { + vec![(value as u8) << 2] + } else if value < 1 << 14 { + (((value as u16) << 2) | 0b01).to_le_bytes().to_vec() + } else if value < 1 << 30 { + (((value as u32) << 2) | 0b10).to_le_bytes().to_vec() + } else { + let mut bytes = value.to_le_bytes().to_vec(); + while bytes.last() == Some(&0) { + bytes.pop(); + } + let header = (((bytes.len() - 4) as u8) << 2) | 0b11; + let mut out = Vec::with_capacity(1 + bytes.len()); + out.push(header); + out.extend_from_slice(&bytes); + out + } +} + +fn push_platform_index(platform: V2PushPlatform) -> u8 { + match platform { + V2PushPlatform::Android => 0, + V2PushPlatform::Ios => 1, + V2PushPlatform::IosVoip => 2, + } +} + +fn decode_push_platform(value: u8) -> Result { + match value { + 0 => Ok(V2PushPlatform::Android), + 1 => Ok(V2PushPlatform::Ios), + 2 => Ok(V2PushPlatform::IosVoip), + value => Err(ChatError::InvalidEncoding(format!( + "unsupported push platform {value}" + ))), + } +} + +fn encode_rich_text( + out: &mut Vec, + text: Option<&str>, + attachments: Option<&[V2FileVariant]>, +) -> Result<(), ChatError> { + match text { + Some(t) => { + out.push(1); // text = Some + encode_string(out, t)?; + } + None => out.push(0), // text = None + } + match attachments { + None => out.push(0), + Some(files) => { + let count = u32::try_from(files.len()) + .map_err(|_| ChatError::InvalidEncoding("too many attachments".into()))?; + out.push(1); + out.extend_from_slice(&encode_compact_u32(count)); + for file in files { + encode_file(out, file)?; + } + } + } + Ok(()) +} + +fn validate_file_node(node: &V2NodeEndpoint) -> Result<(), ChatError> { + let V2NodeEndpoint::WssUrl(url) = node; + let invalid = || ChatError::InvalidEncoding("invalid attachment WSS endpoint".into()); + let (scheme, rest) = url.split_once("://").ok_or_else(invalid)?; + if !scheme.eq_ignore_ascii_case("wss") + || url + .bytes() + .any(|byte| byte <= b' ' || byte == 0x7f || byte == b'\\') + || rest.contains('#') + { + return Err(invalid()); + } + let authority = rest.split(['/', '?']).next().ok_or_else(invalid)?; + if authority.is_empty() || authority.contains('@') { + return Err(invalid()); + } + let port = if let Some(ipv6) = authority.strip_prefix('[') { + let (address, suffix) = ipv6.split_once(']').ok_or_else(invalid)?; + address + .parse::() + .map_err(|_| invalid())?; + if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':').ok_or_else(invalid)?) + } + } else { + let (host, port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = host.strip_suffix('.').unwrap_or(host); + if host.is_empty() + || host.len() > 253 + || !host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(invalid()); + } + if host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + host.parse::().map_err(|_| invalid())?; + } + port + }; + if let Some(port) = port + && (port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err()) + { + return Err(invalid()); + } + Ok(()) +} + +fn encode_file(out: &mut Vec, file: &V2FileVariant) -> Result<(), ChatError> { + let V2FileVariant::P2pMixnet(file) = file; + if file.identifier.len() != 32 || file.claim_ticket.len() != 32 { + return Err(ChatError::InvalidEncoding( + "attachment identifier and claim ticket must be exactly 32 bytes".into(), + )); + } + validate_file_node(&file.node)?; + out.push(0); // FileVariant::P2pMixnet + encode_bytes(out, &file.identifier)?; + encode_bytes(out, &file.claim_ticket)?; + let V2NodeEndpoint::WssUrl(url) = &file.node; + out.push(0); // NodeEndpoint::WssUrl + encode_string(out, url)?; + match &file.meta { + V2FileMeta::General(general) => { + out.push(0); + encode_general_file_meta(out, general)?; + } + V2FileMeta::Image(image) => { + out.push(1); + encode_general_file_meta(out, &image.general)?; + out.extend_from_slice(&image.width.to_le_bytes()); + out.extend_from_slice(&image.height.to_le_bytes()); + encode_thumbnail(out, image.thumbnail.as_deref())?; + } + V2FileMeta::Video(video) => { + out.push(2); + encode_general_file_meta(out, &video.general)?; + out.extend_from_slice(&video.duration.to_le_bytes()); + encode_thumbnail(out, video.thumbnail.as_deref())?; + } + } + Ok(()) +} + +fn encode_general_file_meta( + out: &mut Vec, + general: &V2GeneralFileMeta, +) -> Result<(), ChatError> { + encode_string(out, &general.mime_type)?; + out.extend_from_slice(&general.file_size.to_le_bytes()); + Ok(()) +} + +fn encode_thumbnail(out: &mut Vec, thumbnail: Option<&[u8]>) -> Result<(), ChatError> { + match thumbnail { + None => out.push(0), + Some(bytes) => { + out.push(1); + encode_bytes(out, bytes)?; + } + } + Ok(()) +} + +fn decode_file(cursor: &mut Cursor<'_>) -> Result { + let variant = cursor.read_u8("file_variant")?; + if variant != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported file variant {variant}" + ))); + } + let identifier = decode_file_key(cursor, "file_identifier")?; + let mut claim_ticket = zeroize::Zeroizing::new(decode_file_key(cursor, "file_claim_ticket")?); + let node = match cursor.read_u8("file_node_endpoint")? { + 0 => V2NodeEndpoint::WssUrl(cursor.read_string("file_wss_url")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported attachment node endpoint {value}" + ))); + } + }; + validate_file_node(&node)?; + let meta_index = cursor.read_u8("file_meta")?; + if meta_index > 2 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported file metadata {meta_index}" + ))); + } + let general = V2GeneralFileMeta { + mime_type: cursor.read_string("file_mime_type")?, + file_size: cursor.read_u32("file_size")?, + }; + let meta = match meta_index { + 0 => V2FileMeta::General(general), + 1 => V2FileMeta::Image(V2ImageFileMeta { + general, + width: cursor.read_u32("image_width")?, + height: cursor.read_u32("image_height")?, + thumbnail: decode_thumbnail(cursor)?, + }), + 2 => V2FileMeta::Video(V2VideoFileMeta { + general, + duration: cursor.read_u32("video_duration")?, + thumbnail: decode_thumbnail(cursor)?, + }), + _ => unreachable!(), + }; + Ok(V2FileVariant::P2pMixnet(V2P2pMixnetFile { + identifier, + claim_ticket: core::mem::take(&mut *claim_ticket), + node, + meta, + })) +} + +fn decode_file_key(cursor: &mut Cursor<'_>, field: &str) -> Result, ChatError> { + let (len, consumed) = + decode_compact_u32(&cursor.data[cursor.offset..]).map_err(ChatError::InvalidEncoding)?; + cursor.offset += consumed; + if len != 32 { + return Err(ChatError::InvalidEncoding(format!( + "{field} must be exactly 32 bytes" + ))); + } + Ok(cursor.read_exact(32, field)?.to_vec()) +} + +fn decode_thumbnail(cursor: &mut Cursor<'_>) -> Result>, ChatError> { + match cursor.read_u8("thumbnail_option")? { + 0 => Ok(None), + 1 => Ok(Some(cursor.read_bytes("thumbnail")?)), + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for thumbnail: {value}" + ))), + } +} + +fn encode_optional_push_token( + out: &mut Vec, + push_token: Option<&V2PushToken>, +) -> Result<(), ChatError> { + match push_token { + Some(push_token) => { + out.push(1); + encode_bytes(out, &push_token.token)?; + out.push(match push_token.platform { + V2PushPlatform::Android => 0, + V2PushPlatform::Ios => 1, + V2PushPlatform::IosVoip => 2, + }); + } + None => out.push(0), + } + Ok(()) +} + +fn encode_optional_rich_text( + out: &mut Vec, + welcome_text: Option<&str>, +) -> Result<(), ChatError> { + match welcome_text { + Some(text) => { + out.push(1); // Some(RichText) + out.push(1); // RichText.text = Some + encode_string(out, text)?; + out.push(0); // RichText.attachments = None + } + None => out.push(0), + } + Ok(()) +} + +fn encode_vec_of_bytes(out: &mut Vec, items: &[Vec]) -> Result<(), ChatError> { + let len = u32::try_from(items.len()).map_err(|_| { + ChatError::InvalidEncoding("messages vector is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + for item in items { + encode_bytes(out, item)?; + } + Ok(()) +} + +fn encode_request_device_infos( + out: &mut Vec, + devices: &[V2RequestDeviceInfo], +) -> Result<(), ChatError> { + let len = u32::try_from(devices.len()).map_err(|_| { + ChatError::InvalidEncoding("devices vector is too large for SCALE Compact".into()) + })?; + out.extend_from_slice(&encode_compact_u32(len)); + for device in devices { + out.extend_from_slice(&device.statement_account_id); + encode_bytes(out, &device.encrypted_key)?; + } + Ok(()) +} + +fn decode_chat_request_message_cursor( + cursor: &mut Cursor<'_>, +) -> Result { + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported v2 chat request version index {version_index}" + ))); + } + + let push_token = decode_optional_push_token(cursor)?; + let welcome_text = decode_optional_rich_text(cursor)?; + + Ok(V2ChatRequestMessage { + message_id, + timestamp, + push_token, + welcome_text, + }) +} + +fn decode_chat_request_message_v2_cursor( + cursor: &mut Cursor<'_>, +) -> Result { + let message_id = cursor.read_string("message_id")?; + let timestamp = cursor.read_u64("timestamp")?; + let version_index = cursor.read_u8("version_index")?; + if version_index != 1 { + return Err(ChatError::InvalidEncoding(format!( + "expected chat request content version index 1, got {version_index}" + ))); + } + let identity_account_id = cursor.read_array_32("identity_account_id")?; + let proof = cursor.read_array_32("identity_proof")?; + let device_enc_pub_key = cursor.read_array_32("device_enc_pub_key")?; + let push_token = decode_optional_push_token(cursor)?; + let welcome_text = decode_optional_rich_text(cursor)?; + Ok(V2ChatRequestMessageV2 { + message_id, + timestamp, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id, + proof, + }, + device_enc_pub_key, + push_token, + welcome_text, + }, + }) +} + +fn validate_chat_request_proof(proof: &V2ChatRequestProof) -> Result<(), ChatError> { + if proof.signature.len() != 64 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signature must be 64 bytes, got {}", + proof.signature.len() + ))); + } + if proof.signer.len() != 32 { + return Err(ChatError::InvalidEncoding(format!( + "chat request proof signer must be 32 bytes, got {}", + proof.signer.len() + ))); + } + Ok(()) +} + +fn decode_chat_request_proof(cursor: &mut Cursor<'_>) -> Result { + let proof_index = cursor.read_u8("proof_index")?; + if proof_index != 0 { + return Err(ChatError::InvalidEncoding(format!( + "unsupported chat request proof index {proof_index}" + ))); + } + let signature = cursor.read_exact(64, "proof_signature")?.to_vec(); + let signer = cursor.read_exact(32, "proof_signer")?.to_vec(); + Ok(V2ChatRequestProof { signature, signer }) +} + +fn decode_optional_push_token(cursor: &mut Cursor<'_>) -> Result, ChatError> { + match cursor.read_u8("push_token_option")? { + 0 => Ok(None), + 1 => { + let token = cursor.read_bytes("push_token")?; + let platform = match cursor.read_u8("push_platform")? { + 0 => V2PushPlatform::Android, + 1 => V2PushPlatform::Ios, + 2 => V2PushPlatform::IosVoip, + value => { + return Err(ChatError::InvalidEncoding(format!( + "unsupported push platform {value}" + ))); + } + }; + Ok(Some(V2PushToken { token, platform })) + } + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for push token: {value}" + ))), + } +} + +/// Decode a RichText value from the cursor (non-optional outer wrapper). +/// +/// RichText = Option text + Option attachments. +fn decode_rich_text( + cursor: &mut Cursor<'_>, +) -> Result<(Option, Option>), ChatError> { + let text = match cursor.read_u8("rich_text_text_option")? { + 0 => None, + 1 => Some(cursor.read_string("rich_text_text")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text: {value}" + ))); + } + }; + + let attachments = match cursor.read_u8("rich_text_attachments_option")? { + 0 => None, + 1 => { + let (count, consumed) = decode_compact_u32(&cursor.data[cursor.offset..]) + .map_err(ChatError::InvalidEncoding)?; + cursor.offset += consumed; + let count = count as usize; + // Even with empty URL/MIME strings, a native file needs 75 bytes. + // Bound the collection before reserving from an untrusted count. + if count > cursor.data.len().saturating_sub(cursor.offset) / 75 { + return Err(ChatError::InvalidEncoding( + "attachment count exceeds remaining input".into(), + )); + } + let mut files = Vec::with_capacity(count); + for _ in 0..count { + files.push(decode_file(cursor)?); + } + Some(files) + } + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text attachments: {value}" + ))); + } + }; + Ok((text, attachments)) +} + +fn decode_optional_rich_text(cursor: &mut Cursor<'_>) -> Result, ChatError> { + match cursor.read_u8("welcome_option")? { + 0 => Ok(None), + 1 => { + let text = match cursor.read_u8("welcome_text_option")? { + 0 => None, + 1 => Some(cursor.read_string("welcome_text")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text text: {value}" + ))); + } + }; + + match cursor.read_u8("welcome_attachments_option")? { + 0 => Ok(text), + 1 => Err(ChatError::InvalidEncoding( + "welcome message attachments cannot be represented".into(), + )), + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for rich text attachments: {value}" + ))), + } + } + value => Err(ChatError::InvalidEncoding(format!( + "invalid SCALE option for welcome message: {value}" + ))), + } +} + +#[cfg(feature = "std")] +fn random_bytes_32() -> Result<[u8; 32], ChatError> { + let mut bytes = [0; 32]; + getrandom::getrandom(&mut bytes) + .map_err(|_| ChatError::KeyDerivationFailed("OS key generation failed".into()))?; + Ok(bytes) +} + +#[cfg(feature = "std")] +fn random_nonce() -> Result<[u8; 12], ChatError> { + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce) + .map_err(|_| ChatError::KeyDerivationFailed("OS nonce generation failed".into()))?; + Ok(nonce) +} + +fn chacha20poly1305_encrypt_with_nonce( + key: &[u8; 32], + plaintext: &[u8], + nonce_bytes: [u8; 12], +) -> Result, ChatError> { + chacha20poly1305_encrypt_with_nonce_and_aad(key, plaintext, nonce_bytes, &[]) +} + +fn chacha20poly1305_encrypt_with_nonce_and_aad( + key: &[u8; 32], + plaintext: &[u8], + nonce_bytes: [u8; 12], + aad: &[u8], +) -> Result, ChatError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| ChatError::InvalidEncoding("ChaCha20-Poly1305 encryption failed".into()))?; + + let mut out = Vec::with_capacity(12 + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +fn chacha20poly1305_decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result, ChatError> { + chacha20poly1305_decrypt_with_aad(key, ciphertext, &[]) +} + +fn chacha20poly1305_decrypt_with_aad( + key: &[u8; 32], + ciphertext: &[u8], + aad: &[u8], +) -> Result, ChatError> { + if ciphertext.len() < 28 { + return Err(ChatError::InvalidEncoding(format!( + "ciphertext too short: {} bytes", + ciphertext.len() + ))); + } + + let cipher = ChaCha20Poly1305::new(key.into()); + let nonce = Nonce::from_slice(&ciphertext[..12]); + cipher + .decrypt( + nonce, + Payload { + msg: &ciphertext[12..], + aad, + }, + ) + .map_err(|_| ChatError::InvalidEncoding("ChaCha20-Poly1305 decryption failed".into())) +} + +struct Cursor<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + fn read_u8(&mut self, field: &str) -> Result { + let value = *self.data.get(self.offset).ok_or_else(|| { + ChatError::InvalidEncoding(format!("v2 chat message truncated at {field}")) + })?; + self.offset += 1; + Ok(value) + } + + #[cfg(feature = "std")] + fn read_u16(&mut self, field: &str) -> Result { + let bytes = self.read_exact(2, field)?; + Ok(u16::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u16 for {field}")) + })?)) + } + + fn read_u32(&mut self, field: &str) -> Result { + let bytes = self.read_exact(4, field)?; + Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u32 for {field}")) + })?)) + } + + fn read_compact_u128(&mut self, field: &str) -> Result { + let first = self.read_u8(field)?; + let mode = first & 0b11; + let non_canonical = + || ChatError::InvalidEncoding(format!("{field}: non-canonical compact integer")); + let value = match mode { + 0b00 => u128::from(first >> 2), + 0b01 => { + let next = self.read_exact(1, field)?[0]; + let value = u128::from(u16::from_le_bytes([first, next]) >> 2); + if value < 1 << 6 { + return Err(non_canonical()); + } + value + } + 0b10 => { + let rest = self.read_exact(3, field)?; + let value = u128::from(u32::from_le_bytes([first, rest[0], rest[1], rest[2]]) >> 2); + if value < 1 << 14 { + return Err(non_canonical()); + } + value + } + 0b11 => { + let len = usize::from(first >> 2) + 4; + if len > 16 { + return Err(ChatError::InvalidEncoding(format!( + "{field}: compact integer exceeds u128" + ))); + } + let bytes = self.read_exact(len, field)?; + if bytes[len - 1] == 0 { + return Err(non_canonical()); + } + let mut padded = [0_u8; 16]; + padded[..len].copy_from_slice(bytes); + let value = u128::from_le_bytes(padded); + if value < 1 << 30 { + return Err(non_canonical()); + } + value + } + _ => unreachable!(), + }; + Ok(value) + } + + fn read_u64(&mut self, field: &str) -> Result { + let bytes = self.read_exact(8, field)?; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { + ChatError::InvalidEncoding(format!("v2 chat message invalid u64 for {field}")) + })?)) + } + + fn read_array_32(&mut self, field: &str) -> Result<[u8; 32], ChatError> { + self.read_exact(32, field)? + .try_into() + .map_err(|_| ChatError::InvalidEncoding(format!("invalid 32-byte field {field}"))) + } + + fn read_balance(&mut self, field: &str) -> Result { + self.read_compact_u128(field).map(|value| value.to_string()) + } + + fn read_string(&mut self, field: &str) -> Result { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + let bytes = self.read_exact(len as usize, field)?; + String::from_utf8(bytes.to_vec()) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: invalid utf-8: {e}"))) + } + + fn read_exact(&mut self, len: usize, field: &str) -> Result<&'a [u8], ChatError> { + let end = self.offset.checked_add(len).ok_or_else(|| { + ChatError::InvalidEncoding(format!("v2 chat message length overflow at {field}")) + })?; + if end > self.data.len() { + return Err(ChatError::InvalidEncoding(format!( + "v2 chat message truncated at {field}" + ))); + } + let bytes = &self.data[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn read_bytes(&mut self, field: &str) -> Result, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + Ok(self.read_exact(len as usize, field)?.to_vec()) + } + + fn read_vec_of_bytes(&mut self, field: &str) -> Result>, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|e| ChatError::InvalidEncoding(format!("{field}: {e}")))?; + self.offset += consumed; + let len = len as usize; + if len > self.data.len().saturating_sub(self.offset) { + return Err(ChatError::InvalidEncoding(format!( + "{field}: item count exceeds remaining input" + ))); + } + let mut out = Vec::with_capacity(len); + for index in 0..len { + out.push(self.read_bytes(&format!("{field}[{index}]"))?); + } + Ok(out) + } + + fn read_request_device_infos( + &mut self, + field: &str, + ) -> Result, ChatError> { + let (len, consumed) = decode_compact_u32(&self.data[self.offset..]) + .map_err(|error| ChatError::InvalidEncoding(format!("{field}: {error}")))?; + self.offset += consumed; + let len = len as usize; + if len > self.data.len().saturating_sub(self.offset) / 33 { + return Err(ChatError::InvalidEncoding(format!( + "{field}: device count exceeds remaining input" + ))); + } + let mut devices = Vec::with_capacity(len); + for index in 0..len { + devices.push(V2RequestDeviceInfo { + statement_account_id: self + .read_array_32(&format!("{field}[{index}].statement_account_id"))?, + encrypted_key: self.read_bytes(&format!("{field}[{index}].encrypted_key"))?, + }); + } + Ok(devices) + } + + fn finish(&self) -> Result<(), ChatError> { + if self.offset != self.data.len() { + return Err(ChatError::InvalidEncoding(format!( + "v2 chat message has {} trailing bytes", + self.data.len() - self.offset + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_array_32(hex: &str) -> [u8; 32] { + assert_eq!(hex.len(), 64); + let mut out = [0_u8; 32]; + for (index, byte) in out.iter_mut().enumerate() { + let offset = index * 2; + *byte = u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap(); + } + out + } + + #[test] + fn chacha20poly1305_matches_rfc_8439_vector() { + let key = hex_array_32("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f"); + let nonce: [u8; 12] = hex::decode("070000004041424344454647") + .unwrap() + .try_into() + .unwrap(); + let plaintext = hex::decode(concat!( + "4c616469657320616e642047656e746c656d656e206f662074686520636c617373", + "206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c", + "79206f6e652074697020666f7220746865206675747572652c2073756e7363726565", + "6e20776f756c642062652069742e" + )) + .unwrap(); + let aad = hex::decode("50515253c0c1c2c3c4c5c6c7").unwrap(); + let expected = hex::decode(concat!( + "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63", + "dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692d", + "dbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4d", + "ef08e4b7a9de576d26586cec64b6116", + "1ae10b594f09e26a7e902ecbd0600691" + )) + .unwrap(); + + use chacha20poly1305::aead::Payload; + let cipher = ChaCha20Poly1305::new((&key).into()); + let encrypted = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) + .unwrap(); + assert_eq!(encrypted, expected); + assert_eq!( + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &encrypted, + aad: &aad, + }, + ) + .unwrap(), + plaintext + ); + } + + #[test] + fn day_from_unix_uses_v2_protocol_epoch() { + assert_eq!(chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS - 1), None); + assert_eq!(chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS), Some(0)); + assert_eq!( + chat_request_day_from_unix(PROTOCOL_EPOCH_SECONDS + SECONDS_IN_DAY * 7 + 12), + Some(7) + ); + } + + #[test] + fn full_topic_matches_ios_v2_data_layout() { + let account = [0x11; 32]; + let mut expected_input = Vec::new(); + expected_input.extend_from_slice(&encode_compact_u32(CHAT_REQUEST_CONTEXT.len() as u32)); + expected_input.extend_from_slice(CHAT_REQUEST_CONTEXT); + expected_input.extend_from_slice(&encode_compact_u32(account.len() as u32)); + expected_input.extend_from_slice(&account); + assert_eq!( + chat_request_full_topic(&account), + blake2b_256(&expected_input) + ); + } + + #[test] + fn day_topic_appends_little_endian_u64_day() { + let account = [0x22; 32]; + let day = 42_u64; + let mut expected_input = Vec::new(); + expected_input.extend_from_slice(&encode_compact_u32(CHAT_REQUEST_CONTEXT.len() as u32)); + expected_input.extend_from_slice(CHAT_REQUEST_CONTEXT); + expected_input.extend_from_slice(&encode_compact_u32(account.len() as u32)); + expected_input.extend_from_slice(&account); + expected_input.extend_from_slice(&day.to_le_bytes()); + assert_eq!( + chat_request_day_topic(&account, day), + blake2b_256(&expected_input) + ); + } + + #[test] + fn full_topic_matches_ios_v2_observed_topic() { + let account = + hex_array_32("e6f8b1d6f1c8fde666469b9662d3d0925b21085f876722e428b1226d78ae1301"); + assert_eq!( + chat_request_full_topic(&account), + hex_array_32("463725e892e8c663bb531e8ed8ecf4b4e7e4a198ddfbce76a646d515dab1c5b2") + ); + } + + #[test] + fn session_id_params_match_v2_ordering() { + let requester = [0xAA; 32]; + let acceptor = [0xBB; 32]; + let params = chat_request_session_id_params(&requester, Some("1234"), &acceptor, None); + let mut expected = Vec::new(); + expected.extend_from_slice(&requester); + expected.extend_from_slice(&acceptor); + expected.extend_from_slice(b"/1234/"); + assert_eq!(params, expected); + } + + #[test] + fn session_topic_is_keyed_hash_of_context_and_params() { + let secret = [0x44; 32]; + let requester = [0x55; 32]; + let acceptor = [0x66; 32]; + let params = chat_request_session_id_params(&requester, None, &acceptor, Some("9999")); + let mut input = Vec::new(); + input.extend_from_slice(CHAT_REQUEST_CONTEXT); + input.extend_from_slice(¶ms); + let expected = blake2b_256_keyed(&secret, &input).unwrap(); + assert_eq!( + chat_request_session_topic(&secret, &requester, None, &acceptor, Some("9999")).unwrap(), + expected + ); + } + + #[test] + fn session_topic_rejects_non_32_byte_secret() { + let requester = [0x55; 32]; + let acceptor = [0x66; 32]; + let err = + chat_request_session_topic(&[0x44; 31], &requester, None, &acceptor, None).unwrap_err(); + assert!(err.to_string().contains("shared_secret must be 32 bytes")); + } + + #[test] + fn text_message_round_trips() { + let encoded = encode_text_message("msg-1", 123, "hello").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-1".into(), + timestamp: 123, + content: V2ChatMessageContent::Text("hello".into()) + } + ); + } + + #[test] + fn chat_accepted_message_round_trips() { + let encoded = encode_chat_accepted_message("msg-2", 456, "request-1").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-2".into(), + timestamp: 456, + content: V2ChatMessageContent::ChatAccepted { + request_id: "request-1".into() + } + } + ); + } + + #[test] + fn data_channel_offer_round_trips() { + let encoded = encode_data_channel_offer_message( + "msg-offer", + 457, + b"offer-sdp", + V2DataChannelPurpose::Video, + ) + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-offer".into(), + timestamp: 457, + content: V2ChatMessageContent::DataChannelOffer { + sdp: b"offer-sdp".to_vec(), + purpose: V2DataChannelPurpose::Video, + } + } + ); + } + + #[test] + fn data_channel_offer_lifts_to_transport_neutral_call_signal() { + let decoded = decode_message( + &encode_data_channel_offer_message( + "msg-offer", + 457, + b"offer-sdp", + V2DataChannelPurpose::Video, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + decoded.as_call_signal(), + Some(V2CallSignal::Offer { + offer_id: "msg-offer".into(), + sdp: b"offer-sdp".to_vec(), + purpose: V2DataChannelPurpose::Video, + }) + ); + } + + #[test] + fn data_channel_answer_round_trips() { + let encoded = + encode_data_channel_answer_message("msg-answer", 458, "offer-1", b"answer-sdp") + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-answer".into(), + timestamp: 458, + content: V2ChatMessageContent::DataChannelAnswer { + offer_id: "offer-1".into(), + sdp: b"answer-sdp".to_vec(), + } + } + ); + } + + #[test] + fn data_channel_candidates_round_trips() { + let encoded = encode_data_channel_candidates_message( + "msg-candidates", + 459, + "offer-2", + b"candidate-batch", + ) + .unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-candidates".into(), + timestamp: 459, + content: V2ChatMessageContent::DataChannelCandidates { + offer_id: "offer-2".into(), + sdp: b"candidate-batch".to_vec(), + } + } + ); + } + + #[test] + fn data_channel_closed_round_trips() { + let encoded = encode_data_channel_closed_message("msg-closed", 460, "offer-3").unwrap(); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-closed".into(), + timestamp: 460, + content: V2ChatMessageContent::DataChannelClosed { + offer_id: "offer-3".into(), + } + } + ); + } + + #[test] + fn transport_neutral_call_signal_encodes_to_v2_message() { + let encoded = encode_call_signal_message( + "msg-call", + 461, + &V2CallSignal::Candidates { + offer_id: "offer-4".into(), + sdp: b"candidate-batch".to_vec(), + }, + ) + .unwrap(); + + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-call".into(), + timestamp: 461, + content: V2ChatMessageContent::DataChannelCandidates { + offer_id: "offer-4".into(), + sdp: b"candidate-batch".to_vec(), + } + } + ); + } + + #[test] + fn unsupported_content_preserves_header() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-3").unwrap(); + encoded.extend_from_slice(&789_u64.to_le_bytes()); + encoded.push(0); + encoded.push(99); // 99 is not a known content index + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-3".into(), + timestamp: 789, + content: V2ChatMessageContent::UnsupportedContent { content_index: 99 } + } + ); + } + + #[test] + fn unsupported_version_preserves_header() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-4").unwrap(); + encoded.extend_from_slice(&890_u64.to_le_bytes()); + encoded.push(1); + encoded.extend_from_slice(b"future payload"); + assert_eq!( + decode_message(&encoded).unwrap(), + V2ChatMessage { + message_id: "msg-4".into(), + timestamp: 890, + content: V2ChatMessageContent::UnsupportedVersion { version_index: 1 } + } + ); + } + + #[test] + fn supported_content_rejects_trailing_bytes() { + let mut encoded = encode_text_message("msg-5", 901, "hello").unwrap(); + encoded.push(0); + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("trailing bytes")); + } + + #[test] + fn invalid_data_channel_purpose_rejects_decode() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-invalid").unwrap(); + encoded.extend_from_slice(&902_u64.to_le_bytes()); + encoded.push(0); + encoded.push(8); + encode_bytes(&mut encoded, b"sdp").unwrap(); + encoded.push(9); + + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("unsupported data channel purpose")); + } + + #[test] + fn test_token_message_roundtrip() { + let encoded = + encode_token_message("msg-1", 1000, &[0xAA, 0xBB], V2PushPlatform::Ios).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.message_id, "msg-1"); + assert_eq!(decoded.timestamp, 1000); + assert_eq!( + decoded.content, + V2ChatMessageContent::Token { + token: vec![0xAA, 0xBB], + platform: V2PushPlatform::Ios + } + ); + } + + #[test] + fn token_message_decodes_v2_push_token_shape() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-token-push").unwrap(); + encoded.extend_from_slice(&1001_u64.to_le_bytes()); + encoded.push(0); + encoded.push(1); + encode_bytes(&mut encoded, &[0xAA, 0xBB, 0xCC]).unwrap(); + encoded.push(2); + + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Token { + token: vec![0xAA, 0xBB, 0xCC], + platform: V2PushPlatform::IosVoip + } + ); + } + + #[test] + fn test_send_legacy_message_roundtrip() { + let block_hash = vec![0x11; 32]; + let extrinsic_hash = vec![0x22; 32]; + let encoded = + encode_send_legacy_message("msg-2", 2000, "100", &block_hash, &extrinsic_hash).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::SendLegacy { + amount: "100".into(), + block_hash, + extrinsic_hash + } + ); + } + + #[test] + fn send_legacy_rejects_truncated_ios_payload_shape() { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-send-legacy-real").unwrap(); + encoded.extend_from_slice(&2001_u64.to_le_bytes()); + encoded.push(0); + encoded.push(2); + encode_balance(&mut encoded, "100").unwrap(); + encoded.extend_from_slice(&[0x11; 31]); + + let err = decode_message(&encoded).unwrap_err(); + assert!(err.to_string().contains("truncated at block_hash")); + } + + #[test] + fn send_legacy_rejects_non_canonical_amount() { + let encode = |amount: &[u8]| { + let mut encoded = Vec::new(); + encode_string(&mut encoded, "msg-send-legacy").unwrap(); + encoded.extend_from_slice(&2001_u64.to_le_bytes()); + encoded.push(0); + encoded.push(2); + encoded.extend_from_slice(amount); + encoded.extend_from_slice(&[0x11; 32]); + encoded.extend_from_slice(&[0x22; 32]); + encoded + }; + + assert!(decode_message(&encode(&[10 << 2])).is_ok()); + for amount in [ + &[(10 << 2) | 0b01, 0][..], + &[(10 << 2) | 0b10, 0, 0, 0][..], + &[0b11, 10, 0, 0, 0][..], + &[(1 << 2) | 0b11, 0, 0, 0, 0x40, 0][..], + ] { + let err = decode_message(&encode(amount)).unwrap_err(); + assert!( + err.to_string().contains("non-canonical"), + "{amount:?}: {err}" + ); + } + } + + #[test] + fn test_contact_added_message_roundtrip() { + let encoded = encode_contact_added_message("msg-3", 3000).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.content, V2ChatMessageContent::ContactAdded); + } + + #[test] + fn test_reacted_message_roundtrip() { + let encoded = encode_reacted_message("msg-4", 4000, "ref-1", "\u{1F44D}").unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reacted { + message_id: "ref-1".into(), + emoji: "\u{1F44D}".into() + } + ); + } + + #[test] + fn test_reaction_removed_message_roundtrip() { + let encoded = + encode_reaction_removed_message("msg-5", 5000, "ref-2", "\u{2764}\u{FE0F}").unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::ReactionRemoved { + message_id: "ref-2".into(), + emoji: "\u{2764}\u{FE0F}".into() + } + ); + } + + #[test] + fn test_reply_message_roundtrip() { + let encoded = encode_reply_message("msg-6", 6000, "ref-3", Some("reply text")).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reply { + message_id: "ref-3".into(), + text: Some("reply text".into()), + attachments: None, + } + ); + } + + #[test] + fn test_reply_message_without_text_roundtrip() { + let encoded = encode_reply_message("msg-6b", 6001, "ref-3b", None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Reply { + message_id: "ref-3b".into(), + text: None, + attachments: None, + } + ); + } + + #[test] + fn test_edited_message_roundtrip() { + let encoded = encode_edited_message("msg-7", 7000, "ref-4", Some("new content")).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::Edited { + message_id: "ref-4".into(), + new_text: Some("new content".into()), + attachments: None, + } + ); + } + + #[test] + fn test_left_chat_message_roundtrip() { + let encoded = encode_left_chat_message("msg-8", 8000).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.content, V2ChatMessageContent::LeftChat); + } + + #[test] + fn test_rich_text_message_roundtrip() { + let encoded = encode_rich_text_message("msg-9", 9000, Some("hello rich"), None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: Some("hello rich".into()), + attachments: None, + } + ); + } + + #[test] + fn egui_rich_text_matches_native_chat_v2_vector() { + let encoded = encode_rich_text_message( + "egui-message", + 1_700_000_000_123, + Some("hello from egui"), + None, + ) + .unwrap(); + assert_eq!( + hex::encode(&encoded), + "30656775692d6d6573736167657b68e5cf8b010000000f013c68656c6c6f2066726f6d206567756900" + ); + + let decoded = decode_message(&encoded).unwrap(); + assert_eq!(decoded.message_id, "egui-message"); + assert_eq!(decoded.timestamp, 1_700_000_000_123); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: Some("hello from egui".into()), + attachments: None, + } + ); + } + + #[test] + fn test_rich_text_message_without_text_roundtrip() { + let encoded = encode_rich_text_message("msg-9b", 9001, None, None).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::RichText { + text: None, + attachments: None + } + ); + } + + // Native brevity-chat wire.rs field order/indices and fixed-u32 numeric + // fixture, with real 32-byte HOP entry hashes and FileTicket keys. + fn native_attachment_fixture(content_index: u8, meta_index: u8) -> Vec { + let mut bytes = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 0, content_index]; + if content_index == 7 || content_index == 12 { + bytes.extend_from_slice(&[4, b'r']); + } + bytes.extend_from_slice(&[0, 1, 4, 0, 0x80]); // None text, Some(one file), hash length + bytes.extend_from_slice(&[0xa1; 32]); + bytes.push(0x80); // ticket length + bytes.extend_from_slice(&[0xb1; 32]); + bytes.extend_from_slice(&[0, 0x1c]); // WssUrl, length 7 + bytes.extend_from_slice(b"wss://n"); + bytes.push(meta_index); + match meta_index { + 0 => { + bytes.push(0x28); + bytes.extend_from_slice(b"text/plain"); + bytes.extend_from_slice(&[0xff; 4]); // file_size = u32::MAX + } + 1 => { + bytes.push(0x28); + bytes.extend_from_slice(b"image/jpeg"); + bytes.extend_from_slice(&[0x40, 0xe2, 1, 0]); // file_size = 123456 + bytes.extend_from_slice(&[0x20, 3, 0, 0]); // width = 800 + bytes.extend_from_slice(&[0x58, 2, 0, 0]); // height = 600 + bytes.extend_from_slice(&[1, 0x10, b'L', b'K', b'O', b'2']); + } + 2 => { + bytes.push(0x24); + bytes.extend_from_slice(b"video/mp4"); + bytes.extend_from_slice(&[7, 0, 0, 0]); // file_size = 7 + bytes.extend_from_slice(&[90, 0, 0, 0, 0]); // duration = 90, no thumbnail + } + _ => unreachable!(), + } + bytes + } + + fn native_attachment(meta: V2FileMeta) -> V2FileVariant { + V2FileVariant::P2pMixnet(V2P2pMixnetFile { + identifier: vec![0xa1; 32], + claim_ticket: vec![0xb1; 32], + node: V2NodeEndpoint::WssUrl("wss://n".into()), + meta, + }) + } + + #[test] + fn native_ordinary_reply_and_edit_attachment_fixtures() { + let cases = [ + ( + 7, + 0, + V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: u32::MAX, + }), + ), + ( + 15, + 1, + V2FileMeta::Image(V2ImageFileMeta { + general: V2GeneralFileMeta { + mime_type: "image/jpeg".into(), + file_size: 123_456, + }, + width: 800, + height: 600, + thumbnail: Some(b"LKO2".to_vec()), + }), + ), + ( + 12, + 2, + V2FileMeta::Video(V2VideoFileMeta { + general: V2GeneralFileMeta { + mime_type: "video/mp4".into(), + file_size: 7, + }, + duration: 90, + thumbnail: None, + }), + ), + ]; + for (content_index, meta_index, meta) in cases { + let attachments = vec![native_attachment(meta)]; + let expected = match content_index { + 7 => V2ChatMessageContent::Reply { + message_id: "r".into(), + text: None, + attachments: Some(attachments.clone()), + }, + 12 => V2ChatMessageContent::Edited { + message_id: "r".into(), + new_text: None, + attachments: Some(attachments.clone()), + }, + _ => V2ChatMessageContent::RichText { + text: None, + attachments: Some(attachments.clone()), + }, + }; + assert_eq!( + decode_message(&native_attachment_fixture(content_index, meta_index)).unwrap(), + V2ChatMessage { + message_id: "m".into(), + timestamp: 1, + content: expected + }, + ); + assert_eq!( + encode_rich_text_message("m", 1, None, Some(&attachments)).unwrap(), + native_attachment_fixture(15, meta_index), + ); + } + } + + #[test] + fn rich_attachment_options_preserve_empty_and_multiple_files() { + let file = native_attachment(V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: 0, + })); + for attachments in [Some(vec![]), Some(vec![file.clone(), file]), None] { + let encoded = + encode_rich_text_message("m", 1, Some("caption"), attachments.as_deref()).unwrap(); + assert_eq!( + decode_message(&encoded).unwrap().content, + V2ChatMessageContent::RichText { + text: Some("caption".into()), + attachments + } + ); + } + } + + #[test] + fn attachment_decoder_rejects_malformed_boundaries() { + let valid = native_attachment_fixture(15, 1); + // Each offset is a different SCALE discriminant in the native image fixture. + for (offset, value) in [(12, 2), (13, 2), (15, 1), (82, 1), (91, 3), (115, 2)] { + let mut malformed = valid.clone(); + malformed[offset] = value; + assert!( + decode_message(&malformed).is_err(), + "discriminant at {offset}" + ); + } + // The hash and ticket are Vec on wire but must be exact native keys. + for offset in [16, 49] { + let mut short = valid.clone(); + short[offset] = 31 << 2; + short.remove(offset + 1); + assert!(decode_message(&short).is_err()); + let mut long = valid.clone(); + long[offset] = 33 << 2; + long.insert(offset + 1, 0); + assert!(decode_message(&long).is_err()); + } + // Counts and variable lengths must be checked before allocation. + for offset in [14, 83, 92, 116] { + let mut oversized = valid[..offset].to_vec(); + oversized.extend_from_slice(&[3, 255, 255, 255, 255]); // compact u32::MAX + oversized.extend_from_slice(&valid[offset + 1..]); + assert!(decode_message(&oversized).is_err(), "length at {offset}"); + } + let mut noncanonical = valid[..14].to_vec(); + noncanonical.extend_from_slice(&[5, 0]); // noncanonical compact count 1 + noncanonical.extend_from_slice(&valid[15..]); + assert!(decode_message(&noncanonical).is_err()); + for end in 0..valid.len() { + assert!(decode_message(&valid[..end]).is_err(), "truncated at {end}"); + } + let mut trailing = valid; + trailing.push(0); + assert!(decode_message(&trailing).is_err()); + } + + #[test] + fn attachment_encoder_rejects_wrong_key_widths_and_unsafe_nodes() { + let V2FileVariant::P2pMixnet(file) = + native_attachment(V2FileMeta::General(V2GeneralFileMeta { + mime_type: "text/plain".into(), + file_size: 0, + })); + let mut short_hash = file.clone(); + short_hash.identifier.pop(); + let mut long_ticket = file.clone(); + long_ticket.claim_ticket.push(0); + for invalid in [short_hash, long_ticket] { + assert!( + encode_rich_text_message("m", 1, None, Some(&[V2FileVariant::P2pMixnet(invalid),])) + .is_err() + ); + } + for url in [ + "ws://n", + "wss://", + "wss://user@n", + "wss://n#fragment", + "wss://n:65536", + "wss://[bad]", + "wss://n\n/path", + ] { + let mut invalid = file.clone(); + invalid.node = V2NodeEndpoint::WssUrl(url.into()); + assert!( + encode_rich_text_message("m", 1, None, Some(&[V2FileVariant::P2pMixnet(invalid),])) + .is_err() + ); + } + let mut invalid_wire = native_attachment_fixture(15, 0); + invalid_wire[84] = b'x'; // xss://n is not a secure WebSocket endpoint. + assert!(decode_message(&invalid_wire).is_err()); + } + + #[test] + fn invitation_decoders_do_not_drop_attachments() { + let rich = native_attachment_fixture(15, 1); + let mut legacy = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + legacy.extend_from_slice(&rich[12..]); + assert!(decode_chat_request_message(&legacy).is_err()); + let mut current = vec![4, b'm', 1, 0, 0, 0, 0, 0, 0, 0, 1]; + current.extend_from_slice(&[1; 96]); // identity, proof, device encryption key + current.extend_from_slice(&[0, 1]); // no push token, Some welcome + current.extend_from_slice(&rich[12..]); + assert!(decode_chat_request_message_v2(¤t).is_err()); + } + + #[test] + fn test_coinage_send_message_roundtrip() { + let coin_keys = vec![vec![0xAAu8; 32], vec![0xBBu8; 32]]; + let encoded = encode_coinage_send_message("msg-10", 10000, "1000", &coin_keys).unwrap(); + let decoded = decode_message(&encoded).unwrap(); + assert_eq!( + decoded.content, + V2ChatMessageContent::CoinageSend { + total_value: "1000".into(), + coin_keys: coin_keys.clone() + } + ); + } + #[test] + fn current_multi_device_wire_roundtrips() { + let added = encode_device_added_message("add", 1, &[1; 32], &[2; 32]).unwrap(); + assert!(matches!( + decode_message(&added).unwrap().content, + V2ChatMessageContent::DeviceAdded { .. } + )); + let removed = encode_device_removed_message("remove", 2, &[1; 32]).unwrap(); + assert!(matches!( + decode_message(&removed).unwrap().content, + V2ChatMessageContent::DeviceRemoved { .. } + )); + let compacted = encode_compacted_messages_message( + "compact", + 3, + &[3; 4], + &[4; 5], + &V2NodeEndpoint::WssUrl("wss://chat.example".into()), + ) + .unwrap(); + assert!(matches!( + decode_message(&compacted).unwrap().content, + V2ChatMessageContent::CompactedMessages { .. } + )); + let accepted = encode_multi_chat_accepted_message( + "accept", + 4, + "request", + &V2PeerDevice { + statement_account_id: [5; 32], + encryption_public_key: [6; 32], + }, + ) + .unwrap(); + assert_eq!(accepted[16], 20, "Android DeviceChatAccepted wire index"); + assert!(matches!( + decode_message(&accepted).unwrap().content, + V2ChatMessageContent::MultiChatAccepted { .. } + )); + } + + #[test] + fn request_content_v2_seals_and_opens_with_fixed_nonce() { + let request = V2ChatRequestV2 { + message: V2ChatRequestMessageV2 { + message_id: "request-v2".into(), + timestamp: 42, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id: [7; 32], + proof: [8; 32], + }, + device_enc_pub_key: [9; 32], + push_token: None, + welcome_text: Some("hello".into()), + }, + }, + proof: V2ChatRequestProof { + signature: vec![10; 64], + signer: vec![11; 32], + }, + }; + let recipient_private = [12; 32]; + let wrapper = seal_chat_request_v2_with_nonce( + &[13; 32], + &x25519_public_key(&recipient_private), + &request, + [14; 12], + ) + .unwrap(); + assert_eq!( + open_chat_request_v2(&recipient_private, &wrapper).unwrap(), + request + ); + } + #[test] + fn context_bound_invite_authenticates_product_accounts_and_route() { + let sender_account_id = [7; 32]; + let recipient_account_id = [12; 32]; + let channel_id = [15; 32]; + let request = V2ChatRequestV2 { + message: V2ChatRequestMessageV2 { + message_id: "context-bound".into(), + timestamp: 42, + content: V2ChatRequestContentV2 { + identity_proof: V2ChatRequestIdentityProof { + identity_account_id: sender_account_id, + proof: [8; 32], + }, + device_enc_pub_key: [9; 32], + push_token: None, + welcome_text: Some("secure hello".into()), + }, + }, + proof: V2ChatRequestProof { + signature: vec![10; 64], + signer: vec![11; 32], + }, + }; + let recipient_private = [12; 32]; + let wrapper = seal_context_bound_chat_request_v2_with_nonce( + &[13; 32], + &x25519_public_key(&recipient_private), + "egui-chat.paseo", + &sender_account_id, + &recipient_account_id, + &channel_id, + &request, + [14; 12], + ) + .unwrap(); + assert_eq!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.paseo", + &recipient_account_id, + &channel_id, + &wrapper, + ) + .unwrap(), + request + ); + assert!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.westend", + &recipient_account_id, + &channel_id, + &wrapper, + ) + .is_err() + ); + assert!( + open_context_bound_chat_request_v2( + &recipient_private, + "egui-chat.paseo", + &recipient_account_id, + &[16; 32], + &wrapper, + ) + .is_err() + ); + assert!(open_chat_request_v2(&recipient_private, &wrapper).is_err()); + assert!(decode_context_bound_chat_request_v2(&[99; 32], &channel_id, &wrapper).is_err()); + assert!( + decode_context_bound_chat_request_v2( + &recipient_account_id, + &channel_id, + &wrapper[..163] + ) + .is_err() + ); + let mut unsupported_version = wrapper.clone(); + unsupported_version[5] = 4; + assert!(is_context_bound_chat_request_v2(&unsupported_version)); + assert!( + decode_context_bound_chat_request_v2( + &recipient_account_id, + &channel_id, + &unsupported_version, + ) + .is_err() + ); + } + + #[test] + fn bare_message_exchange_has_no_statement_data_index() { + let request = + encode_message_exchange_request_plaintext("inner", &[vec![0xaa, 0xbb]]).unwrap(); + assert_eq!( + request, + [vec![0x14], b"inner".to_vec(), vec![0x04, 0x08, 0xaa, 0xbb],].concat() + ); + assert_eq!( + decode_message_exchange_request_plaintext(&request).unwrap(), + V2MessageExchangeRequest { + request_id: "inner".into(), + messages: vec![vec![0xaa, 0xbb]], + } + ); + let response = encode_message_exchange_response_plaintext("inner", 0).unwrap(); + assert_eq!(response, [vec![0x14], b"inner".to_vec(), vec![0]].concat()); + assert_eq!( + decode_message_exchange_response_plaintext(&response).unwrap(), + V2MessageExchangeResponse { + request_id: "inner".into(), + response_code: 0, + } + ); + } + + #[test] + fn oversized_collection_counts_are_rejected_before_allocation() { + assert!(decode_transport_plaintext(&[2, 0, 3, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!( + decode_message_exchange_request_plaintext(&[0, 3, 0xff, 0xff, 0xff, 0xff]).is_err() + ); + } +} diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 20c47381b..908f0c770 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "7c073d1a8c5db314"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f7be28c22289b365"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -223,6 +223,33 @@ impl RequestMethod for AccountRingVrfSign { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `account_product_device_chat` method marker. +pub struct AccountProductDeviceChat; +impl AccountProductDeviceChat { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "product_device_chat", + wire_name: "account_product_device_chat", + request_type: "truapi::versioned::account::HostProductDeviceChatRequest", + response_type: "truapi::versioned::account::HostProductDeviceChatResponse", + error_type: Some("truapi::versioned::account::HostProductDeviceChatError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 12, + }), + }; +} +impl RequestMethod for AccountProductDeviceChat { + type Request = truapi::versioned::account::HostProductDeviceChatRequest; + type Response = truapi::versioned::account::HostProductDeviceChatResponse; + type Error = truapi::versioned::account::HostProductDeviceChatError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `account_get_legacy_accounts` method marker. pub struct AccountGetLegacyAccounts; impl AccountGetLegacyAccounts { @@ -2293,6 +2320,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2370,6 +2398,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2447,6 +2476,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs index 44080579d..4737cf004 100644 --- a/rust/crates/truapi-client/src/lib.rs +++ b/rust/crates/truapi-client/src/lib.rs @@ -476,6 +476,77 @@ mod tests { ); } + #[test] + fn native_chat_uses_method_twelve_with_v2_envelopes() { + use truapi::{latest as dto, versioned::account}; + + let request = account::HostProductDeviceChatRequest::V2( + dto::HostProductDeviceChatRequest::Initialize, + ); + assert_eq!( + encode_request::("p:1", &request), + [12, b'p', b':', b'1', 2, 12, 0, 1, 0], + ); + let denomination_request = account::HostProductDeviceChatRequest::V2( + dto::HostProductDeviceChatRequest::PaymentDenomination, + ); + assert_eq!( + encode_request::("p:1", &denomination_request), + [12, b'p', b':', b'1', 2, 12, 0, 1, 12], + ); + + let response = + account::HostProductDeviceChatResponse::V2(dto::HostProductDeviceChatResponse { + device: dto::HostNativeChatDevice { + identity_account_id: [1; 32], + identity_chat_public_key: [2; 32], + product_account: v01::ProductAccountId { + dot_ns_identifier: "chat.dot".into(), + derivation_index: v01::DerivationIndex::Index(0), + }, + account_id: [3; 32], + chat_public_key: [4; 32], + }, + peers: vec![], + binding: None, + opened: vec![], + prepared: vec![], + payments: vec![], + rich_messages: vec![], + migration: None, + migration_id: None, + open_page: None, + migration_invitations: vec![], + state_page: None, + coinage_cents_unit: None, + }); + let mut frame = vec![12, b'p', b':', b'1', 2, 12, 1, 0]; + response.encode_to(&mut frame); + assert_eq!(frame[8], 1, "response envelope is V2"); + assert_eq!( + decode_response::(&frame), + Ok(Decoded { + request_id: "p:1".into(), + value: Ok(response), + }), + ); + + frame[5] = 11; + assert_eq!( + decode_response::(&frame), + Err(DecodeError::UnexpectedMethod { + expected: MethodIds { + trait_id: 2, + method_id: 12, + }, + actual: MethodIds { + trait_id: 2, + method_id: 11, + }, + }), + ); + } + #[test] fn worker_serves_unified_renderer() { use truapi::versioned::renderer::{ diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index b0b28fe6f..61c9e7899 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -45,6 +45,10 @@ struct Cli { client_version: Option, /// Wire codec version for generated handshake calls. + /// + /// Defaults to the Host's authoritative `truapi::WIRE_CODEC_VERSION`. + /// Override only for intentional historical generation; normal client and + /// Host generation must use the same constant. #[arg(long, default_value_t = truapi::WIRE_CODEC_VERSION)] codec_version: u8, diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs index 73e1d545e..9216e86ce 100644 --- a/rust/crates/truapi-codegen/src/platform_callbacks.rs +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -5,6 +5,19 @@ use std::collections::BTreeSet; use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; use crate::rustdoc::{TypeDef, TypeDefKind, TypeRef, VariantFields}; +/// Compound callback results with inline SCALE codecs shared by both bridges. +/// Byte vectors themselves remain unencoded byte payloads. +pub(crate) fn is_scale_vector_result(ty: &TypeRef) -> bool { + matches!( + ty, + TypeRef::Vec(inner) + if matches!(inner.as_ref(), TypeRef::Array(element, _) + if matches!(element.as_ref(), TypeRef::Primitive(name) if name == "u8")) + || matches!(inner.as_ref(), TypeRef::Primitive(name) if name == "str") + || matches!(inner.as_ref(), TypeRef::Named { args, .. } if args.is_empty()) + ) +} + /// Traits the platform surface actually composes: the super trait's /// constituents when one exists, otherwise every collected trait. pub fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { @@ -22,7 +35,7 @@ pub fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { /// Capability trait names a host may omit, taken from the `OptionalPlatform` /// super-trait. A host that supplies none of a trait's callbacks is not -/// broken: the core answers the matching product calls with `Unsupported`. +/// broken: the core applies the capability's absence behavior. pub fn optional_trait_names(definition: &PlatformDefinition) -> BTreeSet { definition .optional_super_trait @@ -63,6 +76,11 @@ pub fn raw_callback_wire_name( method: &PlatformMethod, platform_trait_names: &BTreeSet, ) -> String { + // The public Rust method spells out its capability; the established raw + // connection bridge uses the same namespace-first shape as chainConnect. + if trait_def.name == "HopProvider" && method.name == "connect_hop" { + return "hopConnect".to_string(); + } let raw = raw_callback_name(method); if trait_object_return_name(method, platform_trait_names).is_some() { return format!( diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 0df14fce8..48e6a91fe 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -6,9 +6,9 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; use crate::platform_callbacks::{ - callback_namespace, collect_local_bridge_payload_types, composed_traits, optional_trait_names, - platform_trait_names, raw_callback_field_name, raw_callback_name, raw_callback_wire_name, - snake_case, stream_item, trait_object_return_name, + callback_namespace, collect_local_bridge_payload_types, composed_traits, + is_scale_vector_result, optional_trait_names, platform_trait_names, raw_callback_field_name, + raw_callback_name, raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, }; use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; @@ -45,7 +45,7 @@ pub fn generate_wasm_bridge( use super::{{ WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, missing_callback, parse_optional_bytes_item, + invoke_optional_bytes_return, invoke_optional_string_return, invoke_unit, missing_callback, parse_optional_bytes_item, }}; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -56,7 +56,7 @@ pub fn generate_wasm_bridge( /// Callbacks of an optional capability trait are replaced by a throwing /// stub when the host omits the group. The core never reaches them: it /// only holds an adapter for a capability whose `has_*` accessor is - /// true, and answers the rest with `Unsupported`. + /// true, and applies each omitted capability's absence behavior. pub struct JsBridge {{ "#, ) @@ -331,6 +331,11 @@ fn emit_result_method( &bridge_call("invoke_bytes_return", &method.name, &args, &[]), &map_err, ) + } else if matches!(ok, TypeRef::Option(inner) if is_string(inner)) { + await_chain( + &bridge_call("invoke_optional_string_return", &method.name, &args, &[]), + &map_err, + ) } else if is_optional_bytes(ok) { await_chain( &bridge_call( @@ -344,7 +349,7 @@ fn emit_result_method( ), &map_err, ) - } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) || is_scale_vector_result(ok) { formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? } else { bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); @@ -618,6 +623,7 @@ fn numeric_js_arg(name: &str, primitive: &str) -> Result { "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { Ok(format!("JsValue::from_f64(f64::from({name}))")) } + "u64" | "i64" => Ok(format!("js_sys::BigInt::from({name}).into()")), "bool" => Ok(format!("JsValue::from_bool({name})")), other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), } diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index c54de1a01..a8da38b90 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -915,7 +915,7 @@ fn method_is_included( ) -> Result { wire_id_for_method(trait_def, method)?; - let wrapper_names = method_versioned_wrappers(method, wrappers); + let wrapper_names = method_versioned_wrappers(method, wrappers, true); Ok( wrapper_names.is_empty() || method_wire_version(method, wrappers, target_version)?.is_some(), @@ -932,22 +932,15 @@ fn wire_id_for_method(trait_def: &TraitDef, method: &MethodDef) -> Result { }) } -/// Picks the wrapper variant the generated client emits on the wire for a -/// given method. Returns the highest variant supported by every wrapper the -/// method touches and that is ≤ `target_version`. Returns `None` when no -/// shared variant exists at or below the cap (the method is not exposed by -/// the client). -/// -/// Picking the **highest** variant exposes the newest request/response shape -/// the host is known to support. Hosts that only implement an older codec -/// version still receive a wire envelope they understand because every -/// wrapper keeps each `Vn` variant at `#[codec(index = n - 1)]`. +/// Picks the highest common request/success variant at or below the client cap. +/// Domain-error envelopes evolve independently and may retain an older version; +/// each must have a variant at or below the selected request version. fn method_wire_version( method: &MethodDef, wrappers: &BTreeMap, target_version: u32, ) -> Result> { - let wrapper_names = method_versioned_wrappers(method, wrappers); + let wrapper_names = method_versioned_wrappers(method, wrappers, false); if wrapper_names.is_empty() { return Ok(None); } @@ -972,14 +965,23 @@ fn method_wire_version( }); } - Ok(candidates.and_then(|versions| versions.into_iter().max())) + let error = match &method.return_type { + ReturnType::Result { err, .. } => err, + ReturnType::Subscription { interrupt, .. } => interrupt, + }; + let error_wrapper = versioned_wrapper_for(call_error_inner(error).unwrap_or(error), wrappers) + .map(|(_, wrapper)| wrapper); + Ok(candidates.and_then(|versions| { + versions.into_iter().rev().find(|version| { + error_wrapper + .is_none_or(|wrapper| wrapper.variants.range(..=*version).next_back().is_some()) + }) + })) } -/// For each versioned wrapper, the set of wire versions the generated client -/// actually emits. Each method picks one wire version via [`method_wire_version`]; -/// every wrapper it touches gets that version recorded here. Wrappers that no -/// included method references end up absent from the map and can be elided -/// from the emitted types altogether. +/// Records each leg's actual selected version. Error wrappers can remain below +/// the request/success version, without hiding the method or inventing variants. +/// Wrappers unused by an included method can be elided from emitted types. fn versioned_wrapper_emit_versions( api: &ApiDefinition, wrappers: &BTreeMap, @@ -994,8 +996,14 @@ fn versioned_wrapper_emit_versions( let Some(wire_version) = method_wire_version(method, wrappers, target_version)? else { continue; }; - for wrapper_name in method_versioned_wrappers(method, wrappers) { - emit.entry(wrapper_name).or_default().insert(wire_version); + for wrapper_name in method_versioned_wrappers(method, wrappers, true) { + let selected = *wrappers[&wrapper_name] + .variants + .range(..=wire_version) + .next_back() + .expect("included method has a compatible version for every leg") + .0; + emit.entry(wrapper_name).or_default().insert(selected); } } } @@ -1005,6 +1013,7 @@ fn versioned_wrapper_emit_versions( fn method_versioned_wrappers( method: &MethodDef, wrappers: &BTreeMap, + include_errors: bool, ) -> Vec { let mut names = Vec::new(); for param in &method.params { @@ -1013,19 +1022,23 @@ fn method_versioned_wrappers( match &method.return_type { ReturnType::Result { ok, err } => { collect_type_versioned_wrappers(ok, wrappers, &mut names); - collect_type_versioned_wrappers( - call_error_inner(err).unwrap_or(err), - wrappers, - &mut names, - ); + if include_errors { + collect_type_versioned_wrappers( + call_error_inner(err).unwrap_or(err), + wrappers, + &mut names, + ); + } } ReturnType::Subscription { item, interrupt } => { collect_type_versioned_wrappers(item, wrappers, &mut names); - collect_type_versioned_wrappers( - call_error_inner(interrupt).unwrap_or(interrupt), - wrappers, - &mut names, - ); + if include_errors { + collect_type_versioned_wrappers( + call_error_inner(interrupt).unwrap_or(interrupt), + wrappers, + &mut names, + ); + } } } names.sort(); @@ -1184,7 +1197,7 @@ fn generate_client_view( .collect::>(); for method in &methods { if let Some(version) = method_wire_version(method, &wrappers, target_version)? { - uses_hex_string |= method_versioned_wrappers(method, &wrappers) + uses_hex_string |= method_versioned_wrappers(method, &wrappers, true) .iter() .filter_map(|name| wrappers[name].variants.get(&version)) .any(|variant| match &variant.kind { @@ -1747,9 +1760,16 @@ fn emit_response( let version = wire_version.ok_or_else(|| { anyhow::anyhow!("versioned wrapper `{wrapper_name}` has no selected wire version") })?; - let wrapper = wrapper.variants.get(&version).ok_or_else(|| { - anyhow::anyhow!("versioned wrapper `{wrapper_name}` has no V{version} variant") - })?; + let wrapper = wrapper + .variants + .range(..=version) + .next_back() + .map(|(_, wrapper)| wrapper) + .ok_or_else(|| { + anyhow::anyhow!( + "versioned wrapper `{wrapper_name}` has no variant through V{version}" + ) + })?; return match &wrapper.kind { VersionedKind::Unit => Ok(ResponseEmission { inner_type_ts: "undefined".to_string(), diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 2cfdba9cd..b1d40c90c 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -19,9 +19,10 @@ use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; use crate::platform_callbacks::{ - callback_namespace, collect_local_bridge_payload_types, composed_traits, optional_trait_names, - platform_trait_names, raw_callback_adapter_name, raw_callback_name, raw_callback_type_name, - raw_callback_wire_name, stream_item, to_camel_case, trait_object_return_name, + callback_namespace, collect_local_bridge_payload_types, composed_traits, + is_scale_vector_result, optional_trait_names, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, }; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -201,6 +202,25 @@ fn emit_wasm_adapter( let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); let mut runtime_types: BTreeSet = BTreeSet::new(); let mut support_imports: BTreeSet = BTreeSet::new(); + let mut result_codecs = Vec::new(); + if traits + .iter() + .any(|trait_def| trait_def.name == "HopProvider") + { + support_imports.insert("unavailableHopProvider".to_string()); + } + if traits + .iter() + .any(|trait_def| trait_def.name == "NativeChatFilesHost") + { + support_imports.insert("unavailableNativeChatFilesHost".to_string()); + } + if traits + .iter() + .any(|trait_def| trait_def.name == "CoinageWalletHost") + { + support_imports.insert("coinageWalletHostAdapter".to_string()); + } for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -219,6 +239,13 @@ fn emit_wasm_adapter( } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + if is_scale_vector_result(ok) { + result_codecs.push(format!( + "const {}ResultCodec = {};", + raw_callback_name(method), + local_codec_expr(ok)?, + )); + } collect_codec_imports(ok, codec_types, &mut imports); collect_local_codec_names( ok, @@ -257,6 +284,9 @@ fn emit_wasm_adapter( "#, ) .unwrap(); + if !result_codecs.is_empty() { + out.push_str("import * as S from \"@parity/truapi/scale\";\n"); + } emit_import_block(&mut out, false, "@parity/truapi", &imports); emit_import_block(&mut out, true, "@parity/truapi", &extra_types); emit_import_block( @@ -280,6 +310,12 @@ fn emit_wasm_adapter( if !runtime_types.is_empty() || !support_imports.is_empty() { out.push('\n'); } + for codec in &result_codecs { + writeln!(out, "{codec}").unwrap(); + } + if !result_codecs.is_empty() { + out.push('\n'); + } let optional_traits = optional_trait_names(definition); out.push_str(&emit_raw_callbacks( &traits, @@ -303,7 +339,29 @@ fn emit_wasm_adapter( // narrowed reference rather than re-reading a possibly-absent member. for name in &optional_traits { let namespace = callback_namespace(name); - writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(); + if name == "CoinageWalletHost" { + writeln!( + out, + " const {namespace} = coinageWalletHostAdapter(callbacks.{namespace});" + ) + .unwrap(); + } else { + writeln!(out, " const {namespace} = callbacks.{namespace};").unwrap(); + } + } + // HOP remains a required Rust capability. Older JS embeddings get its + // explicit unavailable implementation, not a phantom working transport. + if traits + .iter() + .any(|trait_def| trait_def.name == "HopProvider") + { + out.push_str(" const hop = callbacks.hop ?? unavailableHopProvider;\n"); + } + if traits + .iter() + .any(|trait_def| trait_def.name == "NativeChatFilesHost") + { + out.push_str(" const nativeChatFiles = callbacks.nativeChatFiles ?? unavailableNativeChatFilesHost;\n"); } out.push_str(" return {\n"); for trait_def in &traits { @@ -506,7 +564,7 @@ fn emit_worker_callbacks( /** * Optional capabilities the main-thread host actually serves. A * capability left out here is not proxied into the worker, so the - * core answers its product calls with `Unsupported`. + * core applies that capability's absence behavior. */ export interface OptionalCapabilities {{ {members} @@ -947,6 +1005,7 @@ fn raw_primitive_ts(p: &str) -> String { match p { "bool" => "boolean".to_string(), "str" => "string".to_string(), + "u64" | "i64" | "u128" | "i128" => "bigint".to_string(), _ => "number".to_string(), } } @@ -996,8 +1055,8 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } /// The call argument expression for one Rust param. Codec types arrive as -/// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and -/// are widened to `bigint`; everything else passes through. Arrow parameter +/// `Uint8Array` and are decoded; wide integers cross as lossless JS `bigint`; +/// everything else passes through. Arrow parameter /// types are left to contextual inference from `RawCallbacks`, so only the /// argument expression varies. fn adapter_arg( @@ -1012,9 +1071,6 @@ fn adapter_arg( { format!("{ty}.dec({name})") } - TypeRef::Primitive(p) if matches!(p.as_str(), "u64" | "u128" | "i64" | "i128") => { - format!("BigInt({name})") - } _ => name, } } @@ -1045,17 +1101,27 @@ fn emit_adapter_entry( let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); let namespace = callback_namespace(&trait_def.name); // Optional capabilities are hoisted into a local binding by the caller. - let host_method = if optional { - format!("{namespace}.{raw}") + let host_method = if optional + || matches!( + trait_def.name.as_str(), + "HopProvider" | "NativeChatFilesHost" + ) { + format!("{namespace}.{}", raw_callback_name(method)) } else { - format!("callbacks.{namespace}.{raw}") + format!("callbacks.{namespace}.{}", raw_callback_name(method)) }; if trait_object_return_name(method, platform_trait_names).is_some() { let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); - return Ok(format!( - "{raw}: {adapter}(callbacks.{}),", - callback_namespace(&trait_def.name) - )); + let host = if optional + || matches!( + trait_def.name.as_str(), + "HopProvider" | "NativeChatFilesHost" + ) { + namespace + } else { + format!("callbacks.{namespace}") + }; + return Ok(format!("{raw}: {adapter}({host}),")); } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { @@ -1096,8 +1162,14 @@ fn validate_adapter_codec_boundary( match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + // Vector results have an inline SCALE codec. Validate their + // element as a direct codec rather than a nested container. + let value = match ok { + TypeRef::Vec(inner) if is_scale_vector_result(ok) => inner.as_ref(), + other => other, + }; validate_adapter_codec_boundary_type( - ok, + value, codec_types, local_codec_types, "return value", @@ -1134,11 +1206,9 @@ fn validate_adapter_codec_boundary_type( Ok(()) } -/// The WASM adapter only knows how to translate a direct codec payload: -/// `Codec` maps to raw `Uint8Array` and the adapter emits `Codec.dec/enc`. -/// Containers such as `Vec`, `Option`, or `(Codec, ...)` would -/// still be declared as raw bytes at the WASM boundary, but no generated code -/// knows how to encode or decode the container. Reject those shapes at codegen. +/// Named codec payload parameters must cross directly. Vector results use an +/// emitted inline SCALE codec and validate their elements separately; other +/// containers of named codecs remain unsupported. fn contains_non_direct_codec_type( ty: &TypeRef, codec_types: &BTreeSet, @@ -1195,6 +1265,9 @@ fn adapter_unary_impl( { format!("{ty}.enc(await {call})") } + ty if is_scale_vector_result(ty) => { + format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) + } _ => format!("await {call}"), }; Ok(format!("async ({params}) => {body}")) @@ -1611,9 +1684,12 @@ fn emit_host_callback_composites( ); } // A host may leave out an optional capability entirely; the core then - // answers the matching product calls with `Unsupported`. + // applies that capability's absence behavior. + // Required Rust capabilities with explicit unavailable embedding backends. let mark = |trait_name: &String| { - if optional_traits.contains(trait_name) { + if optional_traits.contains(trait_name) + || matches!(trait_name.as_str(), "HopProvider" | "NativeChatFilesHost") + { "?" } else { "" @@ -1909,49 +1985,23 @@ mod tests { } } - fn assert_rejects_compound_codec(method: PlatformMethod, expected: &str) { + fn assert_rejects_compound_codec(method: PlatformMethod) { let definition = platform_with_method(method); - let err = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()) - .expect_err("compound codec boundary should fail codegen") - .to_string(); - assert!( - err.contains("unsupported compound codec type"), - "unexpected error: {err}" - ); - assert!(err.contains(expected), "unexpected error: {err}"); + assert!(emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).is_err()); } #[test] - fn wasm_adapter_rejects_compound_codec_return_shapes() { + fn wasm_adapter_rejects_unsupported_compound_codec_return_shapes() { let codec = named("HostFeatureSupportedResponse"); - assert_rejects_compound_codec( - method_with_return(TypeRef::Vec(Box::new(codec.clone()))), - "return value", - ); - assert_rejects_compound_codec( - method_with_return(TypeRef::Option(Box::new(codec.clone()))), - "return value", - ); - assert_rejects_compound_codec( - method_with_return(TypeRef::Tuple(vec![codec])), - "return value", - ); + assert_rejects_compound_codec(method_with_return(TypeRef::Option(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_return(TypeRef::Tuple(vec![codec]))); } #[test] fn wasm_adapter_rejects_compound_codec_param_shapes() { let codec = named("HostFeatureSupportedRequest"); - assert_rejects_compound_codec( - method_with_param(TypeRef::Vec(Box::new(codec.clone()))), - "parameter `request`", - ); - assert_rejects_compound_codec( - method_with_param(TypeRef::Option(Box::new(codec.clone()))), - "parameter `request`", - ); - assert_rejects_compound_codec( - method_with_param(TypeRef::Tuple(vec![codec])), - "parameter `request`", - ); + assert_rejects_compound_codec(method_with_param(TypeRef::Vec(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_param(TypeRef::Option(Box::new(codec.clone())))); + assert_rejects_compound_codec(method_with_param(TypeRef::Tuple(vec![codec]))); } } diff --git a/rust/crates/truapi-coinage/Cargo.toml b/rust/crates/truapi-coinage/Cargo.toml new file mode 100644 index 000000000..7d1ac2b33 --- /dev/null +++ b/rust/crates/truapi-coinage/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "truapi-coinage" +version = "0.1.0" +edition = "2024" +publish = false +license = "AGPL-3.0-only" +description = "Portable Host-owned Coinage selection, send, claim and durable recovery engine" +repository = "https://github.com/paritytech/host-rust-core" + +[package.metadata.provenance] +source = "https://github.com/paritytech/brevity-dozer" +revision = "d504259b60b88ca42f70a8378186a714887ef19f" +paths = ["core/crates/brevity-coinage", "core/crates/brevity-chain/src/pallets/members.rs", "core/crates/brevity-chain/src/tx_extensions.rs"] +license-notice = "NOTICE" + +[dependencies] +async-trait = "0.1" +blake2 = "0.10" +blake2b_simd = "1" +futures = "0.3" +futures-timer = "3" +getrandom = { version = "0.2", features = ["js"] } +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +parking_lot = "0.12" +rand = "0.8" +rand_chacha = "0.3" +schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +serde_json = "1" +substrate-bip39 = { version = "0.6", default-features = false } +tokio = { version = "1", default-features = false, features = ["sync"] } +tracing = "0.1" +web-time = "1" +zeroize = { version = "1", features = ["zeroize_derive"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +futures-timer = { version = "3", features = ["wasm-bindgen"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +subxt-signer = { version = "0.44.3", default-features = false, features = ["std", "sr25519"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } + +[lints] diff --git a/rust/crates/truapi-coinage/LICENSE b/rust/crates/truapi-coinage/LICENSE new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-coinage/LICENSE @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-coinage/LICENSE-MIT b/rust/crates/truapi-coinage/LICENSE-MIT new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/rust/crates/truapi-coinage/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/crates/truapi-coinage/NOTICE b/rust/crates/truapi-coinage/NOTICE new file mode 100644 index 000000000..b1fc0e013 --- /dev/null +++ b/rust/crates/truapi-coinage/NOTICE @@ -0,0 +1,42 @@ +truapi-coinage — AGPL-3.0-only + +This crate is a modified extraction of Coinage domain code from +https://github.com/paritytech/brevity-dozer +revision d504259b60b88ca42f70a8378186a714887ef19f: + core/crates/brevity-coinage/src/ + core/crates/brevity-chain/src/pallets/members.rs + core/crates/brevity-chain/src/tx_extensions.rs +Copyright the Brevity contributors; the upstream repository identifies its +license as GNU Affero General Public License version 3. + +Modified 2026-09-20: separated database, RPC, app diagnostics and native +cryptographic adapters; injected Host execution and portable timers; retained +native Coinage denomination/send/claim/recovery algorithms and wire layouts. +The extraction retains AGPL-3.0-only licensing. It is not relicensed MIT. +The complete GNU Affero General Public License is in LICENSE. No warranty. + +The sr25519 root entropy and hard-junction derivation in src/keys.rs is +adapted from the MIT-licensed host-rust-core source: + rust/crates/truapi-server/src/host_logic/product_account.rs + revision 0926e881d4520ca73c2de82d7ecd91819c3e645f +Copyright (c) 2026 Parity Technologies. +Its complete retained MIT notice is in LICENSE-MIT; the combined crate remains +AGPL-3.0-only. + +Modified 2026-09-20: deliberately replaced the extracted legacy Coinage +derivation with the current hosts/ios/Packages/Coinage main-purse layout: + coins: //coinage//4294967295//0/ (soft final junction) + vouchers: //coinage-ring-vrf//4294967295//0// (all hard junctions). +The fixed purse is u32::MAX and page is zero. Coin roots retain Substrate +BIP-39 expansion; vouchers fold keyed BLAKE2b-256 directly over root entropy. +The reference factories are Sources/KeypairFactory/CoinKeypairFactory.swift +and VoucherKeypairFactory.swift; root expansion follows the current iOS +KeyDerivation package. This is an intentional derivation adaptation, not +compatibility with legacy persisted coin/voucher indices. Host storage must +isolate derivation layouts. Product-account and Chat transport keys remain +independent. + +This library does not provide product permission or a secret-bearing guest +API. Repositories and submission/proof adapters must be owned by the Host's +signing authority. A distributor/operator must satisfy the applicable AGPL +Corresponding Source and network-interaction obligations. diff --git a/rust/crates/truapi-coinage/src/allocator.rs b/rust/crates/truapi-coinage/src/allocator.rs new file mode 100644 index 000000000..991017688 --- /dev/null +++ b/rust/crates/truapi-coinage/src/allocator.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Coin and voucher allocation: draws the next derivation index from the +//! [`CoinageIndexStore`] — the atomic read-increment-write counter — and +//! materializes the local record. A fresh voucher's `ready_at` is +//! `allocated_at + delay`, where the delay is drawn uniformly from +//! `0..=MAX_VOUCHER_WAIT_TIME` (6 h) to decorrelate onboarding batches; its +//! privacy level starts Degraded until the recycler ring proves large enough. + +use std::sync::Arc; + +use crate::clock::Clock; +use crate::constants::MAX_VOUCHER_WAIT_TIME; +use crate::index_store::{CoinageIndexStore, IndexKind}; +use crate::model::{ + Coin, CoinState, Voucher, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState, +}; + +/// The voucher readiness-delay source. Injected so tests pin it; the +/// production impl draws uniform jitter. +pub trait VoucherDelayProvider: Send + Sync { + /// A delay in `0..=MAX_VOUCHER_WAIT_TIME` milliseconds. + fn ready_delay_ms(&self) -> i64; +} + +/// Production jitter from the OS entropy-backed hasher seed. Not +/// cryptographic — the delay only needs to be unpredictable enough to +/// decorrelate onboarding batches, matching the iOS +/// `TimeInterval.random(in: 0...maxVoucherWaitTime)`. +pub struct SystemJitterDelayProvider; + +impl VoucherDelayProvider for SystemJitterDelayProvider { + fn ready_delay_ms(&self) -> i64 { + use std::hash::{BuildHasher, Hasher, RandomState}; + let raw = RandomState::new().build_hasher().finish(); + let span = MAX_VOUCHER_WAIT_TIME.as_millis() as u64 + 1; + (raw % span) as i64 + } +} + +/// Fixed delay for tests. +pub struct FixedDelayProvider(pub i64); + +impl VoucherDelayProvider for FixedDelayProvider { + fn ready_delay_ms(&self) -> i64 { + self.0 + } +} + +pub struct CoinAllocator { + index_store: Arc, +} + +impl CoinAllocator { + pub fn new(index_store: Arc) -> Self { + Self { index_store } + } + + pub async fn allocate(&self, exponent: i16) -> Result { + let derivation_index = self.index_store.get_next_index(IndexKind::Coin).await?; + Ok(Coin { + exponent, + derivation_index, + age: None, + state: CoinState::Available, + }) + } +} + +/// Allocates fresh vouchers: next index, `ready_at = now + jitter`, +/// `Unlocated` remote state and `Degraded` privacy until a later scan +/// reconciles the voucher's on-chain position. +pub struct VoucherAllocator { + index_store: Arc, + delay: Arc, + clock: Arc, +} + +impl VoucherAllocator { + pub fn new( + index_store: Arc, + delay: Arc, + clock: Arc, + ) -> Self { + Self { + index_store, + delay, + clock, + } + } + + pub async fn allocate(&self, exponent: i16) -> Result { + let derivation_index = self.index_store.get_next_index(IndexKind::Voucher).await?; + let allocated_at_ms = self.clock.now_ms(); + Ok(Voucher { + exponent, + derivation_index, + allocated_at_ms, + ready_at_ms: allocated_at_ms.saturating_add(self.delay.ready_delay_ms()), + remote_state: VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Degraded, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::FixedClock; + use crate::index_store::InMemoryCoinageIndexStore; + + #[tokio::test] + async fn coin_allocation_draws_sequential_indices() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let allocator = CoinAllocator::new(Arc::clone(&store) as Arc<_>); + let first = allocator.allocate(3).await.unwrap(); + let second = allocator.allocate(0).await.unwrap(); + assert_eq!( + (first.derivation_index, second.derivation_index), + (0, 1), + "COINB-051 sequence" + ); + assert_eq!(first.exponent, 3); + assert_eq!(first.age, None, "age unknown until first sync"); + assert_eq!(first.state, CoinState::Available); + } + + #[tokio::test] + async fn voucher_allocation_applies_the_readiness_jitter() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let allocator = VoucherAllocator::new( + Arc::clone(&store) as Arc<_>, + Arc::new(FixedDelayProvider(5_000)), + Arc::new(FixedClock(1_000)), + ); + let voucher = allocator.allocate(2).await.unwrap(); + assert_eq!(voucher.derivation_index, 0); + assert_eq!(voucher.allocated_at_ms, 1_000); + assert_eq!(voucher.ready_at_ms, 6_000, "allocated_at + delay"); + assert_eq!(voucher.remote_state, VoucherRemoteState::Unlocated); + assert_eq!(voucher.local_state, VoucherLocalState::Available); + assert_eq!( + voucher.privacy, + VoucherPrivacyLevel::Degraded, + "degraded until the ring proves large enough" + ); + } + + /// Coin and voucher counters never share an index space. + #[tokio::test] + async fn allocators_use_independent_counters() { + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let coins = CoinAllocator::new(Arc::clone(&store) as Arc<_>); + let vouchers = VoucherAllocator::new( + Arc::clone(&store) as Arc<_>, + Arc::new(FixedDelayProvider(0)), + Arc::new(FixedClock(0)), + ); + coins.allocate(0).await.unwrap(); + coins.allocate(0).await.unwrap(); + assert_eq!(vouchers.allocate(0).await.unwrap().derivation_index, 0); + } + + #[test] + fn system_jitter_stays_inside_the_wait_window() { + let provider = SystemJitterDelayProvider; + for _ in 0..64 { + let delay = provider.ready_delay_ms(); + assert!(delay >= 0); + assert!(delay <= MAX_VOUCHER_WAIT_TIME.as_millis() as i64); + } + } +} diff --git a/rust/crates/truapi-coinage/src/balance.rs b/rust/crates/truapi-coinage/src/balance.rs new file mode 100644 index 000000000..b25336590 --- /dev/null +++ b/rust/crates/truapi-coinage/src/balance.rs @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::denomination::DenominationBreakdownContext; +use crate::model::{ + Coin, CoinState, EffectivePrivacy, Voucher, VoucherLocalState, VoucherPrivacyLevel, +}; + +/// The three balance buckets, in planks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BalanceBuckets { + pub full_privacy_planks: u128, + pub degraded_planks: u128, + pub locked_planks: u128, +} + +impl BalanceBuckets { + pub fn total_planks(&self) -> u128 { + self.full_privacy_planks + .saturating_add(self.degraded_planks) + .saturating_add(self.locked_planks) + } +} + +pub fn compute_balance( + coins: &[Coin], + vouchers: &[Voucher], + context: &DenominationBreakdownContext, + now_ms: i64, +) -> BalanceBuckets { + let mut buckets = BalanceBuckets::default(); + for coin in coins { + let value = context.value_in_planks(coin.exponent); + match coin.state { + CoinState::Available if coin.is_expiring_soon() => { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } + CoinState::Available => { + buckets.full_privacy_planks = buckets.full_privacy_planks.saturating_add(value); + } + CoinState::Recycling => { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } + CoinState::PendingTransfer | CoinState::Spent => {} + } + } + for voucher in vouchers { + if voucher.local_state != VoucherLocalState::Available { + continue; + } + let value = context.value_in_planks(voucher.exponent); + if !voucher.remote_state.is_in_recycler() { + buckets.locked_planks = buckets.locked_planks.saturating_add(value); + } else { + match voucher.effective_privacy(now_ms) { + EffectivePrivacy::Full => { + buckets.full_privacy_planks = buckets.full_privacy_planks.saturating_add(value); + } + EffectivePrivacy::Degraded => { + buckets.degraded_planks = buckets.degraded_planks.saturating_add(value); + } + } + } + } + buckets +} + +pub fn next_unlock_at_ms(vouchers: &[Voucher], now_ms: i64) -> Option { + vouchers + .iter() + .filter(|v| { + v.privacy == VoucherPrivacyLevel::Full + && v.local_state == VoucherLocalState::Available + && v.remote_state.is_in_recycler() + && v.ready_at_ms > now_ms + }) + .map(|v| v.ready_at_ms) + .min() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::VoucherRemoteState; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn coin(index: u32, exponent: i16, age: Option, state: CoinState) -> Coin { + Coin { + exponent, + derivation_index: index, + age, + state, + } + } + + fn voucher( + index: u32, + exponent: i16, + remote: VoucherRemoteState, + local: VoucherLocalState, + privacy: VoucherPrivacyLevel, + ready_at_ms: i64, + ) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms, + remote_state: remote, + local_state: local, + privacy, + } + } + + const NOW: i64 = 10_000; + const IN_RECYCLER: VoucherRemoteState = VoucherRemoteState::InRecycler { recycler_index: 0 }; + + #[test] + fn decomposition_with_known_coin_and_voucher_sets() { + let coins = [ + coin(1, 0, Some(3), CoinState::Available), // 10 → full + coin(2, 1, None, CoinState::Available), // 20 → full (unsynced age) + coin(3, 2, Some(14), CoinState::Available), // 40 → locked (expiring) + coin(4, 3, Some(2), CoinState::Recycling), // 80 → locked + coin(5, 4, Some(2), CoinState::PendingTransfer), // reserved → nowhere + coin(6, 4, Some(2), CoinState::Spent), // gone → nowhere + ]; + let vouchers = [ + // 10 → full (ready, full privacy, in recycler). + voucher( + 10, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW, + ), + // 20 → degraded (full privacy but not ready yet). + voucher( + 11, + 1, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 1, + ), + // 40 → degraded (degraded at onboarding). + voucher( + 12, + 2, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Degraded, + 0, + ), + // 80 → locked (not in a recycler yet). + voucher( + 13, + 3, + VoucherRemoteState::Onboarding, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + 0, + ), + // reserved → nowhere. + voucher( + 14, + 4, + IN_RECYCLER, + VoucherLocalState::PendingTransfer, + VoucherPrivacyLevel::Full, + 0, + ), + ]; + let buckets = compute_balance(&coins, &vouchers, &ctx(), NOW); + assert_eq!(buckets.full_privacy_planks, 10 + 20 + 10); + assert_eq!(buckets.degraded_planks, 20 + 40); + assert_eq!(buckets.locked_planks, 40 + 80 + 80); + assert_eq!(buckets.total_planks(), 300); + } + + #[test] + fn empty_wallet_is_all_zero() { + assert_eq!( + compute_balance(&[], &[], &ctx(), NOW), + BalanceBuckets::default() + ); + } + + #[test] + fn next_unlock_picks_the_earliest_upgradeable_voucher() { + let vouchers = [ + voucher( + 1, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 500, + ), + voucher( + 2, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 100, + ), + // Already ready → no timer needed for it. + voucher( + 3, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW, + ), + // Degraded privacy never upgrades → ignored. + voucher( + 4, + 0, + IN_RECYCLER, + VoucherLocalState::Available, + VoucherPrivacyLevel::Degraded, + NOW + 50, + ), + // Not in a recycler → ignored. + voucher( + 5, + 0, + VoucherRemoteState::Unlocated, + VoucherLocalState::Available, + VoucherPrivacyLevel::Full, + NOW + 10, + ), + ]; + assert_eq!(next_unlock_at_ms(&vouchers, NOW), Some(NOW + 100)); + assert_eq!(next_unlock_at_ms(&vouchers[2..], NOW), None); + } +} diff --git a/rust/crates/truapi-coinage/src/claim.rs b/rust/crates/truapi-coinage/src/claim.rs new file mode 100644 index 000000000..41aa15488 --- /dev/null +++ b/rust/crates/truapi-coinage/src/claim.rs @@ -0,0 +1,755 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use tokio::sync::watch; +use tracing::warn; + +use crate::claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::denomination::DenominationBreakdownContext; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimStatus { + /// Waiting for the send to appear on-chain. + Detecting, + /// Coins confirmed on-chain, awaiting claim. + Sent, + /// Claim extrinsic in flight. + Claiming, + Finished { + claimed_amount: u128, + }, + Error, +} + +impl ClaimStatus { + pub fn is_terminal(&self) -> bool { + matches!(self, ClaimStatus::Finished { .. } | ClaimStatus::Error) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SendConfirmation { + OnChain, + AlreadyClaimed, +} + +#[async_trait] +pub trait TransferSendVerifying: Send + Sync { + /// Resolves when the memo's coins are on-chain; errors on timeout. + async fn await_send_on_chain( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String>; + + /// Resolves when the memo's coins have left the chain (claimed). + async fn await_claim_on_chain( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String>; + + async fn await_send_or_claimed( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result; +} + +#[async_trait] +pub trait ClaimExecutor: Send + Sync { + async fn claim( + &self, + memo_key: &[u8; 32], + message_id: &str, + ) -> Result, String>; +} + +pub fn claimed_amount_from_plan(plan: &ClaimPlan, context: &DenominationBreakdownContext) -> u128 { + if plan.entries.is_empty() { + return plan.total_value; + } + plan.entries.iter().fold(0u128, |acc, entry| { + acc.saturating_add(context.value_in_planks(entry.exponent)) + }) +} + +#[derive(Default)] +pub struct ClaimStatusStore { + subjects: Mutex>>, + terminal: Mutex>, +} + +impl ClaimStatusStore { + /// Emits the current status immediately (the watch receiver's seed + /// value), then streams updates for live claims. For a terminal + /// message the receiver completes right after that seed read. + /// Returns `None` for a message id that was never seen. + pub fn watch_status(&self, message_id: &str) -> Option> { + if let Some(sender) = self.subjects.lock().get(message_id) { + return Some(sender.subscribe()); + } + let terminal = self.terminal.lock(); + let status = terminal.get(message_id)?; + // One-shot: seed a fresh channel and drop the sender so + // `changed` completes immediately after the seed read. + let (tx, rx) = watch::channel(status.clone()); + drop(tx); + Some(rx) + } + + /// Current status snapshot. + pub fn status(&self, message_id: &str) -> Option { + if let Some(sender) = self.subjects.lock().get(message_id) { + return Some(sender.borrow().clone()); + } + self.terminal.lock().get(message_id).cloned() + } + + pub fn update_status(&self, message_id: &str, status: ClaimStatus) { + let mut subjects = self.subjects.lock(); + if status.is_terminal() { + if let Some(sender) = subjects.remove(message_id) { + let _ = sender.send(status.clone()); + // Sender drops here → subscriber streams close. + } + self.terminal.lock().insert(message_id.to_string(), status); + return; + } + match subjects.get(message_id) { + Some(sender) => { + let _ = sender.send(status); + } + None => { + let (sender, _) = watch::channel(status); + subjects.insert(message_id.to_string(), sender); + } + } + } +} + +pub fn restore_persisted_statuses(plans: &[ClaimPlan], store: &ClaimStatusStore) { + for plan in plans { + let Some(message_id) = &plan.message_id else { + continue; + }; + let status = match plan.status { + ClaimPlanStatus::Processing => ClaimStatus::Detecting, + ClaimPlanStatus::Detected => ClaimStatus::Sent, + ClaimPlanStatus::Finished => ClaimStatus::Finished { + claimed_amount: plan.claimed_amount.unwrap_or(plan.total_value), + }, + ClaimPlanStatus::Error => ClaimStatus::Error, + }; + store.update_status(message_id, status); + } +} + +/// Claim orchestration errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimError { + AlreadyClaiming, + Failed(String), +} + +impl std::fmt::Display for ClaimError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClaimError::AlreadyClaiming => write!(f, "already claiming this memo"), + ClaimError::Failed(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ClaimError {} + +/// One incoming `coinageSend` to claim. +#[derive(Debug, Clone)] +pub struct IncomingClaim { + pub memo_key: [u8; 32], + pub message_id: String, + pub total_value: u128, +} + +pub struct ClaimOrchestrator { + plans: Arc, + verifier: Arc, + executor: Arc, + statuses: Arc, + context: DenominationBreakdownContext, + claiming_memos: Mutex>, +} + +impl ClaimOrchestrator { + pub fn new( + plans: Arc, + verifier: Arc, + executor: Arc, + statuses: Arc, + context: DenominationBreakdownContext, + ) -> Self { + Self { + plans, + verifier, + executor, + statuses, + context, + claiming_memos: Mutex::new(HashSet::new()), + } + } + + pub async fn restore_persisted_statuses(&self) -> Result<(), String> { + let plans = self.plans.load_all().await?; + restore_persisted_statuses(&plans, &self.statuses); + Ok(()) + } + + /// Claims one incoming memo; resolves the claimed planks. + pub async fn claim_incoming(&self, incoming: IncomingClaim) -> Result { + { + let mut claiming = self.claiming_memos.lock(); + if !claiming.insert(incoming.memo_key) { + return Err(ClaimError::AlreadyClaiming); + } + } + let _guard = ClaimingGuard { + memo_key: incoming.memo_key, + claiming: &self.claiming_memos, + }; + + let outcome = self.claim_inner(&incoming).await; + if let Err(ClaimError::Failed(message)) = &outcome { + warn!(message, message_id = incoming.message_id, "claim failed"); + if let Err(error) = self + .plans + .update_status(&incoming.memo_key, ClaimPlanStatus::Error, None) + .await + { + warn!(error, "claim error status stamp failed"); + } + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Error); + } + outcome + } + + async fn claim_inner(&self, incoming: &IncomingClaim) -> Result { + let existing = self + .plans + .plan(&incoming.memo_key) + .await + .map_err(ClaimError::Failed)?; + + match &existing { + Some(plan) if plan.status == ClaimPlanStatus::Finished => { + let claimed = plan + .claimed_amount + .unwrap_or_else(|| claimed_amount_from_plan(plan, &self.context)); + self.statuses.update_status( + &incoming.message_id, + ClaimStatus::Finished { + claimed_amount: claimed, + }, + ); + return Ok(claimed); + } + Some(_) => {} + None => { + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Detecting); + self.verifier + .await_send_on_chain(&incoming.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await + .map_err(ClaimError::Failed)?; + self.plans + .save(&ClaimPlan { + memo_key: incoming.memo_key, + message_id: Some(incoming.message_id.clone()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: incoming.total_value, + markers: crate::claim_plan::ClaimMarkers::default(), + }) + .await + .map_err(ClaimError::Failed)?; + } + } + + self.statuses + .update_status(&incoming.message_id, ClaimStatus::Claiming); + let entries = self + .executor + .claim(&incoming.memo_key, &incoming.message_id) + .await + .map_err(ClaimError::Failed)?; + + let finished = ClaimPlan { + memo_key: incoming.memo_key, + message_id: Some(incoming.message_id.clone()), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Finished, + claimed_amount: None, + total_value: incoming.total_value, + markers: crate::claim_plan::ClaimMarkers::default(), + }; + let claimed = claimed_amount_from_plan(&finished, &self.context); + self.plans + .update_status(&incoming.memo_key, ClaimPlanStatus::Finished, Some(claimed)) + .await + .map_err(ClaimError::Failed)?; + self.statuses.update_status( + &incoming.message_id, + ClaimStatus::Finished { + claimed_amount: claimed, + }, + ); + Ok(claimed) + } +} + +struct ClaimingGuard<'a> { + memo_key: [u8; 32], + claiming: &'a Mutex>, +} + +impl Drop for ClaimingGuard<'_> { + fn drop(&mut self) { + self.claiming.lock().remove(&self.memo_key); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn entry(exponent: i16) -> CodableClaimPlanEntry { + CodableClaimPlanEntry { + entry_index: 0, + exponent, + derivation_index: 1, + } + } + + fn plan(entries: Vec, status: ClaimPlanStatus) -> ClaimPlan { + ClaimPlan { + memo_key: [7; 32], + message_id: Some("m1".into()), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status, + claimed_amount: None, + total_value: 990, + markers: crate::claim_plan::ClaimMarkers::default(), + } + } + + #[test] + fn claimed_amount_sums_entry_denominations() { + let plan = plan( + vec![entry(4), entry(2), entry(0)], + ClaimPlanStatus::Finished, + ); + assert_eq!(claimed_amount_from_plan(&plan, &ctx()), 210); + } + + #[test] + fn claimed_amount_falls_back_to_total_value() { + let plan = plan(Vec::new(), ClaimPlanStatus::Finished); + assert_eq!(claimed_amount_from_plan(&plan, &ctx()), 990); + } + + #[tokio::test] + async fn watch_emits_current_then_streams_updates() { + let store = ClaimStatusStore::default(); + store.update_status("m1", ClaimStatus::Detecting); + let mut rx = store.watch_status("m1").unwrap(); + assert_eq!( + *rx.borrow(), + ClaimStatus::Detecting, + "immediate current value" + ); + store.update_status("m1", ClaimStatus::Claiming); + rx.changed().await.unwrap(); + assert_eq!(*rx.borrow(), ClaimStatus::Claiming); + } + + #[tokio::test] + async fn terminal_status_closes_and_cleans_up() { + let store = ClaimStatusStore::default(); + store.update_status("m1", ClaimStatus::Claiming); + let mut live = store.watch_status("m1").unwrap(); + + store.update_status("m1", ClaimStatus::Finished { claimed_amount: 5 }); + live.changed().await.unwrap(); + assert_eq!(*live.borrow(), ClaimStatus::Finished { claimed_amount: 5 }); + assert!( + live.changed().await.is_err(), + "stream completes after terminal" + ); + assert!( + store.subjects.lock().is_empty(), + "subject removed after terminal send" + ); + + // Late watcher: value once, then complete. + let mut late = store.watch_status("m1").unwrap(); + assert_eq!(*late.borrow(), ClaimStatus::Finished { claimed_amount: 5 }); + assert!(late.changed().await.is_err()); + assert!(store.watch_status("unknown").is_none()); + } + + #[test] + fn restore_maps_plan_statuses_to_claim_statuses() { + let store = ClaimStatusStore::default(); + let mut processing = plan(Vec::new(), ClaimPlanStatus::Processing); + processing.message_id = Some("p".into()); + let mut detected = plan(Vec::new(), ClaimPlanStatus::Detected); + detected.message_id = Some("d".into()); + let mut finished = plan(Vec::new(), ClaimPlanStatus::Finished); + finished.message_id = Some("f".into()); + finished.claimed_amount = Some(123); + let mut finished_no_amount = plan(Vec::new(), ClaimPlanStatus::Finished); + finished_no_amount.message_id = Some("f2".into()); + let mut errored = plan(Vec::new(), ClaimPlanStatus::Error); + errored.message_id = Some("e".into()); + let mut no_message = plan(Vec::new(), ClaimPlanStatus::Processing); + no_message.message_id = None; + + restore_persisted_statuses( + &[ + processing, + detected, + finished, + finished_no_amount, + errored, + no_message, + ], + &store, + ); + assert_eq!(store.status("p"), Some(ClaimStatus::Detecting)); + assert_eq!(store.status("d"), Some(ClaimStatus::Sent)); + assert_eq!( + store.status("f"), + Some(ClaimStatus::Finished { + claimed_amount: 123 + }) + ); + assert_eq!( + store.status("f2"), + Some(ClaimStatus::Finished { + claimed_amount: 990 + }), + "missing claimed_amount falls back to total_value" + ); + assert_eq!(store.status("e"), Some(ClaimStatus::Error)); + } + + // — orchestrator plumbing — + + #[derive(Default)] + struct MemPlans { + plans: Mutex>, + saves: AtomicUsize, + } + + #[async_trait] + impl ClaimPlanStore for MemPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.saves.fetch_add(1, Ordering::SeqCst); + self.plans.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.plans.lock().get(memo_key).cloned()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.plans.lock().values().cloned().collect()) + } + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.plans.lock(); + let plan = plans.get_mut(memo_key).ok_or("plan not found")?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.plans.lock().remove(memo_key); + Ok(()) + } + } + + #[derive(Default)] + struct MockVerifier { + send_awaits: AtomicUsize, + } + + #[async_trait] + impl TransferSendVerifying for MockVerifier { + async fn await_send_on_chain( + &self, + _memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String> { + assert_eq!(block_timeout, 100, "COINM-024: 100-block timeout"); + self.send_awaits.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn await_claim_on_chain( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result<(), String> { + Ok(()) + } + async fn await_send_or_claimed( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result { + Ok(SendConfirmation::OnChain) + } + } + + struct MockExecutor { + plans: Arc, + saves_seen_at_claim: AtomicUsize, + calls: AtomicUsize, + outcome: Result, String>, + release: Option>, + } + + #[async_trait] + impl ClaimExecutor for MockExecutor { + async fn claim( + &self, + memo_key: &[u8; 32], + _message_id: &str, + ) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.saves_seen_at_claim + .store(self.plans.saves.load(Ordering::SeqCst), Ordering::SeqCst); + assert!( + self.plans.plan(memo_key).await.unwrap().is_some(), + "plan must be persisted before the claim executor runs (COINA-016)" + ); + if let Some(release) = &self.release { + let mut release = release.clone(); + while !*release.borrow() { + if release.changed().await.is_err() { + break; + } + } + } + self.outcome.clone() + } + } + + fn orchestrator( + plans: Arc, + verifier: Arc, + executor: Arc, + ) -> ClaimOrchestrator { + ClaimOrchestrator::new( + plans, + verifier, + executor, + Arc::new(ClaimStatusStore::default()), + ctx(), + ) + } + + fn incoming() -> IncomingClaim { + IncomingClaim { + memo_key: [7; 32], + message_id: "m1".into(), + total_value: 990, + } + } + + #[tokio::test] + async fn fresh_claim_saves_plan_before_submitting() { + let plans = Arc::new(MemPlans::default()); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(vec![entry(2), entry(0)]), // 40 + 10 + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::clone(&verifier), + Arc::clone(&executor), + ); + + let claimed = orchestrator.claim_incoming(incoming()).await.unwrap(); + assert_eq!(claimed, 50); + assert_eq!(verifier.send_awaits.load(Ordering::SeqCst), 1); + assert!( + executor.saves_seen_at_claim.load(Ordering::SeqCst) >= 1, + "save-before-submit (COINA-016)" + ); + let stored = plans.plan(&[7; 32]).await.unwrap().unwrap(); + assert_eq!(stored.status, ClaimPlanStatus::Finished); + assert_eq!(stored.claimed_amount, Some(50)); + assert!( + stored.entries.is_empty(), + "finish is the status-only path — entries_data untouched (COINM-006)" + ); + assert_eq!( + orchestrator.statuses.status("m1"), + Some(ClaimStatus::Finished { claimed_amount: 50 }) + ); + } + + #[tokio::test] + async fn existing_plan_short_circuits_the_send_await() { + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(vec![entry(0)]), + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::clone(&verifier), + Arc::clone(&executor), + ); + + let claimed = orchestrator.claim_incoming(incoming()).await.unwrap(); + assert_eq!(claimed, 10); + assert_eq!( + verifier.send_awaits.load(Ordering::SeqCst), + 0, + "crash-recovery safety: input coins may already be spent (COINM-022)" + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 1, "claim still runs"); + } + + #[tokio::test] + async fn finished_plan_is_reported_without_reclaiming() { + let plans = Arc::new(MemPlans::default()); + let mut finished = plan(Vec::new(), ClaimPlanStatus::Finished); + finished.claimed_amount = Some(321); + plans.save(&finished).await.unwrap(); + let verifier = Arc::new(MockVerifier::default()); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(Vec::new()), + release: None, + }); + let orchestrator = orchestrator(plans, verifier.clone(), Arc::clone(&executor)); + + assert_eq!(orchestrator.claim_incoming(incoming()).await.unwrap(), 321); + assert_eq!(verifier.send_awaits.load(Ordering::SeqCst), 0); + assert_eq!(executor.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn concurrent_claim_for_the_same_memo_is_rejected() { + let (release_tx, release_rx) = watch::channel(false); + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Ok(Vec::new()), + release: Some(release_rx), + }); + let orchestrator = Arc::new(orchestrator( + plans, + Arc::new(MockVerifier::default()), + executor, + )); + + let first = tokio::spawn({ + let orchestrator = Arc::clone(&orchestrator); + async move { orchestrator.claim_incoming(incoming()).await } + }); + // Let the first claim reach the stalled executor. + for _ in 0..32 { + tokio::task::yield_now().await; + } + assert_eq!( + orchestrator.claim_incoming(incoming()).await, + Err(ClaimError::AlreadyClaiming) + ); + release_tx.send(true).unwrap(); + first.await.unwrap().unwrap(); + assert!(orchestrator.claim_incoming(incoming()).await.is_ok()); + } + + #[tokio::test] + async fn failed_claim_stamps_error() { + let plans = Arc::new(MemPlans::default()); + plans + .save(&plan(Vec::new(), ClaimPlanStatus::Processing)) + .await + .unwrap(); + let executor = Arc::new(MockExecutor { + plans: Arc::clone(&plans), + saves_seen_at_claim: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + outcome: Err("CoinPayment: Unavailable".into()), + release: None, + }); + let orchestrator = orchestrator( + Arc::clone(&plans), + Arc::new(MockVerifier::default()), + executor, + ); + + let outcome = orchestrator.claim_incoming(incoming()).await; + assert_eq!( + outcome, + Err(ClaimError::Failed("CoinPayment: Unavailable".into())) + ); + assert_eq!( + plans.plan(&[7; 32]).await.unwrap().unwrap().status, + ClaimPlanStatus::Error + ); + assert_eq!(orchestrator.statuses.status("m1"), Some(ClaimStatus::Error)); + } +} diff --git a/rust/crates/truapi-coinage/src/claim_plan.rs b/rust/crates/truapi-coinage/src/claim_plan.rs new file mode 100644 index 000000000..2ca940def --- /dev/null +++ b/rust/crates/truapi-coinage/src/claim_plan.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct CodableClaimPlanEntry { + pub entry_index: i16, + /// The destination coin's denomination exponent. + pub exponent: i16, + /// The destination coin's derivation index. + pub derivation_index: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimPlanStatus { + Processing, + /// Coins confirmed on-chain (outgoing: awaiting recipient claim; + /// incoming: ready to submit the claim extrinsic). + Detected, + Finished, + Error, +} + +impl ClaimPlanStatus { + pub fn as_raw(self) -> i64 { + match self { + ClaimPlanStatus::Processing => 0, + ClaimPlanStatus::Detected => 1, + ClaimPlanStatus::Finished => 2, + ClaimPlanStatus::Error => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => ClaimPlanStatus::Processing, + 1 => ClaimPlanStatus::Detected, + 2 => ClaimPlanStatus::Finished, + 3 => ClaimPlanStatus::Error, + _ => return None, + }) + } +} + +/// Durable per-entry progress for a claim of externally supplied secrets. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClaimMarkers { + /// Entries whose transfer this wallet submitted, recorded before submission. + pub submitted: Vec, + /// Entries absent at finalized state that this wallet never submitted: + /// their source was spent elsewhere, so they can never be credited. + pub forfeited: Vec, + /// Total value of the forfeited entries. + pub forfeited_value: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimPlan { + pub memo_key: [u8; 32], + /// The chat message carrying the memo, when known. + pub message_id: Option, + pub entries: Vec, + pub outgoing_public_keys: Vec<[u8; 32]>, + /// Best-effort finalized snapshot captured before durable chat + /// acceptance. Exact/pass-through coins may already be visible here, + /// which provides historical evidence for an ultra-fast recipient claim. + pub detection_anchor: Option<[u8; 32]>, + pub status: ClaimPlanStatus, + /// Value of the processed entry prefix, including forfeited entries. + pub claimed_amount: Option, + pub total_value: u128, + pub markers: ClaimMarkers, +} + +impl ClaimPlan { + /// Value actually credited to this wallet: the processed prefix without + /// forfeited entries. `None` when nothing was processed or the markers + /// exceed the prefix. + pub fn credited_amount(&self) -> Option { + self.claimed_amount? + .checked_sub(self.markers.forfeited_value) + } +} + +/// SCALE-encode the entries blob for `claim_plans.entries_data`. +pub fn encode_claim_plan_entries(entries: &[CodableClaimPlanEntry]) -> Vec { + entries.encode() +} + +/// Decode an `entries_data` blob; errors on malformed or trailing bytes. +pub fn decode_claim_plan_entries(bytes: &[u8]) -> Result, String> { + let mut input = bytes; + let entries = Vec::::decode(&mut input) + .map_err(|error| format!("claim plan entries: {error}"))?; + if !input.is_empty() { + return Err("claim plan entries: trailing bytes".into()); + } + Ok(entries) +} + +#[async_trait] +pub trait ClaimPlanStore: Send + Sync { + /// Full save (insert or replace, re-encoding entries). + async fn save(&self, plan: &ClaimPlan) -> Result<(), String>; + + /// Lookup by memo key. + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String>; + + async fn load_all(&self) -> Result, String>; + + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String>; + + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_scale_layout_is_pinned() { + let entry = CodableClaimPlanEntry { + entry_index: 1, + exponent: -2, + derivation_index: 0x0403_0201, + }; + // i16 LE ++ i16 LE ++ u32 LE = 8 bytes, fixed. + assert_eq!(entry.encode(), vec![1, 0, 0xFE, 0xFF, 1, 2, 3, 4]); + } + + #[test] + fn entries_blob_round_trips() { + let entries = vec![ + CodableClaimPlanEntry { + entry_index: 0, + exponent: 3, + derivation_index: 7, + }, + CodableClaimPlanEntry { + entry_index: 1, + exponent: -1, + derivation_index: 8, + }, + ]; + let blob = encode_claim_plan_entries(&entries); + assert_eq!(decode_claim_plan_entries(&blob).unwrap(), entries); + assert!( + decode_claim_plan_entries(&[]).is_err(), + "empty blob is malformed" + ); + let mut trailing = blob.clone(); + trailing.push(0); + assert!(decode_claim_plan_entries(&trailing).is_err()); + } + + #[test] + fn status_raw_round_trips() { + for status in [ + ClaimPlanStatus::Processing, + ClaimPlanStatus::Detected, + ClaimPlanStatus::Finished, + ClaimPlanStatus::Error, + ] { + assert_eq!(ClaimPlanStatus::from_raw(status.as_raw()), Some(status)); + } + assert_eq!(ClaimPlanStatus::from_raw(4), None); + } +} diff --git a/rust/crates/truapi-coinage/src/clock.rs b/rust/crates/truapi-coinage/src/clock.rs new file mode 100644 index 000000000..7c9df7c73 --- /dev/null +++ b/rust/crates/truapi-coinage/src/clock.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! The injected wall clock (unix milliseconds, the crate-wide timestamp +//! convention) — services never read system time directly, so tests pin +//! `now` deterministically. + +/// Milliseconds since the unix epoch. +pub trait Clock: Send + Sync { + fn now_ms(&self) -> i64; +} + +/// Production clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> i64 { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} + +/// Fixed clock for tests. +pub struct FixedClock(pub i64); + +impl Clock for FixedClock { + fn now_ms(&self) -> i64 { + self.0 + } +} diff --git a/rust/crates/truapi-coinage/src/constants.rs b/rust/crates/truapi-coinage/src/constants.rs new file mode 100644 index 000000000..a11326cc3 --- /dev/null +++ b/rust/crates/truapi-coinage/src/constants.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::time::Duration; + +pub const CASH_ASSET_PRECISION: u8 = 6; + +/// The shipped runtime's `Coinage.UnderlyingAssetUnit`: raw CASH planks per +/// user-facing cent, `10^(precision - 2)`. Live chain metadata remains the +/// authority wherever it is read — this is the pre-live default for display +/// projections (a fresh recipient renders a received transfer in chat before +/// any wallet flow has fetched the live constants; a placeholder of `1` +/// there showed a 2-CASH transfer as "20,000"). +pub const CASH_PLANKS_PER_CENT: u128 = 10u128.pow((CASH_ASSET_PRECISION - 2) as u32); + +pub const COIN_MAX_AGE: i16 = 16; + +/// Coins with `age >= RECYCLE_AT_AGE` are excluded from selection so they can +/// be recycled before reaching `COIN_MAX_AGE`. Equals `COIN_MAX_AGE - 2`. +pub const RECYCLE_AT_AGE: i16 = 14; + +pub const MINIMUM_RING_SIZE: u32 = 10; + +pub const WAL_MORTALITY_BLOCKS: u64 = 300; + +/// Longest a voucher may sit waiting before onboarding is abandoned +/// (`maxVoucherWaitTime`, 6 hours). +pub const MAX_VOUCHER_WAIT_TIME: Duration = Duration::from_secs(6 * 60 * 60); + +pub const SEND_VERIFY_BLOCK_TIMEOUT: u32 = 100; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_constants_are_pinned() { + assert_eq!(COIN_MAX_AGE, 16); + assert_eq!(RECYCLE_AT_AGE, 14); + assert_eq!(COIN_MAX_AGE - 2, RECYCLE_AT_AGE); + assert_eq!(MINIMUM_RING_SIZE, 10); + assert_eq!(WAL_MORTALITY_BLOCKS, 300); + assert_eq!(MAX_VOUCHER_WAIT_TIME, Duration::from_secs(21_600)); + assert_eq!(SEND_VERIFY_BLOCK_TIMEOUT, 100); + assert_eq!(CASH_ASSET_PRECISION, 6); + assert_eq!(CASH_PLANKS_PER_CENT, 10_000); + } +} diff --git a/rust/crates/truapi-coinage/src/denomination.rs b/rust/crates/truapi-coinage/src/denomination.rs new file mode 100644 index 000000000..4c4cd6e8b --- /dev/null +++ b/rust/crates/truapi-coinage/src/denomination.rs @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +/// One power-of-two denomination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Denomination { + pub exponent: i16, +} + +/// Greedy decomposition outcome: the denominations that fit, plus the +/// remainder below the smallest denomination (zero when the amount is +/// exactly representable). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DenominationBreakdown { + pub denominations: Vec, + pub remainder: u128, +} + +impl DenominationBreakdown { + pub fn is_exact(&self) -> bool { + self.remainder == 0 + } +} + +/// The pallet-constant context driving all denomination math. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DenominationBreakdownContext { + /// `UnderlyingAssetUnit` pallet constant, in planks. + pub asset_unit: u128, + /// `MaximumExponent` pallet constant. + pub max_exponent: i16, + /// `MinimumExponent` pallet constant (may be negative). + pub min_exponent: i16, + pub precision: u8, +} + +impl DenominationBreakdownContext { + /// Converts the UI/protocol CASH balance unit (one cent) into the raw + /// planks consumed by Coinage selection and emitted in transfer memos. + /// `UnderlyingAssetUnit` is exactly that cent in the active asset. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + cents.checked_mul(self.asset_unit) + } + + /// Converts raw Coinage planks back into whole CASH cents. A remainder + /// is rejected rather than rounded across a payment confirmation edge. + pub fn cash_cents_from_planks(&self, planks: u128) -> Option { + (self.asset_unit != 0 && planks.is_multiple_of(self.asset_unit)) + .then(|| planks / self.asset_unit) + } + + /// Denomination value in planks: `unit << exponent` for non-negative + /// exponents, `unit >> -exponent` for negative ones. Each direction + /// saturates (`u128::MAX` or `0`) rather than panicking on an absurd + /// shift amount. + pub fn value_in_planks(&self, exponent: i16) -> u128 { + if exponent >= 0 { + self.asset_unit + .checked_shl(u32::from(exponent as u16)) + .unwrap_or(u128::MAX) + } else { + let shift = u32::from(exponent.unsigned_abs()); + if shift >= 128 { + 0 + } else { + self.asset_unit >> shift + } + } + } + + /// Greedy binary decomposition from `max_exponent` down to `min_exponent`, + /// taking as many of each denomination as fit; anything below the + /// smallest denomination is returned as `remainder`. + pub fn breakdown(&self, amount_planks: u128) -> DenominationBreakdown { + let mut remaining = amount_planks; + let mut denominations = Vec::new(); + let mut exponent = self.max_exponent; + while exponent >= self.min_exponent { + let value = self.value_in_planks(exponent); + if value > 0 { + while remaining >= value { + denominations.push(Denomination { exponent }); + remaining -= value; + } + } + exponent -= 1; + } + DenominationBreakdown { + denominations, + remainder: remaining, + } + } + + /// Total planks of a denomination list. + pub fn total_value(&self, denominations: &[Denomination]) -> u128 { + denominations.iter().fold(0u128, |acc, d| { + acc.saturating_add(self.value_in_planks(d.exponent)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unit 10, exponents 0..=4 → denominations 10, 20, 40, 80, 160. + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + #[test] + fn value_in_planks_shifts_both_directions() { + let ctx = ctx(); + assert_eq!(ctx.value_in_planks(0), 10); + assert_eq!(ctx.value_in_planks(3), 80); + let fractional = DenominationBreakdownContext { + asset_unit: 16, + max_exponent: 2, + min_exponent: -2, + precision: 0, + }; + assert_eq!(fractional.value_in_planks(-1), 8); + assert_eq!(fractional.value_in_planks(-2), 4); + } + + #[test] + fn cash_cents_and_asset_planks_have_an_explicit_exact_boundary() { + let context = DenominationBreakdownContext { + asset_unit: 10_000, + max_exponent: 16, + min_exponent: 0, + precision: 6, + }; + + for (cash, cents, planks) in [ + (1, 100, 1_000_000), + (10, 1_000, 10_000_000), + (100, 10_000, 100_000_000), + (200, 20_000, 200_000_000), + ] { + assert_eq!( + context.cash_cents_to_planks(cents), + Some(planks), + "{cash} CASH" + ); + assert_eq!( + context.cash_cents_from_planks(planks), + Some(cents), + "{cash} CASH" + ); + } + assert_eq!(context.cash_cents_from_planks(9_999), None); + assert_eq!(context.cash_cents_to_planks(u128::MAX), None); + } + + #[test] + fn breakdown_of_known_amounts_is_greedy_largest_first() { + let ctx = ctx(); + // 230 = 160 + 40 + 20 + 10. + let b = ctx.breakdown(230); + assert_eq!( + b.denominations, + [ + Denomination { exponent: 4 }, + Denomination { exponent: 2 }, + Denomination { exponent: 1 }, + Denomination { exponent: 0 }, + ] + ); + assert!(b.is_exact()); + // 320 = 160 + 160 (repeated denominations allowed). + let b = ctx.breakdown(320); + assert_eq!( + b.denominations, + [Denomination { exponent: 4 }, Denomination { exponent: 4 }] + ); + assert!(b.is_exact()); + } + + #[test] + fn breakdown_reports_sub_denomination_remainder() { + let ctx = ctx(); + let b = ctx.breakdown(235); + assert_eq!(b.remainder, 5, "5 planks sit below the 10-plank unit"); + assert!(!b.is_exact()); + assert_eq!(ctx.total_value(&b.denominations), 230); + } + + #[test] + fn breakdown_of_zero_is_empty_and_exact() { + let b = ctx().breakdown(0); + assert!(b.denominations.is_empty()); + assert!(b.is_exact()); + } + + #[test] + fn negative_exponents_extend_below_the_unit() { + let ctx = DenominationBreakdownContext { + asset_unit: 16, + max_exponent: 1, + min_exponent: -2, + precision: 0, + }; + // 28 = 16 + 8 + 4. + let b = ctx.breakdown(28); + assert_eq!( + b.denominations, + [ + Denomination { exponent: 0 }, + Denomination { exponent: -1 }, + Denomination { exponent: -2 }, + ] + ); + assert!(b.is_exact()); + } + + /// Breakdown reconstructs: `total_value(breakdown(x)) + remainder == x` + /// across a sweep — the invariant coin allocation relies on. + #[test] + fn breakdown_reconstructs_every_amount() { + let ctx = ctx(); + for amount in 0..2_000u128 { + let b = ctx.breakdown(amount); + assert_eq!(ctx.total_value(&b.denominations) + b.remainder, amount); + assert!(b.remainder < ctx.value_in_planks(ctx.min_exponent)); + } + } +} diff --git a/rust/crates/truapi-coinage/src/index_store.rs b/rust/crates/truapi-coinage/src/index_store.rs new file mode 100644 index 000000000..00ad14289 --- /dev/null +++ b/rust/crates/truapi-coinage/src/index_store.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parking_lot::Mutex; +use std::collections::HashMap; + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode}; + +pub const COIN_INDEX_KEY: &str = "coin-index"; + +pub const VOUCHER_INDEX_KEY: &str = "voucher-index"; + +/// Which counter a call addresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IndexKind { + Coin, + Voucher, +} + +impl IndexKind { + /// The platform storage key this counter lives under. + pub fn storage_key(self) -> &'static str { + match self { + IndexKind::Coin => COIN_INDEX_KEY, + IndexKind::Voucher => VOUCHER_INDEX_KEY, + } + } +} + +pub fn encode_index(index: u32) -> Vec { + index.encode() +} + +/// Decode a stored counter; `None` on malformed bytes. +pub fn decode_index(bytes: &[u8]) -> Option { + let mut input = bytes; + let value = u32::decode(&mut input).ok()?; + input.is_empty().then_some(value) +} + +/// Durable, crash-safe monotonic counters. `get_next_index` is the +/// atomic read-increment-write allocation primitive: callers +/// (`CoinAllocator`/`VoucherAllocator`) receive each index exactly once. +#[async_trait] +pub trait CoinageIndexStore: Send + Sync { + async fn get_next_index(&self, kind: IndexKind) -> Result; + + /// The current high-water mark; `None` on a fresh install. + async fn current_index(&self, kind: IndexKind) -> Result, String>; + + /// Overwrites the counter (used for backup-recovery horizon writes): + /// only ever move it forward — a lower value re-issues already-used + /// indices and corrupts key derivation. + async fn set_index(&self, kind: IndexKind, index: u32) -> Result<(), String>; +} + +/// Test/dev impl over a mutex-guarded map. Also the executable spec of +/// the counter contract for the platform impls. +#[derive(Default)] +pub struct InMemoryCoinageIndexStore { + counters: Mutex>, +} + +#[async_trait] +impl CoinageIndexStore for InMemoryCoinageIndexStore { + async fn get_next_index(&self, kind: IndexKind) -> Result { + let mut counters = self.counters.lock(); + let next = match counters.get(&kind) { + None => 0, + Some(current) => current + .checked_add(1) + .ok_or_else(|| "derivation index space exhausted".to_string())?, + }; + counters.insert(kind, next); + Ok(next) + } + + async fn current_index(&self, kind: IndexKind) -> Result, String> { + Ok(self.counters.lock().get(&kind).copied()) + } + + async fn set_index(&self, kind: IndexKind, index: u32) -> Result<(), String> { + self.counters.lock().insert(kind, index); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fresh_counter_starts_at_zero_then_increments() { + let store = InMemoryCoinageIndexStore::default(); + assert_eq!(store.current_index(IndexKind::Coin).await.unwrap(), None); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 0); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 1); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 2); + assert_eq!(store.current_index(IndexKind::Coin).await.unwrap(), Some(2)); + } + + #[tokio::test] + async fn coin_and_voucher_counters_are_independent() { + let store = InMemoryCoinageIndexStore::default(); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 0); + assert_eq!(store.get_next_index(IndexKind::Coin).await.unwrap(), 1); + assert_eq!(store.get_next_index(IndexKind::Voucher).await.unwrap(), 0); + assert_eq!( + store.current_index(IndexKind::Voucher).await.unwrap(), + Some(0) + ); + } + + #[tokio::test] + async fn horizon_write_moves_the_counter() { + let store = InMemoryCoinageIndexStore::default(); + store.set_index(IndexKind::Voucher, 41).await.unwrap(); + assert_eq!(store.get_next_index(IndexKind::Voucher).await.unwrap(), 42); + } + + /// Concurrent allocators must never observe the same index — the + /// atomicity contract platform impls have to uphold. + #[tokio::test] + async fn concurrent_allocation_yields_unique_indices() { + use std::collections::HashSet; + use std::sync::Arc; + let store = Arc::new(InMemoryCoinageIndexStore::default()); + let mut handles = Vec::new(); + for _ in 0..64 { + let store = Arc::clone(&store); + handles.push(tokio::spawn(async move { + store.get_next_index(IndexKind::Coin).await.unwrap() + })); + } + let mut seen = HashSet::new(); + for handle in handles { + assert!(seen.insert(handle.await.unwrap()), "index issued twice"); + } + assert_eq!(seen.len(), 64); + } + + #[test] + fn index_codec_is_scale_u32() { + assert_eq!(encode_index(7), 7u32.encode()); + assert_eq!(decode_index(&encode_index(0xDEAD_BEEF)), Some(0xDEAD_BEEF)); + assert_eq!(decode_index(&[1, 2, 3]), None, "short read is malformed"); + assert_eq!( + decode_index(&[1, 2, 3, 4, 5]), + None, + "trailing bytes are malformed" + ); + } + + #[test] + fn storage_keys_are_pinned() { + assert_eq!(IndexKind::Coin.storage_key(), "coin-index"); + assert_eq!(IndexKind::Voucher.storage_key(), "voucher-index"); + } +} diff --git a/rust/crates/truapi-coinage/src/keys.rs b/rust/crates/truapi-coinage/src/keys.rs new file mode 100644 index 000000000..a219db060 --- /dev/null +++ b/rust/crates/truapi-coinage/src/keys.rs @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::Encode; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// The current iOS main purse; persisted indices must belong to this layout. +pub const MAIN_PURSE: u32 = u32::MAX; +/// Coinage currently derives all main-purse keys on page zero. +pub const PAGE: u32 = 0; + +pub const RECYCLER_ALIAS_CONTEXT: &[u8; 32] = b"pop:polkadot.network/coinrecyclr"; + +/// Derives sr25519 coins at `//coinage//4294967295//0/`: three hard +/// parent junctions and a soft item junction. The cached parent is zeroized on drop. +/// Callers must not reuse indices persisted under a different purse layout. +pub struct CoinKeypairFactory { + parent: Result, +} + +impl Zeroize for CoinKeypairFactory { + fn zeroize(&mut self) { + // Dropping the cached Schnorrkel keypair zeroizes its secret. Leave no + // usable parent after explicit clearing, and do not allocate on drop. + self.parent = Err(String::new()); + } +} + +impl Drop for CoinKeypairFactory { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl ZeroizeOnDrop for CoinKeypairFactory {} + +impl CoinKeypairFactory { + pub fn new(entropy: &[u8]) -> Self { + let parent = derive_sr25519_hard_path( + entropy, + &[ + junction_chain_code("coinage"), + junction_chain_code(u64::from(MAIN_PURSE)), + junction_chain_code(u64::from(PAGE)), + ], + ) + .map_err(|error| format!("coin key derivation: {error}")); + Self { parent } + } + + /// The full keypair for one coin index. + pub fn keypair(&self, index: u32) -> Result { + use rand::SeedableRng; + use schnorrkel::derive::{ChainCode, Derivation}; + + // Recovery derives thousands of children; expand BIP-39 and the three + // hard parents once per factory, not once per scanned index. + let parent = self.parent.as_ref().map_err(Clone::clone)?; + // Only the HDKD auxiliary randomness is fixed. Schnorrkel mixes the + // parent secret and nonce into its witness RNG; the scalar/public key + // retain standard soft derivation. This makes the complete exported + // 64-byte secret stable so a durable handoff replays the same memo. + // Signing continues to use Schnorrkel's ordinary randomized path. + Ok(parent + .derived_key_simple_rng( + ChainCode(junction_chain_code(u64::from(index))), + [], + rand_chacha::ChaCha20Rng::from_seed([0; 32]), + ) + .0) + } + + /// The coin's on-chain identity (`CoinsByOwner` storage key part). + pub fn public_key(&self, index: u32) -> Result<[u8; 32], String> { + Ok(self.keypair(index)?.public.to_bytes()) + } + + /// The raw 64-byte expanded secret — the exact layout + /// `schnorrkel::SecretKey::from_bytes` reconstructs a signer from. + pub fn secret_bytes(&self, index: u32) -> Result<[u8; 64], String> { + Ok(self.keypair(index)?.secret.to_bytes()) + } +} + +/// A derived voucher seed (32 bytes). Secret material: the Bandersnatch +/// secret key is expanded from it. Zeroized on drop. +#[derive(Zeroize, ZeroizeOnDrop, PartialEq, Eq)] +pub struct VoucherSeed(pub [u8; 32]); + +impl std::fmt::Debug for VoucherSeed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print seed bytes. + f.write_str("VoucherSeed(..)") + } +} + +/// Derives vouchers at `//coinage-ring-vrf//4294967295//0//`. +/// All four junctions are hard and fold directly over root entropy, without +/// BIP-39 expansion. Callers must keep persisted indices scoped to this layout. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct VoucherKeypairFactory { + entropy: Vec, +} + +impl VoucherKeypairFactory { + pub fn new(entropy: &[u8]) -> Self { + Self { + entropy: entropy.to_vec(), + } + } + + /// The chained seed for one voucher index. + pub fn seed(&self, index: u32) -> VoucherSeed { + let mut seed = VoucherSeed(keyed_blake2b_256( + &self.entropy, + &junction_chain_code("coinage-ring-vrf"), + )); + for junction in [MAIN_PURSE, PAGE, index] { + let chain_code = junction_chain_code(u64::from(junction)); + seed.0 = keyed_blake2b_256(&seed.0, &chain_code); + } + seed + } + + /// The member key used by Coinage and Members storage. + pub fn public_key( + &self, + index: u32, + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 32], String> { + crypto.member_key(&self.seed(index)) + } + + /// Ownership signature binding the member key to an input coin. + pub fn proof_of_ownership( + &self, + index: u32, + message: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 64], String> { + crypto.sign(&self.seed(index), message) + } + + /// The context-specific recycler alias, independent of the implication. + pub fn alias( + &self, + index: u32, + context: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result<[u8; 32], String> { + crypto.alias(&self.seed(index), context) + } + + /// Transaction-bound Bandersnatch membership proof at a finalized ring. + pub fn ring_vrf_proof( + &self, + index: u32, + ring_exponent: u8, + ring_members: &[[u8; 32]], + context: &[u8], + message: &[u8], + crypto: &dyn VoucherCryptography, + ) -> Result, String> { + crypto.ring_vrf_proof( + &self.seed(index), + ring_exponent, + ring_members, + context, + message, + ) + } +} + +/// SCALE-encoded Substrate junction → 32-byte chain code. Numeric path +/// components must be passed as `u64`, even though purse/page/item are `u32`. +/// Oversized encodings hash through BLAKE2b-256; short ones zero-pad. +fn junction_chain_code(junction: impl Encode) -> [u8; 32] { + let mut chain_code = [0u8; 32]; + if junction.encoded_size() > chain_code.len() { + chain_code = blake2b_256(&junction.encode()); + } else { + junction.encode_to(&mut &mut chain_code[..]); + } + chain_code +} + +fn keyed_blake2b_256(message: &[u8], key: &[u8]) -> [u8; 32] { + let mut params = blake2b_simd::Params::new(); + params.hash_length(32).key(key); + params + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +fn blake2b_256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoinDerivedWalletError { + /// The requesting signer's account id is not this coin's public key. + UnexpectedAccount, +} + +impl std::fmt::Display for CoinDerivedWalletError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "signer account does not match the coin key") + } +} + +impl std::error::Error for CoinDerivedWalletError {} + +pub struct CoinDerivedWallet { + keypair: schnorrkel::Keypair, +} + +impl CoinDerivedWallet { + pub fn new(keypair: schnorrkel::Keypair) -> Self { + Self { keypair } + } + + /// The wallet for one coin index. + pub fn for_index(factory: &CoinKeypairFactory, index: u32) -> Result { + Ok(Self::new(factory.keypair(index)?)) + } + + /// The coin's raw public key (== its sr25519 account id). + pub fn public_key(&self) -> [u8; 32] { + self.keypair.public.to_bytes() + } + + pub fn fetch_signer_secret( + &self, + signer_account_id: &[u8; 32], + ) -> Result<[u8; 64], CoinDerivedWalletError> { + if *signer_account_id != self.public_key() { + return Err(CoinDerivedWalletError::UnexpectedAccount); + } + Ok(self.keypair.secret.to_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENTROPY: [u8; 16] = [7u8; 16]; + + #[test] + fn coin_keys_are_distinct_per_index_and_deterministic() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let a0 = factory.public_key(0).unwrap(); + let a1 = factory.public_key(1).unwrap(); + assert_ne!(a0, a1, "indices must never collide"); + assert_eq!(a0, CoinKeypairFactory::new(&ENTROPY).public_key(0).unwrap()); + } + + #[test] + fn clearing_cached_coin_parent_prevents_further_derivation() { + let mut factory = CoinKeypairFactory::new(&ENTROPY); + factory.public_key(0).unwrap(); + factory.zeroize(); + assert!(factory.public_key(0).is_err()); + assert!(factory.secret_bytes(1).is_err()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn coin_keys_match_canonical_main_purse_with_soft_items() { + use subxt_signer::{SecretUri, bip39::Mnemonic, sr25519}; + + let mnemonic = Mnemonic::from_entropy(&ENTROPY).unwrap(); + for index in [0, u32::MAX] { + let soft_uri: SecretUri = format!("{mnemonic}//coinage//4294967295//0/{index}") + .parse() + .unwrap(); + let hard_uri: SecretUri = format!("{mnemonic}//coinage//4294967295//0//{index}") + .parse() + .unwrap(); + let actual = CoinKeypairFactory::new(&ENTROPY).keypair(index).unwrap(); + let expected = sr25519::Keypair::from_uri(&soft_uri).unwrap(); + assert_eq!(actual.public.to_bytes(), expected.public_key().0); + assert_ne!( + actual.public.to_bytes(), + sr25519::Keypair::from_uri(&hard_uri) + .unwrap() + .public_key() + .0, + "coin items must be soft, unlike voucher items" + ); + let message = b"coin main-purse signing regression"; + let signature = actual.sign_simple(b"substrate", message); + assert!(sr25519::verify( + &sr25519::Signature(signature.to_bytes()), + message, + &expected.public_key(), + )); + } + } + + #[test] + fn coin_secret_bytes_are_64_and_rebuild_the_keypair() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let secret = factory.secret_bytes(3).unwrap(); + let rebuilt = schnorrkel::SecretKey::from_bytes(&secret).unwrap(); + assert_eq!( + rebuilt.to_public().to_bytes(), + factory.public_key(3).unwrap(), + "memo secret bytes must reconstruct the coin's public identity" + ); + } + + #[test] + fn voucher_seeds_are_deterministic_and_distinct() { + let factory = VoucherKeypairFactory::new(&ENTROPY); + assert_eq!( + factory.seed(0), + VoucherKeypairFactory::new(&ENTROPY).seed(0) + ); + assert_ne!(factory.seed(0), factory.seed(1)); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn voucher_entropy_matches_canonical_full_path_fold() { + use blake2::{ + Blake2bMac, + digest::{KeyInit, Mac, consts::U32}, + }; + use subxt_signer::{DeriveJunction, SecretUri}; + + // Independent URI parser and BLAKE2 implementation: no production + // chain-code helper, path constants, or keyed-hash helper is reused. + let uri: SecretUri = "//coinage-ring-vrf//4294967295//0//5".parse().unwrap(); + let mut expected = ENTROPY.to_vec(); + for junction in uri.junctions { + let DeriveJunction::Hard(chain_code) = junction else { + panic!("voucher reference path must be entirely hard"); + }; + let mut hash = as KeyInit>::new_from_slice(&chain_code).unwrap(); + Mac::update(&mut hash, &expected); + expected = hash.finalize().into_bytes().to_vec(); + } + assert_eq!( + VoucherKeypairFactory::new(&ENTROPY).seed(5).0.as_slice(), + expected + ); + } + + /// The voucher path must not shadow the coin path from the same + /// entropy — different junction lists, different key material. + #[test] + fn voucher_path_diverges_from_coin_path() { + let coins = CoinKeypairFactory::new(&ENTROPY); + let vouchers = VoucherKeypairFactory::new(&ENTROPY); + assert_ne!(vouchers.seed(0).0, coins.secret_bytes(0).unwrap()[..32]); + } + + #[test] + fn coin_derived_wallet_guards_the_signer_secret() { + let factory = CoinKeypairFactory::new(&ENTROPY); + let wallet = CoinDerivedWallet::for_index(&factory, 3).unwrap(); + let account_id = wallet.public_key(); + + let secret = wallet.fetch_signer_secret(&account_id).unwrap(); + let rebuilt = schnorrkel::SecretKey::from_bytes(&secret).unwrap(); + assert_eq!(rebuilt.to_public().to_bytes(), account_id); + + let mut wrong = account_id; + wrong[0] ^= 1; + assert_eq!( + wallet.fetch_signer_secret(&wrong), + Err(CoinDerivedWalletError::UnexpectedAccount) + ); + } +} + +/// Required exact Bandersnatch primitive implementation. The ring exponent is +/// the chain's member-count exponent (9/10/14), not the PCS exponent. +/// Adapters must use the deployed Bandersnatch suite; a different curve or +/// synthetic proof is not a valid implementation. Seeds must not be retained. +pub trait VoucherCryptography: Send + Sync { + /// Derive the Bandersnatch public member key. + fn member_key(&self, seed: &VoucherSeed) -> Result<[u8; 32], String>; + /// Sign an ownership message with the derived Bandersnatch key. + fn sign(&self, seed: &VoucherSeed, message: &[u8]) -> Result<[u8; 64], String>; + /// Derive the context-bound alias for this voucher. + fn alias(&self, seed: &VoucherSeed, context: &[u8]) -> Result<[u8; 32], String>; + /// Generate the canonical 785-byte ring-VRF proof. + fn ring_vrf_proof( + &self, + seed: &VoucherSeed, + ring_exponent: u8, + ring_members: &[[u8; 32]], + context: &[u8], + message: &[u8], + ) -> Result, String>; +} + +// Derived from MIT-licensed host-rust-core product_account.rs. Copyright +// (c) 2026 Parity Technologies. The complete MIT notice is in LICENSE-MIT. +fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[[u8; 32]], +) -> Result { + use schnorrkel::{ExpansionMode, derive::ChainCode}; + let mini_secret = substrate_bip39::mini_secret_from_entropy(entropy, "") + .map_err(|error| format!("invalid BIP-39 entropy: {error:?}"))?; + let mut keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + for junction in junctions { + let chain_code = ChainCode(*junction); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + +#[cfg(test)] +mod root_derivation_tests { + #[test] + fn entropy_expansion_matches_deployed_host() { + let root = super::derive_sr25519_hard_path(&[0xAB; 16], &[]).unwrap(); + assert_eq!( + hex::encode(root.public.to_bytes()), + "0062ba8ae929ea64bc2ad6f21359e96a29e236a41d376d1c5ba76491da94fc72" + ); + } +} diff --git a/rust/crates/truapi-coinage/src/lib.rs b/rust/crates/truapi-coinage/src/lib.rs new file mode 100644 index 000000000..0205af17f --- /dev/null +++ b/rust/crates/truapi-coinage/src/lib.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Host-owned Coinage domain engine, licensed AGPL-3.0-only. +//! +//! The signing authority owns this crate and every memo it creates. Nothing +//! here grants product permission or exports a secret through a guest API. +//! The Host must authorize each outgoing operation before `confirm`, persist +//! the encrypted transport handoff, and reconcile ambiguous outcomes. +//! +//! Durable adapters implement repositories, monotonic indices, WAL and claim +//! plans. Chain adapters provide pinned storage/finality and exact transaction +//! submission; Bandersnatch primitives are supplied by the signing authority. +//! `tokio::sync` supplies runtime-independent channels/locks only. Background +//! execution uses the injected [`Spawner`]; timers support native and WASM. +//! +//! Source and modification notices: `NOTICE`; full license: `LICENSE`. + +/// Allocator domain contracts and algorithms. +pub mod allocator; +/// Balance domain contracts and algorithms. +pub mod balance; +/// Claim domain contracts and algorithms. +pub mod claim; +/// Claim plan domain contracts and algorithms. +pub mod claim_plan; +/// Clock domain contracts and algorithms. +pub mod clock; +/// Constants domain contracts and algorithms. +pub mod constants; +/// Denomination domain contracts and algorithms. +pub mod denomination; +/// Index store domain contracts and algorithms. +pub mod index_store; +/// Keys domain contracts and algorithms. +pub mod keys; +/// Members domain contracts and algorithms. +pub mod members; +/// Memo domain contracts and algorithms. +pub mod memo; +/// Model domain contracts and algorithms. +pub mod model; +/// Outgoing transfer domain contracts and algorithms. +pub mod outgoing_transfer; +/// Pallet domain contracts and algorithms. +pub mod pallet; +/// Query domain contracts and algorithms. +pub mod query; +/// Recipient domain contracts and algorithms. +pub mod recipient; +/// Recovery domain contracts and algorithms. +pub mod recovery; +/// Repo domain contracts and algorithms. +pub mod repo; +/// Ring proof domain contracts and algorithms. +pub mod ring_proof; +/// Secret claim domain contracts and algorithms. +pub mod secret_claim; +/// Selection domain contracts and algorithms. +pub mod selection; +/// Sync domain contracts and algorithms. +pub mod sync; +/// Tasks domain contracts and algorithms. +pub mod tasks; +mod timer; +/// Transfer sender domain contracts and algorithms. +pub mod transfer_sender; +/// Tx extensions domain contracts and algorithms. +pub mod tx_extensions; +/// Voucher location domain contracts and algorithms. +pub mod voucher_location; +/// Crash-safe write-ahead journal contracts and encoding. +pub mod wal; + +/// Host executor used for all long-lived Coinage work. +pub type Spawner = std::sync::Arc) + Send + Sync>; + +pub use allocator::{ + CoinAllocator, FixedDelayProvider, SystemJitterDelayProvider, VoucherAllocator, + VoucherDelayProvider, +}; +pub use balance::{BalanceBuckets, compute_balance, next_unlock_at_ms}; +pub use claim::{ + ClaimError, ClaimExecutor, ClaimOrchestrator, ClaimStatus, ClaimStatusStore, IncomingClaim, + SendConfirmation, TransferSendVerifying, claimed_amount_from_plan, restore_persisted_statuses, +}; +pub use claim_plan::{ + ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry, decode_claim_plan_entries, + encode_claim_plan_entries, +}; +pub use clock::{Clock, FixedClock, SystemClock}; +pub use constants::{ + CASH_ASSET_PRECISION, CASH_PLANKS_PER_CENT, COIN_MAX_AGE, MAX_VOUCHER_WAIT_TIME, + MINIMUM_RING_SIZE, RECYCLE_AT_AGE, SEND_VERIFY_BLOCK_TIMEOUT, WAL_MORTALITY_BLOCKS, +}; +pub use denomination::{Denomination, DenominationBreakdown, DenominationBreakdownContext}; +pub use index_store::{ + COIN_INDEX_KEY, CoinageIndexStore, InMemoryCoinageIndexStore, IndexKind, VOUCHER_INDEX_KEY, + decode_index, encode_index, +}; +pub use keys::VoucherCryptography; +pub use keys::{ + CoinDerivedWallet, CoinKeypairFactory, MAIN_PURSE, PAGE, VoucherKeypairFactory, VoucherSeed, +}; +pub use memo::{MemoEntry, TransferMemo}; +pub use model::{ + Coin, CoinState, EffectivePrivacy, Voucher, VoucherLocalState, VoucherPrivacyLevel, + VoucherRemoteState, ring_readiness_upgraded, +}; +pub use outgoing_transfer::{ + OutgoingCoinTransferParts, OutgoingCoinTransferService, OutgoingHandoffRejected, + OutgoingTransferError, OutgoingTransferReconciliation, +}; +pub use query::{ + AliasState, CoinOnChainQueryService, CoinageQueryError, CoinageStorageKey, CoinageStorageQuery, + LockInfo, LockReason, QueryVoucherLocationSubscriber, RecyclerReadinessLoader, + RecyclerRevisionSnapshot, VoucherOnChainInfo, VoucherOnChainQueryService, +}; +pub use recipient::{CoinageSendMessage, TransferRecipientService}; +pub use recovery::{RecoveryChainProbe, RecoveryReport, TransferRecoveryService}; +pub use repo::{ + CoinRepository, InMemoryCoinRepository, InMemoryVoucherRepository, TransferContext, + TransferStateCommitter, VoucherRepository, +}; +pub use ring_proof::BandersnatchRingProofProvider; +pub use ring_proof::PersonRingProofSigner; +pub use ring_proof::{ + FREE_UNLOAD_TOKEN_CONTEXT_PREFIX, PersonOriginKind, RECYCLER_ALIAS_CONTEXT, RING_VRF_PROOF_LEN, + ResolvedUnloadToken, RingProofError, RingProofParams, RingProofProvider, UnloadProofRequest, + UnloadTokenProof, +}; +pub use secret_claim::{ + ExternalCoinTransferBackend, ExternalCoinTransferRequest, ExternalSecretClaimService, + SpentCoinTransferRecoveryReport, SpentCoinTransferRecoveryService, external_claim_message_id, +}; +pub use secret_claim::{ExternalMemoClaiming, SpentCoinsRecovering}; +pub use selection::{ + CoinSelectionError, CoinSelectionResult, CoinSelector, PrivacyLevel, RecyclerKey, + TransferStrategy, VoucherGroup, find_exact_match, +}; +pub use sync::{ + CoinStateSubscriber, CoinStateSyncService, CoinStateUpdate, CoinageDatabaseDependencyFactory, + NotifyingCoinRepository, NotifyingVoucherRepository, OnChainCoin, +}; +pub use tasks::ActiveTaskRegistry; +pub use transfer_sender::{ + OpaqueTransferPreview, PreparedUnloadGroup, RegularCoinTransferParts, + RegularCoinTransferService, RegularTransferError, RegularTransferSubmitter, + SplitTransferSubmission, TransferPreviewChoice, TransferPreviewStrategy, UnloadGroupDraft, + UnloadOriginPreparation, VoucherSelectionDiagnostic, voucher_selection_diagnostics, +}; +pub use voucher_location::{ + RingPosition, RingStatus, VoucherLocationService, VoucherLocationSubscriber, + VoucherLocationUpdate, +}; + +#[cfg(test)] +fn test_spawner() -> Spawner { + std::sync::Arc::new(|future| { + tokio::spawn(future); + }) +} + +pub use wal::{CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore}; diff --git a/rust/crates/truapi-coinage/src/members.rs b/rust/crates/truapi-coinage/src/members.rs new file mode 100644 index 000000000..43a8e88ee --- /dev/null +++ b/rust/crates/truapi-coinage/src/members.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-chain/src/pallets/members.rs. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! SCALE layouts used by Coinage recycler queries. +use parity_scale_codec::{Decode, Encode}; + +/// A ring collection's 32-byte identifier. +pub type CollectionIdentifier = [u8; 32]; + +/// A member's 32-byte ring-VRF (bandersnatch) public key. +pub type MemberKey = [u8; 32]; + +pub type RingIndex = u32; + +/// Where a member key currently sits within a collection +/// (`indiv_support::traits::reality::RingPosition`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum RingPosition { + #[codec(index = 0)] + Onboarding { queue_page: u32, queued_at: u64 }, + #[codec(index = 1)] + Included { + ring_index: u32, + ring_page: u32, + ring_position: u32, + }, + #[codec(index = 2)] + Suspended, +} + +/// `Members.RingKeysStatus` value (`indiv_support::…::RingStatus`). It +/// stores `total` and `included` (queued = `total - included`) plus the +/// timestamp the ring became immutable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct RingStatus { + pub total: u32, + pub included: u32, + /// Seconds timestamp once the ring stopped accepting keys. + pub immutable_since: Option, +} + +impl RingStatus { + /// Keys queued behind the proof set. + pub fn queued(&self) -> u32 { + self.total.saturating_sub(self.included) + } +} + +/// `Members.Root` value: the current ring root commitment. +/// +/// 2026-08 wipe (spec 1000032): the root commitment shrank from 768 to +/// 288 bytes (live value = 288 + 4 + 848 = 1140 bytes, probed via +/// `brevity-ffi/examples/ring_root_type_probe.rs`). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingRoot { + pub root: [u8; 288], + pub revision: u32, + pub intermediate: [u8; 848], +} diff --git a/rust/crates/truapi-coinage/src/memo.rs b/rust/crates/truapi-coinage/src/memo.rs new file mode 100644 index 000000000..57da358c7 --- /dev/null +++ b/rust/crates/truapi-coinage/src/memo.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use blake2::Blake2b; +use blake2::digest::Digest; +use blake2::digest::consts::U32; +use parity_scale_codec::{Compact, Decode, Encode, Input}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// One raw 64-byte coin secret key. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct MemoEntry(pub [u8; 64]); + +impl std::fmt::Debug for MemoEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MemoEntry(..)") + } +} + +/// The transfer memo: entries plus the expected total value in planks. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct TransferMemo { + pub entries: Vec, + pub total_value: u128, +} + +impl std::fmt::Debug for TransferMemo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "TransferMemo {{ entries: {}, .. }}", self.entries.len()) + } +} + +impl TransferMemo { + /// Exact iOS `TransferMemo.encode(scaleEncoder:)` layout: SCALE + /// `Vec>` entries followed by a SCALE compact `BigUInt` + /// total. The returned bytes are secret material: callers must retain + /// them only in zeroizing buffers and must never log or expose them. + pub fn scale_encoded(&self) -> Vec { + let count = u32::try_from(self.entries.len()).expect("memo entry count fits SCALE u32"); + let mut encoded = + Vec::with_capacity(self.entries.len().saturating_mul(66).saturating_add(22)); + Compact(count).encode_to(&mut encoded); + for entry in &self.entries { + Compact(64u32).encode_to(&mut encoded); + encoded.extend_from_slice(&entry.0); + } + Compact(self.total_value).encode_to(&mut encoded); + encoded + } + + /// Strict inverse of [`Self::scale_encoded`]. Every entry must be one + /// 64-byte expanded sr25519 secret, and trailing bytes reject. + /// Partially decoded keys are wiped on every exit. + pub fn from_scale_encoded(bytes: &[u8]) -> Result { + let mut input = bytes; + let count = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo entry count decode failed: {error}"))? + .0 as usize; + if count > input.len() / 66 { + return Err("transfer memo entries exceed its encoded length".into()); + } + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + let length = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo entry length decode failed: {error}"))? + .0; + if length != 64 { + return Err(format!( + "transfer memo entry is {length} bytes; expected 64" + )); + } + let mut entry = MemoEntry([0; 64]); + input + .read(&mut entry.0) + .map_err(|error| format!("transfer memo entry decode failed: {error}"))?; + entries.push(entry); + } + let total_value = Compact::::decode(&mut input) + .map_err(|error| format!("transfer memo total decode failed: {error}"))? + .0; + if !input.is_empty() { + return Err("transfer memo has trailing bytes".into()); + } + Ok(Self { + entries, + total_value, + }) + } + + pub fn identifier(&self) -> [u8; 32] { + let value_be = self.total_value.to_be_bytes(); + let first_nonzero = value_be + .iter() + .position(|byte| *byte != 0) + .unwrap_or(value_be.len()); + let value = &value_be[first_nonzero..]; + let Some((first, rest)) = self.entries.split_first() else { + return blake2b_256(value); + }; + let mut acc = blake2b_256_keyed(&first.0, value); + for entry in rest { + acc = blake2b_256_keyed(&entry.0, &acc); + } + acc + } +} + +fn blake2b_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Blake2b::::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn blake2b_256_keyed(key: &[u8], data: &[u8]) -> [u8; 32] { + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(key) + .hash(data); + <[u8; 32]>::try_from(hash.as_bytes()).expect("hash_length is 32") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn memo() -> TransferMemo { + TransferMemo { + entries: vec![MemoEntry([1u8; 64]), MemoEntry([2u8; 64])], + total_value: 1_234_567, + } + } + + #[test] + fn identifier_is_deterministic() { + assert_eq!(memo().identifier(), memo().identifier()); + } + + #[test] + fn identifier_matches_the_reference_fold_layout() { + let memo = TransferMemo { + entries: vec![MemoEntry([0x11; 64]), MemoEntry([0x22; 64])], + total_value: 100, + }; + // BigUInt(100).serialize == [0x64] — one byte, not 16. + let step1 = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x11; 64]) + .hash(&[0x64]); + let step2 = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x22; 64]) + .hash(step1.as_bytes()); + assert_eq!( + memo.identifier(), + <[u8; 32]>::try_from(step2.as_bytes()).unwrap() + ); + + // BigUInt(0).serialize is EMPTY — the fold starts from zero bytes. + let zero = TransferMemo { + entries: vec![MemoEntry([0x11; 64])], + total_value: 0, + }; + let expected = blake2b_simd::Params::new() + .hash_length(32) + .key(&[0x11; 64]) + .hash(&[]); + assert_eq!( + zero.identifier(), + <[u8; 32]>::try_from(expected.as_bytes()).unwrap() + ); + } + + #[test] + fn scale_layout_matches_ios_vec_data_then_compact_biguint() { + let memo = TransferMemo { + entries: vec![MemoEntry([0xAB; 64])], + total_value: 1_000, + }; + let encoded = memo.scale_encoded(); + assert_eq!(encoded[0], 0x04, "one-entry Vec compact prefix"); + assert_eq!(&encoded[1..3], &[0x01, 0x01], "64-byte Data prefix"); + assert_eq!(&encoded[3..67], &[0xAB; 64]); + assert_eq!(&encoded[67..], &[0xA1, 0x0F], "compact 1000"); + + let decoded = TransferMemo::from_scale_encoded(&encoded).unwrap(); + assert_eq!(decoded.entries, memo.entries); + assert_eq!(decoded.total_value, memo.total_value); + } + + #[test] + fn scale_decode_rejects_wrong_key_lengths_and_trailing_bytes() { + let mut wrong = vec![vec![1u8; 63]].encode(); + Compact(1u128).encode_to(&mut wrong); + assert!(TransferMemo::from_scale_encoded(&wrong).is_err()); + + let mut trailing = memo().scale_encoded(); + trailing.push(0); + assert!(TransferMemo::from_scale_encoded(&trailing).is_err()); + } + + #[test] + fn identifier_binds_value_entries_and_order() { + let base = memo().identifier(); + let mut other = memo(); + other.total_value += 1; + assert_ne!(base, other.identifier(), "total value is bound"); + + let mut reordered = memo(); + reordered.entries.reverse(); + assert_ne!(base, reordered.identifier(), "entry order is bound"); + + let mut truncated = memo(); + truncated.entries.pop(); + assert_ne!(base, truncated.identifier(), "every entry is bound"); + } + + #[test] + fn zeroize_clears_entry_bytes() { + let mut entry = MemoEntry([0xAB; 64]); + entry.zeroize(); + assert_eq!(entry.0, [0u8; 64]); + } + + #[test] + fn zeroize_clears_the_whole_memo() { + let mut memo = memo(); + memo.zeroize(); + assert!(memo.entries.is_empty(), "entries are dropped and wiped"); + assert_eq!(memo.total_value, 0); + } + + #[test] + fn debug_never_prints_key_bytes() { + let rendered = format!("{:?} {:?}", memo(), MemoEntry([0xCD; 64])); + assert!(!rendered.contains("205"), "no decimal byte dump"); + assert!(!rendered.to_lowercase().contains("cd"), "no hex byte dump"); + assert_eq!(rendered, "TransferMemo { entries: 2, .. } MemoEntry(..)"); + } +} diff --git a/rust/crates/truapi-coinage/src/model.rs b/rust/crates/truapi-coinage/src/model.rs new file mode 100644 index 000000000..7082461d4 --- /dev/null +++ b/rust/crates/truapi-coinage/src/model.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::constants::{COIN_MAX_AGE, MINIMUM_RING_SIZE, RECYCLE_AT_AGE}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoinState { + Available, + /// A recycle-into-voucher extrinsic is in flight. + Recycling, + /// Reserved for an outgoing transfer (set before submission, + /// reverted on failure). + PendingTransfer, + Spent, +} + +impl CoinState { + pub fn can_transition_to(self, to: CoinState) -> bool { + use CoinState::*; + matches!( + (self, to), + (Available, Recycling) + | (Available, PendingTransfer) + | (Recycling, Spent) + | (Recycling, Available) + | (PendingTransfer, Spent) + | (PendingTransfer, Available) + | (Spent, Available) + ) + } + + /// Stable integer for the `coins.state` column. + pub fn as_raw(self) -> i64 { + match self { + CoinState::Available => 0, + CoinState::Recycling => 1, + CoinState::PendingTransfer => 2, + CoinState::Spent => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => CoinState::Available, + 1 => CoinState::Recycling, + 2 => CoinState::PendingTransfer, + 3 => CoinState::Spent, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Coin { + pub exponent: i16, + pub derivation_index: u32, + pub age: Option, + pub state: CoinState, +} + +impl Coin { + pub fn is_expiring_soon(&self) -> bool { + matches!(self.age, Some(age) if age >= RECYCLE_AT_AGE) + } + + pub fn is_chain_invalid(&self) -> bool { + matches!(self.age, Some(age) if age >= COIN_MAX_AGE) + } + + /// Eligible for transfer selection: available and not expiring soon. + pub fn is_selectable(&self) -> bool { + self.state == CoinState::Available && !self.is_expiring_soon() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherRemoteState { + /// Not yet observed anywhere on-chain (also the state of freshly + /// recovered vouchers before `VoucherLocationService` reconciles). + Unlocated, + /// Ring membership submitted, not yet included. + Onboarding, + /// Included in a recycler ring at this index. + InRecycler { recycler_index: u32 }, + /// Consumed by an unload extrinsic. + Unloaded, +} + +impl VoucherRemoteState { + pub fn is_in_recycler(&self) -> bool { + matches!(self, VoucherRemoteState::InRecycler { .. }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherLocalState { + Available, + PendingTransfer, + PendingOnboarding, + Spent, +} + +impl VoucherLocalState { + pub fn as_raw(self) -> i64 { + match self { + VoucherLocalState::Available => 0, + VoucherLocalState::PendingTransfer => 1, + VoucherLocalState::PendingOnboarding => 2, + VoucherLocalState::Spent => 3, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => VoucherLocalState::Available, + 1 => VoucherLocalState::PendingTransfer, + 2 => VoucherLocalState::PendingOnboarding, + 3 => VoucherLocalState::Spent, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoucherPrivacyLevel { + Degraded, + Full, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectivePrivacy { + Degraded, + Full, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Voucher { + pub exponent: i16, + pub derivation_index: u32, + pub allocated_at_ms: i64, + pub ready_at_ms: i64, + pub remote_state: VoucherRemoteState, + pub local_state: VoucherLocalState, + pub privacy: VoucherPrivacyLevel, +} + +impl Voucher { + pub fn effective_privacy(&self, now_ms: i64) -> EffectivePrivacy { + if self.privacy == VoucherPrivacyLevel::Full && now_ms >= self.ready_at_ms { + EffectivePrivacy::Full + } else { + EffectivePrivacy::Degraded + } + } + + /// Spendable through an unload strategy: locally available and + /// on-chain in a recycler. + pub fn is_unloadable(&self) -> bool { + self.local_state == VoucherLocalState::Available && self.remote_state.is_in_recycler() + } +} + +pub fn ring_readiness_upgraded(included_members: u32) -> bool { + included_members >= MINIMUM_RING_SIZE +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coin(age: Option, state: CoinState) -> Coin { + Coin { + exponent: 0, + derivation_index: 1, + age, + state, + } + } + + #[test] + fn coin_state_machine_progression() { + use CoinState::*; + // The forward paths. + assert!(Available.can_transition_to(Recycling)); + assert!(Available.can_transition_to(PendingTransfer)); + assert!(Recycling.can_transition_to(Spent)); + assert!(PendingTransfer.can_transition_to(Spent)); + assert!(Recycling.can_transition_to(Available)); + assert!(PendingTransfer.can_transition_to(Available)); + assert!(Spent.can_transition_to(Available)); + // Undocumented jumps are rejected. + assert!(!Available.can_transition_to(Spent)); + assert!(!Available.can_transition_to(Available)); + assert!(!Spent.can_transition_to(Recycling)); + assert!(!Spent.can_transition_to(PendingTransfer)); + assert!(!Recycling.can_transition_to(PendingTransfer)); + assert!(!PendingTransfer.can_transition_to(Recycling)); + } + + #[test] + fn coin_state_raw_round_trips() { + for state in [ + CoinState::Available, + CoinState::Recycling, + CoinState::PendingTransfer, + CoinState::Spent, + ] { + assert_eq!(CoinState::from_raw(state.as_raw()), Some(state)); + } + assert_eq!(CoinState::from_raw(9), None); + } + + #[test] + fn expiring_soon_at_the_recycle_age_boundary() { + assert!(!coin(Some(13), CoinState::Available).is_expiring_soon()); + assert!(coin(Some(14), CoinState::Available).is_expiring_soon()); + assert!(coin(Some(16), CoinState::Available).is_expiring_soon()); + assert!(!coin(None, CoinState::Available).is_expiring_soon()); + } + + #[test] + fn chain_invalid_at_max_age() { + assert!(!coin(Some(15), CoinState::Available).is_chain_invalid()); + assert!(coin(Some(16), CoinState::Available).is_chain_invalid()); + } + + #[test] + fn selectable_excludes_expiring_and_non_available() { + assert!(coin(Some(13), CoinState::Available).is_selectable()); + assert!(!coin(Some(14), CoinState::Available).is_selectable()); + assert!(!coin(Some(1), CoinState::Recycling).is_selectable()); + assert!(!coin(Some(1), CoinState::PendingTransfer).is_selectable()); + assert!(!coin(Some(1), CoinState::Spent).is_selectable()); + } + + fn voucher(privacy: VoucherPrivacyLevel, ready_at_ms: i64) -> Voucher { + Voucher { + exponent: 0, + derivation_index: 1, + allocated_at_ms: 0, + ready_at_ms, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 0 }, + local_state: VoucherLocalState::Available, + privacy, + } + } + + #[test] + fn full_privacy_voucher_is_degraded_before_ready_at() { + let v = voucher(VoucherPrivacyLevel::Full, 1_000); + assert_eq!(v.effective_privacy(999), EffectivePrivacy::Degraded); + assert_eq!(v.effective_privacy(1_000), EffectivePrivacy::Full); + assert_eq!(v.effective_privacy(2_000), EffectivePrivacy::Full); + } + + #[test] + fn degraded_voucher_never_upgrades_by_time() { + let v = voucher(VoucherPrivacyLevel::Degraded, 1_000); + assert_eq!(v.effective_privacy(i64::MAX), EffectivePrivacy::Degraded); + } + + #[test] + fn ring_readiness_upgrades_exactly_at_the_minimum() { + assert!(!ring_readiness_upgraded(MINIMUM_RING_SIZE - 1)); + assert!(ring_readiness_upgraded(MINIMUM_RING_SIZE)); + assert!(ring_readiness_upgraded(MINIMUM_RING_SIZE + 1)); + } + + #[test] + fn minimum_ring_size_is_ten() { + assert_eq!(MINIMUM_RING_SIZE, 10); + } + + #[test] + fn unloadable_requires_local_available_and_in_recycler() { + let mut v = voucher(VoucherPrivacyLevel::Full, 0); + assert!(v.is_unloadable()); + v.remote_state = VoucherRemoteState::Onboarding; + assert!(!v.is_unloadable()); + v.remote_state = VoucherRemoteState::InRecycler { recycler_index: 2 }; + v.local_state = VoucherLocalState::PendingTransfer; + assert!(!v.is_unloadable()); + } +} diff --git a/rust/crates/truapi-coinage/src/outgoing_transfer.rs b/rust/crates/truapi-coinage/src/outgoing_transfer.rs new file mode 100644 index 000000000..03b6b9b7e --- /dev/null +++ b/rust/crates/truapi-coinage/src/outgoing_transfer.rs @@ -0,0 +1,922 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +use futures::future::AbortHandle; +use tokio::sync::Mutex as AsyncMutex; +use tracing::warn; + +use crate::clock::Clock; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState}; +use crate::query::CoinOnChainQueryService; +use crate::repo::{CoinRepository, TransferContext, VoucherRepository}; +use crate::selection::{CoinSelectionError, CoinSelector, TransferStrategy}; +use crate::wal::{ + CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore, +}; + +/// Dependencies that are stable for one active signing session. +pub struct OutgoingCoinTransferParts { + pub spawner: crate::Spawner, + pub coins: Arc, + pub vouchers: Arc, + pub wal: Arc, + pub on_chain: Arc, + pub denominations: DenominationBreakdownContext, + pub clock: Arc, + /// Finalized heads allowed for the opportunistic live settlement watch. + /// A timeout retains the durable reservation for a later reconciliation. + pub settlement_timeout_heads: u32, +} + +/// The handoff returned a definitive *not accepted* result. +/// This is deliberately a unit type. Transport diagnostics belong at the +/// adapter boundary; carrying an arbitrary error string through the +/// key-owning service makes accidental secret interpolation much easier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct OutgoingHandoffRejected; + +impl std::fmt::Display for OutgoingHandoffRejected { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("outgoing coin memo was rejected before acceptance") + } +} + +impl std::error::Error for OutgoingHandoffRejected {} + +/// Typed outgoing-transfer failures. No variant contains memo bytes or raw +/// coin keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutgoingTransferError { + Selection(CoinSelectionError), + /// The amount is representable and funded only by an on-chain split or + /// voucher unload. This service intentionally performs neither. + RequiresOnChainPreparation, + Query(String), + KeyDerivation(String), + Reservation(String), + Journal(String), + HandoffRejected, + /// An explicit pre-handoff rejection was received, but the durable + /// reservation could not be completely removed. Funds remain reserved. + Rollback(String), +} + +impl std::fmt::Display for OutgoingTransferError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Selection(error) => write!(formatter, "{error}"), + Self::RequiresOnChainPreparation => formatter + .write_str("exact whole coins are unavailable; on-chain preparation is required"), + Self::Query(error) => write!(formatter, "coin query failed: {error}"), + Self::KeyDerivation(error) => write!(formatter, "coin key derivation failed: {error}"), + Self::Reservation(error) => write!(formatter, "coin reservation failed: {error}"), + Self::Journal(error) => write!(formatter, "coin transfer journal failed: {error}"), + Self::HandoffRejected => { + formatter.write_str("outgoing coin memo was rejected before acceptance") + } + Self::Rollback(error) => { + write!( + formatter, + "outgoing coin reservation rollback failed: {error}" + ) + } + } + } +} + +impl std::error::Error for OutgoingTransferError {} + +impl From for OutgoingTransferError { + fn from(error: CoinSelectionError) -> Self { + Self::Selection(error) + } +} + +/// Outcome of one startup/manual pending-transfer reconciliation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OutgoingTransferReconciliation { + pub confirmed_spent: Vec, + pub still_pending: Vec, + pub completed_journals: usize, +} + +/// Outgoing transfer engine tied to exactly one root entropy. +/// Construct a fresh instance on signing-session activation and drop (or +/// [`shutdown`](Self::shutdown)) it on session teardown. +pub struct OutgoingCoinTransferService { + spawner: crate::Spawner, + key_factory: Arc, + coins: Arc, + vouchers: Arc, + wal: Arc, + on_chain: Arc, + denominations: DenominationBreakdownContext, + clock: Arc, + settlement_timeout_heads: u32, + handoff_lock: AsyncMutex<()>, + settlement_tasks: Mutex>, +} + +impl OutgoingCoinTransferService { + pub fn new(root_entropy: &[u8], parts: OutgoingCoinTransferParts) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins: parts.coins, + vouchers: parts.vouchers, + wal: parts.wal, + on_chain: parts.on_chain, + denominations: parts.denominations, + clock: parts.clock, + settlement_timeout_heads: parts.settlement_timeout_heads.max(1), + handoff_lock: AsyncMutex::new(()), + settlement_tasks: Mutex::new(Vec::new()), + spawner: parts.spawner, + } + } + + /// Converts a W3S/UI CASH-cent amount to the raw planks required by the + /// exact-coin selector. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + self.denominations.cash_cents_to_planks(cents) + } + + pub fn planks_per_cash_cent(&self) -> u128 { + self.denominations.asset_unit + } + + /// Selects and hands off exact whole coins. + /// `handoff` owns the memo and must return `Err` **only** when it can + /// certify that no recipient/transport durably accepted the secret. If + /// acceptance is ambiguous (timeout, cancellation after submit, lost + /// acknowledgement), it must return `Ok`; the normal payment/chat + /// tracker can report the transport uncertainty while the coin + /// reservation remains safe. + /// + /// For a host-correlated payment with durable preparation/restart + /// receipts, use `RegularCoinTransferService::confirm_operation`, which + /// also handles exact whole-coin transfers without chain preparation. + pub async fn handoff_exact( + &self, + amount_planks: u128, + handoff: F, + ) -> Result<(), OutgoingTransferError> + where + F: FnOnce(TransferMemo) -> Fut, + Fut: Future>, + { + // Selection + reservation is serialized per session so two callers + // cannot observe the same pre-reservation snapshot. + let _guard = self.handoff_lock.lock().await; + let present = self.present_local_coins().await?; + let selection = CoinSelector::new(self.denominations.clone(), usize::MAX).select( + amount_planks, + &present, + &[], + self.clock.now_ms(), + )?; + let selected = match selection.strategy { + TransferStrategy::ExactMatch { coins } => coins, + TransferStrategy::Split { .. } | TransferStrategy::UnloadIntoCoins { .. } => { + return Err(OutgoingTransferError::RequiresOnChainPreparation); + } + }; + + let indices = selected + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + let public_keys = indices + .iter() + .map(|index| { + self.key_factory + .public_key(*index) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let entries = indices + .iter() + .map(|index| { + self.key_factory + .secret_bytes(*index) + .map(MemoEntry) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let memo = TransferMemo { + entries, + total_value: amount_planks, + }; + let memo_identifier = memo.identifier(); + let entry_id = format!("secret-handoff-{}", hex::encode(memo_identifier)); + let journal = TransferWalEntry { + entry_id: entry_id.clone(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: selected + .iter() + .map(|coin| WalCoinRef { + derivation_index: coin.derivation_index, + exponent: coin.exponent, + }) + .collect(), + ..WalPayload::default() + }, + // Secret handoffs ignore extrinsic mortality. Pending remains + // the truthful checkpoint because no extrinsic is broadcast. + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }; + + let context = Arc::new(TransferContext::new( + Arc::clone(&self.coins), + Arc::clone(&self.vouchers), + )); + if let Err(error) = context.reserve(&indices, &[]).await { + let rollback = context.revert().await; + return Err(match rollback { + Ok(()) => OutgoingTransferError::Reservation(error), + Err(rollback) => OutgoingTransferError::Rollback(format!( + "reserve error: {error}; state restore error: {rollback}" + )), + }); + } + if let Err(error) = self.wal.save(&journal).await { + // A local persistence failure is expected to mean no row, but + // delete the deterministic id first to close an ambiguous + // commit edge before making the inputs selectable again. + if let Err(cleanup) = self.wal.delete(&entry_id).await { + return Err(OutgoingTransferError::Rollback(format!( + "journal error: {error}; journal cleanup error: {cleanup}; inputs retained" + ))); + } + return match context.revert().await { + Ok(()) => Err(OutgoingTransferError::Journal(error)), + Err(rollback) => Err(OutgoingTransferError::Rollback(format!( + "journal error: {error}; state restore error: {rollback}" + ))), + }; + } + + if handoff(memo).await.is_err() { + // Delete protection before restoring availability. The opposite + // order leaves a crash window with an Available coin still + // referenced by an indefinite secret-handoff journal. + if let Err(error) = self.wal.delete(&entry_id).await { + return Err(OutgoingTransferError::Rollback(format!( + "handoff rejected; journal cleanup error: {error}; inputs retained" + ))); + } + if let Err(error) = context.revert().await { + return Err(OutgoingTransferError::Rollback(format!( + "handoff rejected; state restore error: {error}" + ))); + } + return Err(OutgoingTransferError::HandoffRejected); + } + + self.spawn_settlement_watch(context, indices, public_keys, entry_id); + Ok(()) + } + + /// Reconciles every durable secret-handoff entry in one ordered batch. + /// Absent inputs become `Spent`; present inputs remain (or are restored + /// to) `PendingTransfer`. A journal is removed only when all its inputs + /// are absent. + pub async fn reconcile_pending_transfers( + &self, + ) -> Result { + let journals = self + .wal + .load_all() + .await + .map_err(OutgoingTransferError::Journal)? + .into_iter() + // Correlated operations have a durable parent and are reconciled + // by TransferRecoveryService. In particular, Prepared must not be + // mistaken for a proven accepted handoff here. + .filter(|entry| { + entry.operation == WalOperation::SecretHandoff + && entry.operation_parent_id().is_none() + }) + .collect::>(); + if journals.is_empty() { + return Ok(OutgoingTransferReconciliation::default()); + } + + let mut references = Vec::new(); + for (journal_index, journal) in journals.iter().enumerate() { + for input in &journal.payload.input_coins { + let public_key = self + .key_factory + .public_key(input.derivation_index) + .map_err(OutgoingTransferError::KeyDerivation)?; + references.push((journal_index, input.derivation_index, public_key)); + } + } + let public_keys = references + .iter() + .map(|(_, _, key)| *key) + .collect::>(); + let remote = self + .on_chain + .fetch_coins(&public_keys, None) + .await + .map_err(|error| OutgoingTransferError::Query(error.to_string()))?; + if remote.len() != references.len() { + return Err(OutgoingTransferError::Query( + "coin query returned an incomplete batch".into(), + )); + } + let local_states = self + .coins + .list() + .await + .map_err(OutgoingTransferError::Reservation)? + .into_iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(); + + let mut report = OutgoingTransferReconciliation::default(); + let mut journal_has_present = vec![false; journals.len()]; + for ((journal_index, derivation_index, _), reading) in references.into_iter().zip(remote) { + if reading.is_none() { + if local_states.get(&derivation_index) != Some(&CoinState::Spent) { + self.coins + .set_state(derivation_index, CoinState::Spent) + .await + .map_err(OutgoingTransferError::Reservation)?; + } + report.confirmed_spent.push(derivation_index); + } else { + journal_has_present[journal_index] = true; + if local_states.get(&derivation_index) == Some(&CoinState::Available) { + self.coins + .set_state(derivation_index, CoinState::PendingTransfer) + .await + .map_err(OutgoingTransferError::Reservation)?; + } + report.still_pending.push(derivation_index); + } + } + for (journal, has_present) in journals.iter().zip(journal_has_present) { + if !has_present && !journal.payload.input_coins.is_empty() { + self.wal + .delete(&journal.entry_id) + .await + .map_err(OutgoingTransferError::Journal)?; + report.completed_journals += 1; + } + } + report.confirmed_spent.sort_unstable(); + report.confirmed_spent.dedup(); + report.still_pending.sort_unstable(); + report.still_pending.dedup(); + Ok(report) + } + + /// Aborts live finalized-head waits. Durable journal rows and + /// reservations deliberately remain for the next session startup. + pub fn shutdown(&self) { + for task in self.settlement_tasks.lock().drain(..) { + task.abort(); + } + } + + async fn present_local_coins(&self) -> Result, OutgoingTransferError> { + let local = self + .coins + .list() + .await + .map_err(OutgoingTransferError::Reservation)?; + let candidates = local + .into_iter() + .filter(|coin| coin.is_selectable()) + .collect::>(); + let public_keys = candidates + .iter() + .map(|coin| { + self.key_factory + .public_key(coin.derivation_index) + .map_err(OutgoingTransferError::KeyDerivation) + }) + .collect::, _>>()?; + let remote = self + .on_chain + .fetch_coins(&public_keys, None) + .await + .map_err(|error| OutgoingTransferError::Query(error.to_string()))?; + if remote.len() != candidates.len() { + return Err(OutgoingTransferError::Query( + "coin query returned an incomplete batch".into(), + )); + } + Ok(candidates + .into_iter() + .zip(remote) + .filter_map(|(mut local, remote)| { + let remote = remote?; + local.exponent = remote.exponent; + local.age = Some(remote.age); + local.is_selectable().then_some(local) + }) + .collect()) + } + + fn spawn_settlement_watch( + &self, + context: Arc, + indices: Vec, + public_keys: Vec<[u8; 32]>, + entry_id: String, + ) { + let on_chain = Arc::clone(&self.on_chain); + let wal = Arc::clone(&self.wal); + let timeout = self.settlement_timeout_heads; + let task = crate::tasks::spawn_abortable(&self.spawner, async move { + match on_chain + .await_all_coins_off_chain(&public_keys, timeout) + .await + { + Ok(()) => { + if let Err(error) = context.process(&indices, &[]).await { + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement could not persist spent inputs" + ); + return; + } + if let Err(error) = wal.delete(&entry_id).await { + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement could not clear its journal" + ); + } + } + Err(error) => { + // Never revert after handoff. Startup/manual recovery + // can retry with a fresh connection. + warn!( + %error, + coin_count = indices.len(), + "outgoing transfer settlement watch ended; reservation retained" + ); + } + } + }); + self.settlement_tasks.lock().push(task); + } +} + +impl Drop for OutgoingCoinTransferService { + fn drop(&mut self) { + for task in self.settlement_tasks.get_mut().drain(..) { + task.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use async_trait::async_trait; + use futures::StreamExt; + use futures::channel::mpsc; + use futures::stream::{self, BoxStream}; + use parity_scale_codec::Encode; + + use super::*; + use crate::clock::FixedClock; + use crate::query::{CoinageStorageKey, CoinageStorageQuery}; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + const ENTROPY: [u8; 16] = [0x17; 16]; + type HeadReceiver = mpsc::UnboundedReceiver>; + + #[derive(Default)] + struct MemWal { + entries: Mutex>, + } + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.entries.lock(); + if entries.iter().any(|saved| saved.entry_id == entry.entry_id) { + return Err("duplicate journal".into()); + } + entries.push(entry.clone()); + Ok(()) + } + + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + for entry in entries { + self.save(entry).await?; + } + Ok(()) + } + + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.entries.lock(); + let entry = entries + .iter_mut() + .find(|entry| entry.entry_id == entry_id) + .ok_or_else(|| "journal not found".to_string())?; + entry.checkpoint = checkpoint; + Ok(()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self.entries.lock().clone()) + } + + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.entries + .lock() + .retain(|entry| entry.entry_id != entry_id); + Ok(()) + } + } + + struct TestStorage { + values: Mutex>>, + heads: Mutex>, + } + + impl Default for TestStorage { + fn default() -> Self { + Self { + values: Mutex::new(HashMap::new()), + heads: Mutex::new(None), + } + } + } + + impl TestStorage { + fn set_coin(&self, public_key: [u8; 32], exponent: i8, age: u16) { + self.values.lock().insert( + CoinageStorageKey::Coin(public_key), + (exponent, age).encode(), + ); + } + + fn remove_coin(&self, public_key: [u8; 32]) { + self.values + .lock() + .remove(&CoinageStorageKey::Coin(public_key)); + } + + fn head_channel(&self) -> mpsc::UnboundedSender> { + let (sender, receiver) = mpsc::unbounded(); + *self.heads.lock() = Some(receiver); + sender + } + } + + #[async_trait] + impl CoinageStorageQuery for TestStorage { + async fn query( + &self, + keys: &[CoinageStorageKey], + _at: Option<[u8; 32]>, + ) -> Result>>, String> { + let values = self.values.lock(); + Ok(keys.iter().map(|key| values.get(key).cloned()).collect()) + } + + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + self.heads + .lock() + .take() + .map(StreamExt::boxed) + .unwrap_or_else(|| stream::pending().boxed()) + } + } + + struct Harness { + service: OutgoingCoinTransferService, + coins: Arc, + wal: Arc, + storage: Arc, + } + + fn coin(index: u32) -> Coin { + Coin { + // Deliberately stale: selection must use the on-chain value. + exponent: 0, + derivation_index: index, + age: None, + state: CoinState::Available, + } + } + + fn denominations() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 6, + min_exponent: 0, + precision: 2, + } + } + + fn harness(local: Vec) -> Harness { + let coins = Arc::new(InMemoryCoinRepository::with_coins(local)); + let wal = Arc::new(MemWal::default()); + let storage = Arc::new(TestStorage::default()); + let service = OutgoingCoinTransferService::new( + &ENTROPY, + OutgoingCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers: Arc::new(InMemoryVoucherRepository::default()), + wal: Arc::clone(&wal) as Arc<_>, + on_chain: Arc::new(CoinOnChainQueryService::new(Arc::clone(&storage) as Arc<_>)), + denominations: denominations(), + clock: Arc::new(FixedClock(1_234)), + settlement_timeout_heads: 3, + }, + ); + Harness { + service, + coins, + wal, + storage, + } + } + + async fn state(coins: &InMemoryCoinRepository, index: u32) -> CoinState { + coins + .list() + .await + .unwrap() + .into_iter() + .find(|coin| coin.derivation_index == index) + .unwrap() + .state + } + + #[tokio::test] + async fn exact_on_chain_selection_reserves_before_handoff_and_settles_on_absence() { + let harness = harness(vec![coin(1), coin(2), coin(3)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(1).unwrap(), 2, 1); // 40 + harness.storage.set_coin(keys.public_key(2).unwrap(), 1, 1); // 20 + harness.storage.set_coin(keys.public_key(3).unwrap(), 0, 1); // 10 + let heads = harness.storage.head_channel(); + let observed = Arc::new(Mutex::new(None)); + + harness + .service + .handoff_exact(60, { + let observed = Arc::clone(&observed); + let coins = Arc::clone(&harness.coins); + move |memo| async move { + assert_eq!(state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(state(&coins, 2).await, CoinState::PendingTransfer); + assert_eq!(state(&coins, 3).await, CoinState::Available); + *observed.lock() = Some(( + memo.total_value, + memo.entries.iter().map(|entry| entry.0).collect::>(), + )); + Ok(()) + } + }) + .await + .unwrap(); + + let observed = observed.lock().take().unwrap(); + assert_eq!(observed.0, 60); + assert_eq!( + observed.1, + vec![keys.secret_bytes(1).unwrap(), keys.secret_bytes(2).unwrap()] + ); + let journal = harness.wal.load_all().await.unwrap(); + assert_eq!(journal.len(), 1); + assert_eq!(journal[0].operation, WalOperation::SecretHandoff); + assert_eq!(journal[0].created_at_ms, 1_234); + + harness.storage.remove_coin(keys.public_key(1).unwrap()); + harness.storage.remove_coin(keys.public_key(2).unwrap()); + heads.unbounded_send(Ok([9; 32])).unwrap(); + for _ in 0..40 { + if state(&harness.coins, 1).await == CoinState::Spent { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(state(&harness.coins, 1).await, CoinState::Spent); + assert_eq!(state(&harness.coins, 2).await, CoinState::Spent); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_insufficient_nonrepresentable_and_preparation_only_amounts() { + let harness = harness(vec![coin(1)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(1).unwrap(), 2, 1); // 40 + + let never = |_memo| async { Ok::<(), OutgoingHandoffRejected>(()) }; + assert_eq!( + harness.service.handoff_exact(5, never).await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::AmountNotRepresentable { remainder: 5 } + )) + ); + assert_eq!( + harness + .service + .handoff_exact(80, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::InsufficientFunds + )) + ); + assert_eq!( + harness + .service + .handoff_exact(20, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::RequiresOnChainPreparation) + ); + assert_eq!(state(&harness.coins, 1).await, CoinState::Available); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn explicit_pre_handoff_rejection_removes_journal_then_rolls_back() { + let harness = harness(vec![coin(4)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(4).unwrap(), 1, 1); + let saw_commit_boundary = Arc::new(Mutex::new(false)); + + let result = harness + .service + .handoff_exact(20, { + let saw_commit_boundary = Arc::clone(&saw_commit_boundary); + let coins = Arc::clone(&harness.coins); + let wal = Arc::clone(&harness.wal); + move |_memo| async move { + assert_eq!(state(&coins, 4).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + *saw_commit_boundary.lock() = true; + Err(OutgoingHandoffRejected) + } + }) + .await; + + assert_eq!(result, Err(OutgoingTransferError::HandoffRejected)); + assert!(*saw_commit_boundary.lock()); + assert_eq!(state(&harness.coins, 4).await, CoinState::Available); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn accepted_handoff_stays_pending_and_cannot_be_selected_twice() { + let harness = harness(vec![coin(5)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(5).unwrap(), 2, 1); + harness + .service + .handoff_exact(40, |_memo| async { Ok(()) }) + .await + .unwrap(); + + assert_eq!(state(&harness.coins, 5).await, CoinState::PendingTransfer); + assert_eq!(harness.wal.load_all().await.unwrap().len(), 1); + assert_eq!( + harness + .service + .handoff_exact(40, |_memo| async { Ok(()) }) + .await, + Err(OutgoingTransferError::Selection( + CoinSelectionError::EmptyWallet + )) + ); + let report = harness.service.reconcile_pending_transfers().await.unwrap(); + assert_eq!(report.still_pending, [5]); + assert!(report.confirmed_spent.is_empty()); + assert_eq!(harness.wal.load_all().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn startup_reconciliation_retires_absent_handoffs() { + let harness = harness(vec![coin(6)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + let public_key = keys.public_key(6).unwrap(); + harness.storage.set_coin(public_key, 0, 1); + harness + .service + .handoff_exact(10, |_memo| async { Ok(()) }) + .await + .unwrap(); + harness.service.shutdown(); + harness.storage.remove_coin(public_key); + + let report = harness.service.reconcile_pending_transfers().await.unwrap(); + assert_eq!(report.confirmed_spent, [6]); + assert!(report.still_pending.is_empty()); + assert_eq!(report.completed_journals, 1); + assert_eq!(state(&harness.coins, 6).await, CoinState::Spent); + assert!(harness.wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn diagnostics_never_render_expanded_coin_secrets() { + let harness = harness(vec![coin(7)]); + let keys = CoinKeypairFactory::new(&ENTROPY); + harness.storage.set_coin(keys.public_key(7).unwrap(), 0, 1); + let secret_hex = hex::encode(keys.secret_bytes(7).unwrap()); + let rendered_memo = Arc::new(Mutex::new(String::new())); + + let error = harness + .service + .handoff_exact(10, { + let rendered_memo = Arc::clone(&rendered_memo); + move |memo| async move { + *rendered_memo.lock() = format!("{memo:?}"); + Err(OutgoingHandoffRejected) + } + }) + .await + .unwrap_err(); + let diagnostics = format!("{error:?} {error} {}", rendered_memo.lock()); + assert!(!diagnostics.contains(&secret_hex)); + } + + struct FailingReservationRepository { + inner: Arc, + fail_index: u32, + } + + #[async_trait] + impl CoinRepository for FailingReservationRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.inner.upsert(coin).await + } + + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + if derivation_index == self.fail_index && state == CoinState::PendingTransfer { + return Err("injected reservation failure".into()); + } + self.inner.set_state(derivation_index, state).await + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await + } + } + + #[tokio::test] + async fn partial_reservation_failure_restores_every_prior_input() { + let inner = Arc::new(InMemoryCoinRepository::with_coins([coin(1), coin(2)])); + let coins = Arc::new(FailingReservationRepository { + inner: Arc::clone(&inner), + fail_index: 2, + }); + let storage = Arc::new(TestStorage::default()); + let keys = CoinKeypairFactory::new(&ENTROPY); + storage.set_coin(keys.public_key(1).unwrap(), 1, 1); // 20 + storage.set_coin(keys.public_key(2).unwrap(), 0, 1); // 10 + let wal = Arc::new(MemWal::default()); + let service = OutgoingCoinTransferService::new( + &ENTROPY, + OutgoingCoinTransferParts { + spawner: crate::test_spawner(), + coins, + vouchers: Arc::new(InMemoryVoucherRepository::default()), + wal: Arc::clone(&wal) as Arc<_>, + on_chain: Arc::new(CoinOnChainQueryService::new(storage)), + denominations: denominations(), + clock: Arc::new(FixedClock(0)), + settlement_timeout_heads: 1, + }, + ); + + let error = service + .handoff_exact(30, |_memo| async { Ok(()) }) + .await + .unwrap_err(); + assert!(matches!(error, OutgoingTransferError::Reservation(_))); + assert_eq!(state(&inner, 1).await, CoinState::Available); + assert_eq!(state(&inner, 2).await, CoinState::Available); + assert!(wal.load_all().await.unwrap().is_empty()); + } +} diff --git a/rust/crates/truapi-coinage/src/pallet.rs b/rust/crates/truapi-coinage/src/pallet.rs new file mode 100644 index 000000000..52109a779 --- /dev/null +++ b/rust/crates/truapi-coinage/src/pallet.rs @@ -0,0 +1,963 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::{Compact, Encode}; +use serde_json::{Value, json}; + +/// The pallet name every call and storage path hangs off. +pub const PALLET_NAME: &str = "Coinage"; + +/// The transaction-extension identifier of the coinage origins. +pub const AS_COINAGE_EXTENSION_ID: &str = "AsCoinage"; + +pub mod storage { + pub const COINS_BY_OWNER: &str = "CoinsByOwner"; + pub const RECYCLERS_COIN_TO_RECYCLER: &str = "RecyclersCoinToRecycler"; + /// Alias state storage. The Host resolves legacy denomination/ring/alias + /// keys or asset-instance-prefixed keys from runtime metadata. + pub const RECYCLER_ALIAS_STATES: &str = "RecyclerAliasStates"; + pub const CONSUMED_FREE_UNLOAD_TOKENS: &str = "ConsumedFreeUnloadTokens"; +} + +/// Call names, exactly as the runtime metadata spells them. +pub mod calls { + pub const LOAD_EXTERNAL_ASSET_UNPAID_BATCH: &str = + "load_recycler_with_external_asset_unpaid_batch"; + pub const SPLIT: &str = "split"; + pub const UNLOAD_RECYCLER_INTO_COINS: &str = "unload_recycler_into_coins"; + pub const LOAD_RECYCLER_WITH_COIN: &str = "load_recycler_with_coin"; + pub const UNLOAD_RECYCLER_INTO_EXTERNAL_ASSET: &str = "unload_recycler_into_external_asset"; + /// 2026-08 upgrade rename of `…_and_vouchers`. + pub const UNLOAD_RECYCLER_INTO_EXTERNAL_ASSET_AND_LOADED_COINS: &str = + "unload_recycler_into_external_asset_and_loaded_coins"; + pub const TRANSFER: &str = "transfer"; +} + +/// `Coinage.load_recycler_with_coin(member_key, proof_of_ownership)`. +/// Both arguments are fixed byte arrays in the live metadata, so their +/// SCALE representation is the raw bytes with no compact length prefix. +/// Pallet/call indices are resolved from runtime metadata by the host. +pub fn load_recycler_with_coin_call( + pallet_index: u8, + call_index: u8, + member_key: &[u8; 32], + proof_of_ownership: &[u8; 64], +) -> Vec { + let mut call = Vec::with_capacity(2 + member_key.len() + proof_of_ownership.len()); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(member_key); + call.extend_from_slice(proof_of_ownership); + call +} + +/// `Coinage.transfer(to)`. +/// The live runtime declares `to` as one fixed 32-byte account id. The +/// source coin is not an argument: it is selected by the signed +/// `AsCoinage(Some(AsCoin))` transaction extension and the extrinsic +/// signer's sr25519 account id. Keeping this builder fixed-width prevents +/// accidentally encoding the recipient as a SCALE `Vec`. +pub fn transfer_call(pallet_index: u8, call_index: u8, recipient: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + recipient.len()); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(recipient); + call +} + +/// Physical Members collection for a runtime-selected Coinage ABI. +/// `None` uses the legacy denomination at byte 16; `Some` inserts the +/// little-endian asset instance at bytes 16..20 and moves denomination to +/// byte 20, matching native `RecyclerCollectionIdentifier`. +/// The Host validates the runtime denomination before calling this helper. +pub fn recycler_collection_identifier(instance_id: Option, coin_value: i16) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..16].copy_from_slice(b"coinage/recycler"); + let denomination_offset = if let Some(instance_id) = instance_id { + id[16..20].copy_from_slice(&instance_id.to_le_bytes()); + 20 + } else { + 16 + }; + id[denomination_offset] = coin_value.clamp(0, u8::MAX as i16) as u8; + id +} + +fn hex_value(bytes: &[u8]) -> Value { + Value::String(format!("0x{}", hex::encode(bytes))) +} + +fn string_number(value: impl ToString) -> Value { + Value::String(value.to_string()) +} + +/// `Preservation` of an unpaid external-asset load (tagged union, payload +/// always null). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preservation { + Protect, + Preserve, + Expendable, +} + +impl Preservation { + fn to_json(self) -> Value { + let tag = match self { + Preservation::Protect => "Protect", + Preservation::Preserve => "Preserve", + Preservation::Expendable => "Expendable", + }; + json!([tag, null]) + } + + /// Pinned from the live `CodecPreservation` metadata variant indices, + /// which are NOT the declaration order of this enum. + fn scale_index(self) -> u8 { + match self { + Self::Expendable => 0, + Self::Protect => 1, + Self::Preserve => 2, + } + } +} + +/// One item of `load_recycler_with_external_asset_unpaid_batch`. +/// Field order and widths are pinned from the live +/// `indiv_pallet_coinage::pallet::UnpaidLoadInput` composite: +/// `preservation: CodecPreservation, value: i8, member_key: [u8; 32], +/// proof_of_ownership: [u8; 64]`. `value` is a coin exponent, so the +/// runtime's one-byte width is the whole domain (`MinimumExponent` 0..= +/// `MaximumExponent` 14). +#[derive(Debug, Clone)] +pub struct UnpaidLoadInput { + pub value: i8, + pub preservation: Preservation, + pub member_key: [u8; 32], + pub proof_of_ownership: Vec, +} + +impl UnpaidLoadInput { + pub fn to_json(&self) -> Value { + json!({ + "value": string_number(self.value), + "preservation": self.preservation.to_json(), + "memberKey": hex_value(&self.member_key), + "proofOfOwnership": hex_value(&self.proof_of_ownership), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SplitDestination { + pub exponent: i16, + pub accounts: Vec<[u8; 32]>, +} + +impl SplitDestination { + pub fn to_json(&self) -> Value { + json!([ + string_number(self.exponent), + self.accounts + .iter() + .map(|a| hex_value(a)) + .collect::>(), + ]) + } +} + +/// The `split` call args (`split_into`). +pub fn split_args(split_into: &[SplitDestination]) -> Value { + json!({ + "split_into": split_into.iter().map(SplitDestination::to_json).collect::>(), + }) +} + +/// SCALE call bytes for `Coinage.split(split_into)`. +/// Live metadata declares `split_into` as +/// `Vec<(i8, Vec)>`. The public model retains `i16` so it can +/// share the denomination domain; this boundary rejects an exponent that the +/// runtime cannot represent rather than truncating it. +pub fn split_call( + pallet_index: u8, + call_index: u8, + split_into: &[SplitDestination], +) -> Result, String> { + let count = u32::try_from(split_into.len()) + .map_err(|_| "Coinage split destination list is too large".to_string())?; + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&Compact(count).encode()); + for destination in split_into { + let exponent = i8::try_from(destination.exponent).map_err(|_| { + format!( + "Coinage split exponent {} does not fit the live i8 field", + destination.exponent + ) + })?; + call.push(exponent as u8); + call.extend_from_slice(&destination.accounts.encode()); + } + Ok(call) +} + +/// The batch-load call args (`items`). +pub fn load_external_asset_unpaid_batch_args(items: &[UnpaidLoadInput]) -> Value { + json!({ + "items": items.iter().map(UnpaidLoadInput::to_json).collect::>(), + }) +} + +/// SCALE call bytes for +/// `Coinage.load_recycler_with_external_asset_unpaid_batch(items)`. +/// Runtime metadata pins each item as +/// `(Preservation, i8, [u8; 32], [u8; 64])`. Keeping the proof +/// length check at this boundary prevents a JSON-era `Vec` assumption +/// from silently producing a different call layout. +pub fn load_external_asset_unpaid_batch_call( + pallet_index: u8, + call_index: u8, + items: &[UnpaidLoadInput], +) -> Result, String> { + let item_count = u32::try_from(items.len()) + .map_err(|_| "Coinage unpaid voucher batch is too large".to_string())?; + let mut call = Vec::with_capacity(2 + 5 + items.len().saturating_mul(99)); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&Compact(item_count).encode()); + for item in items { + let proof: &[u8; 64] = item.proof_of_ownership.as_slice().try_into().map_err( + |_: std::array::TryFromSliceError| { + format!( + "Coinage unpaid voucher proof has {} bytes; expected 64", + item.proof_of_ownership.len() + ) + }, + )?; + // `preservation` precedes `value` in the runtime composite, and + // `value` is one byte — the reverse of both was a silent + // field-shift that made the node's decode panic. + call.push(item.preservation.scale_index()); + call.extend_from_slice(&item.value.to_le_bytes()); + call.extend_from_slice(&item.member_key); + call.extend_from_slice(proof); + } + Ok(call) +} + +fn aliases_json(aliases: &[Vec]) -> Vec { + aliases.iter().map(|alias| hex_value(alias)).collect() +} + +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoCoinsArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub split_into: Vec, + pub max_fee: u128, +} + +impl UnloadRecyclerIntoCoinsArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "split_into": self + .split_into + .iter() + .map(SplitDestination::to_json) + .collect::>(), + "max_fee": string_number(self.max_fee), + }) + } +} + +/// SCALE call bytes for `Coinage.unload_recycler_into_coins`. +/// Legacy field order and widths are: +/// `Vec<[u8;32]>, i8, u32, u32, Vec<(i8, Vec)>, u128`. +/// For the asset-instance ABI, `Some(instance_id)` inserts a fixed-width +/// little-endian `u32` before aliases, with no SCALE `Option` discriminant. +/// The Host must select the ABI and instance from metadata and trusted config. +/// Aliases are accepted as fixed arrays here so a JSON-era variable-length +/// value cannot shift every following field. +#[allow(clippy::too_many_arguments)] +pub fn unload_recycler_into_coins_call( + pallet_index: u8, + call_index: u8, + instance_id: Option, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + split_into: &[SplitDestination], + max_fee: u128, +) -> Result, String> { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + if let Some(instance_id) = instance_id { + call.extend_from_slice(&instance_id.to_le_bytes()); + } + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + + let count = u32::try_from(split_into.len()) + .map_err(|_| "Coinage unload destination list is too large".to_string())?; + call.extend_from_slice(&Compact(count).encode()); + for destination in split_into { + let exponent = i8::try_from(destination.exponent).map_err(|_| { + format!( + "Coinage unload destination exponent {} does not fit the live i8 field", + destination.exponent + ) + })?; + call.push(exponent as u8); + call.extend_from_slice(&destination.accounts.encode()); + } + call.extend_from_slice(&max_fee.to_le_bytes()); + Ok(call) +} + +/// Arguments for `Coinage.unload_recycler_into_external_asset`. +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoExternalAssetArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub to: [u8; 32], +} + +impl UnloadRecyclerIntoExternalAssetArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "to": hex_value(&self.to), + }) + } +} + +/// SCALE call bytes for `unload_recycler_into_external_asset`. +/// Live metadata: `Vec<[u8; 32]>`, `i8`, `u32`, `u32`, `[u8; 32]`. +pub fn unload_recycler_into_external_asset_call( + pallet_index: u8, + call_index: u8, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + to: &[u8; 32], +) -> Vec { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + call.extend_from_slice(to); + call +} + +#[derive(Debug, Clone)] +pub struct NewVoucher { + pub coin_value: i8, + pub member_key: [u8; 32], +} + +impl NewVoucher { + pub fn to_json(&self) -> Value { + json!([string_number(self.coin_value), hex_value(&self.member_key),]) + } +} + +/// Arguments for +/// `Coinage.unload_recycler_into_external_asset_and_vouchers`. +#[derive(Debug, Clone)] +pub struct UnloadRecyclerIntoExternalAssetAndVouchersArgs { + pub aliases: Vec>, + pub value: i8, + pub index: u32, + pub revision: u32, + pub to: [u8; 32], + pub external_asset_amount: u128, + pub new_vouchers: Vec, +} + +impl UnloadRecyclerIntoExternalAssetAndVouchersArgs { + pub fn to_json(&self) -> Value { + json!({ + "aliases": aliases_json(&self.aliases), + "value": string_number(self.value), + "index": string_number(self.index), + "revision": string_number(self.revision), + "to": hex_value(&self.to), + "external_asset_amount": string_number(self.external_asset_amount), + "new_vouchers": self + .new_vouchers + .iter() + .map(NewVoucher::to_json) + .collect::>(), + }) + } +} + +/// SCALE call bytes for +/// `unload_recycler_into_external_asset_and_vouchers`. +/// The surplus entries are unkeyed `(i8, [u8; 32])` tuples and the external +/// amount is a fixed-width little-endian `u128`, as pinned by live metadata. +#[allow(clippy::too_many_arguments)] +pub fn unload_recycler_into_external_asset_and_vouchers_call( + pallet_index: u8, + call_index: u8, + aliases: &[[u8; 32]], + value: i8, + index: u32, + revision: u32, + to: &[u8; 32], + external_asset_amount: u128, + new_vouchers: &[NewVoucher], +) -> Vec { + let mut call = Vec::new(); + call.extend_from_slice(&[pallet_index, call_index]); + call.extend_from_slice(&aliases.encode()); + call.push(value as u8); + call.extend_from_slice(&index.to_le_bytes()); + call.extend_from_slice(&revision.to_le_bytes()); + call.extend_from_slice(to); + call.extend_from_slice(&external_asset_amount.to_le_bytes()); + call.extend_from_slice(&Compact(new_vouchers.len() as u32).encode()); + for voucher in new_vouchers { + call.push(voucher.coin_value as u8); + call.extend_from_slice(&voucher.member_key); + } + call +} + +/// The personhood half of an unload-token proof: the ring-VRF proof and +/// the ring it opens against. +#[derive(Debug, Clone)] +pub struct PeopleProof { + pub proof: Vec, + pub ring: u32, + /// The ring revision the proof was built against — required by the + /// 2026-08 runtime's `MembershipProof` (spec 1000032). + pub revision: u32, +} + +impl PeopleProof { + fn to_json(&self) -> Value { + json!({ + "proof": hex_value(&self.proof), + "ring": string_number(self.ring), + "revision": string_number(self.revision), + }) + } +} + +#[derive(Debug, Clone)] +pub enum AsCoinageMode { + /// Sr25519 coin-keypair origin — the variant tag is the whole payload. + AsCoin, + /// Full-person unload token: people-ring proof + per-voucher recycler + /// alias proofs. + AsUnloadTokenPeople { + proof: PeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Lite-person unload token (same payload as the full-person mode). + AsUnloadTokenLitePeople { + proof: PeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Pre-computed proof from the PAID unload-token ring. + AsUnloadTokenPaid { + proof: Vec, + period: u32, + paid_token_ring_index: u32, + paid_token_ring_revision: u32, + alias_proofs: Vec>, + }, + /// Fee recycler output as the token (first alias proof = fee coin). + /// `retry_counter` joined in the 2026-08 runtime (spec 1000032). + AsUnloadTokenFromOutput { + fee_recycler_value: i8, + fee_recycler_index: u32, + fee_recycler_revision: u32, + retry_counter: u8, + alias_proofs: Vec>, + }, + InfallibleUnpaidSigned { + nonce: u32, + }, +} + +impl AsCoinageMode { + /// The `["TagName", null | payload]` JSON the dynamic SCALE layer + /// consumes. + pub fn to_json(&self) -> Value { + let alias_list = + |proofs: &[Vec]| proofs.iter().map(|p| hex_value(p)).collect::>(); + match self { + AsCoinageMode::AsCoin => json!(["AsCoin", null]), + AsCoinageMode::AsUnloadTokenPeople { + proof, + period, + counter, + alias_proofs, + } => json!([ + "AsUnloadTokenPeople", + { + "proof": proof.to_json(), + "period": string_number(period), + "counter": string_number(counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenLitePeople { + proof, + period, + counter, + alias_proofs, + } => json!([ + "AsUnloadTokenLitePeople", + { + "proof": proof.to_json(), + "period": string_number(period), + "counter": string_number(counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenPaid { + proof, + period, + paid_token_ring_index, + paid_token_ring_revision, + alias_proofs, + } => json!([ + "AsUnloadTokenPaid", + { + "proof": hex_value(proof), + "period": string_number(period), + "paidTokenRingIndex": string_number(paid_token_ring_index), + "paidTokenRingRevision": string_number(paid_token_ring_revision), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::AsUnloadTokenFromOutput { + fee_recycler_value, + fee_recycler_index, + fee_recycler_revision, + retry_counter, + alias_proofs, + } => json!([ + "AsUnloadTokenFromOutput", + { + "feeRecyclerValue": string_number(fee_recycler_value), + "feeRecyclerIndex": string_number(fee_recycler_index), + "feeRecyclerRevision": string_number(fee_recycler_revision), + "retryCounter": string_number(retry_counter), + "aliasProofs": alias_list(alias_proofs), + } + ]), + AsCoinageMode::InfallibleUnpaidSigned { nonce } => json!([ + "InfallibleUnpaidSigned", + { "nonce": string_number(nonce) } + ]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_recycler_with_coin_call_has_fixed_array_arguments() { + let call = load_recycler_with_coin_call(52, 2, &[0x11; 32], &[0x22; 64]); + assert_eq!(call.len(), 98); + assert_eq!(&call[..2], &[52, 2]); + assert_eq!(&call[2..34], &[0x11; 32]); + assert_eq!(&call[34..], &[0x22; 64]); + } + + #[test] + fn unpaid_external_asset_batch_call_matches_fixed_runtime_layout() { + let call = load_external_asset_unpaid_batch_call( + 52, + 1, + &[ + UnpaidLoadInput { + value: -2, + preservation: Preservation::Expendable, + member_key: [0x11; 32], + proof_of_ownership: vec![0x22; 64], + }, + UnpaidLoadInput { + value: 3, + preservation: Preservation::Protect, + member_key: [0x33; 32], + proof_of_ownership: vec![0x44; 64], + }, + ], + ) + .unwrap(); + // Pinned from the live `UnpaidLoadInput` composite: preservation + // (1 byte, live variant indices) then value (1 byte), member key, + // proof. Getting either the order or the value width wrong shifts + // every later field and the node's decode panics. + assert_eq!(&call[..3], &[52, 1, 8], "two-item Compact"); + assert_eq!(call[3], 0, "Expendable variant"); + assert_eq!(call[4] as i8, -2i8); + assert_eq!(&call[5..37], &[0x11; 32]); + assert_eq!(&call[37..101], &[0x22; 64]); + assert_eq!(call[101], 1, "Protect variant"); + assert_eq!(call[102] as i8, 3i8); + assert_eq!(&call[103..135], &[0x33; 32]); + assert_eq!(&call[135..199], &[0x44; 64]); + assert_eq!(call.len(), 199, "2 + 1 + 2 * (1 + 1 + 32 + 64)"); + } + + #[test] + fn unpaid_external_asset_batch_rejects_variable_length_proof() { + let error = load_external_asset_unpaid_batch_call( + 1, + 2, + &[UnpaidLoadInput { + value: 0, + preservation: Preservation::Preserve, + member_key: [0; 32], + proof_of_ownership: vec![0; 63], + }], + ) + .unwrap_err(); + assert!(error.contains("63 bytes; expected 64")); + } + + #[test] + fn transfer_call_has_one_fixed_account_argument() { + let call = transfer_call(52, 6, &[0xA5; 32]); + assert_eq!(call.len(), 34); + assert_eq!(&call[..2], &[52, 6]); + assert_eq!(&call[2..], &[0xA5; 32]); + } + + #[test] + fn split_call_matches_live_scale_layout() { + let call = split_call( + 52, + 3, + &[ + SplitDestination { + exponent: -1, + accounts: vec![[0x11; 32], [0x22; 32]], + }, + SplitDestination { + exponent: 4, + accounts: vec![[0x33; 32]], + }, + ], + ) + .unwrap(); + assert_eq!(&call[..3], &[52, 3, 8], "two destination tuples"); + assert_eq!(call[3], 0xff, "i8 exponent"); + assert_eq!(call[4], 8, "two accounts"); + assert_eq!(&call[5..37], &[0x11; 32]); + assert_eq!(&call[37..69], &[0x22; 32]); + assert_eq!(call[69], 4); + assert_eq!(call[70], 4, "one account"); + assert_eq!(&call[71..103], &[0x33; 32]); + assert_eq!(call.len(), 103); + } + + #[test] + fn unload_into_coins_call_matches_legacy_scale_layout() { + let call = unload_recycler_into_coins_call( + 52, + 4, + None, + &[[0xAA; 32], [0xBB; 32]], + -2, + 0x1122_3344, + 0x5566_7788, + &[SplitDestination { + exponent: 3, + accounts: vec![[0xCC; 32]], + }], + 9, + ) + .unwrap(); + assert_eq!(&call[..3], &[52, 4, 8], "two aliases"); + assert_eq!(&call[3..35], &[0xAA; 32]); + assert_eq!(&call[35..67], &[0xBB; 32]); + assert_eq!(call[67], 0xfe); + assert_eq!(&call[68..72], &0x1122_3344u32.to_le_bytes()); + assert_eq!(&call[72..76], &0x5566_7788u32.to_le_bytes()); + assert_eq!(call[76], 4, "one split tuple"); + assert_eq!(call[77], 3); + assert_eq!(call[78], 4, "one account"); + assert_eq!(&call[79..111], &[0xCC; 32]); + assert_eq!(&call[111..], &9u128.to_le_bytes()); + assert_eq!(call.len(), 127); + } + + #[test] + fn unload_into_coins_call_matches_asset_instance_scale_layout() { + let call = unload_recycler_into_coins_call( + 52, + 4, + Some(0x1122_3344), + &[[0xAA; 32], [0xBB; 32]], + -2, + 0x5566_7788, + 0x99AA_BBCC, + &[SplitDestination { + exponent: 3, + accounts: vec![[0xCC; 32]], + }], + 9, + ) + .unwrap(); + // The complete runtime argument tuple catches an Option tag, misplaced + // instance, variable-width alias or shifted trailing argument. + let expected = ( + 52u8, + 4u8, + 0x1122_3344u32, + vec![[0xAAu8; 32], [0xBB; 32]], + -2i8, + 0x5566_7788u32, + 0x99AA_BBCCu32, + vec![(3i8, vec![[0xCCu8; 32]])], + 9u128, + ) + .encode(); + assert_eq!(call, expected); + } + + #[test] + fn coin_output_calls_reject_exponents_outside_live_i8() { + for exponent in [-129, 128] { + let destinations = [SplitDestination { + exponent, + accounts: vec![[0; 32]], + }]; + assert!(split_call(1, 2, &destinations).is_err()); + for instance_id in [None, Some(0x1122_3344)] { + assert!( + unload_recycler_into_coins_call( + 1, + 2, + instance_id, + &[], + 0, + 0, + 0, + &destinations, + 0, + ) + .is_err() + ); + } + } + } + + #[test] + fn recycler_collection_matches_legacy_and_asset_instance_wire_layouts() { + assert_eq!( + recycler_collection_identifier(None, 5), + *b"coinage/recycler\x05\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0" + ); + assert_eq!( + recycler_collection_identifier(Some(0x1122_3344), 5), + *b"coinage/recycler\x44\x33\x22\x11\x05\0\0\0\0\0\0\0\0\0\0\0" + ); + } + + #[test] + fn as_coinage_modes_match_the_reference_json() { + assert_eq!(AsCoinageMode::AsCoin.to_json(), json!(["AsCoin", null])); + assert_eq!( + AsCoinageMode::InfallibleUnpaidSigned { nonce: 7 }.to_json(), + json!(["InfallibleUnpaidSigned", { "nonce": "7" }]) + ); + let people = AsCoinageMode::AsUnloadTokenPeople { + proof: PeopleProof { + proof: vec![0xAA], + ring: 2, + revision: 6, + }, + period: 9, + counter: 1, + alias_proofs: vec![vec![0xBB], vec![0xCC]], + } + .to_json(); + assert_eq!( + people, + json!([ + "AsUnloadTokenPeople", + { + "proof": { "proof": "0xaa", "ring": "2", "revision": "6" }, + "period": "9", + "counter": "1", + "aliasProofs": ["0xbb", "0xcc"], + } + ]) + ); + let from_output = AsCoinageMode::AsUnloadTokenFromOutput { + fee_recycler_value: -1, + fee_recycler_index: 3, + fee_recycler_revision: 4, + retry_counter: 2, + alias_proofs: vec![], + } + .to_json(); + assert_eq!( + from_output[1]["feeRecyclerValue"], + Value::String("-1".into()) + ); + } + + #[test] + fn call_args_match_the_reference_json() { + let split = split_args(&[SplitDestination { + exponent: 3, + accounts: vec![[1u8; 32]], + }]); + assert_eq!(split["split_into"][0][0], Value::String("3".into())); + assert!( + split["split_into"][0][1][0] + .as_str() + .unwrap() + .starts_with("0x0101") + ); + + let batch = load_external_asset_unpaid_batch_args(&[UnpaidLoadInput { + value: 2, + preservation: Preservation::Expendable, + member_key: [9u8; 32], + proof_of_ownership: vec![0xDD], + }]); + let item = &batch["items"][0]; + assert_eq!(item["value"], Value::String("2".into())); + assert_eq!(item["preservation"], json!(["Expendable", null])); + assert_eq!(item["proofOfOwnership"], Value::String("0xdd".into())); + } + + #[test] + fn unload_call_args_match_the_reference_json() { + let into_coins = UnloadRecyclerIntoCoinsArgs { + aliases: vec![vec![0xAA; 32]], + value: -2, + index: 7, + revision: 9, + split_into: vec![SplitDestination { + exponent: 3, + accounts: vec![[0x11; 32]], + }], + max_fee: 0, + } + .to_json(); + assert_eq!( + into_coins, + json!({ + "aliases": [format!("0x{}", "aa".repeat(32))], + "value": "-2", + "index": "7", + "revision": "9", + "split_into": [[ + "3", + [format!("0x{}", "11".repeat(32))] + ]], + "max_fee": "0", + }) + ); + + let into_asset = UnloadRecyclerIntoExternalAssetArgs { + aliases: vec![vec![0xBB; 32], vec![0xCC; 32]], + value: 4, + index: 10, + revision: 11, + to: [0x22; 32], + } + .to_json(); + assert_eq!(into_asset["aliases"].as_array().unwrap().len(), 2); + assert_eq!(into_asset["value"], "4"); + assert_eq!(into_asset["index"], "10"); + assert_eq!(into_asset["revision"], "11"); + assert_eq!( + into_asset["to"], + Value::String(format!("0x{}", "22".repeat(32))) + ); + + let with_change = UnloadRecyclerIntoExternalAssetAndVouchersArgs { + aliases: vec![vec![0xDD; 32]], + value: 5, + index: 12, + revision: 13, + to: [0x33; 32], + external_asset_amount: u128::MAX, + new_vouchers: vec![NewVoucher { + coin_value: -1, + member_key: [0x44; 32], + }], + } + .to_json(); + assert_eq!( + with_change["external_asset_amount"], + Value::String(u128::MAX.to_string()) + ); + assert_eq!( + with_change["new_vouchers"][0], + json!(["-1", format!("0x{}", "44".repeat(32)),]) + ); + assert!(with_change.get("externalAssetAmount").is_none()); + assert!(with_change.get("newVouchers").is_none()); + } + + #[test] + fn external_asset_unload_calls_match_live_scale_layout() { + let aliases = [[0x11; 32], [0x22; 32]]; + let plain = + unload_recycler_into_external_asset_call(68, 5, &aliases, -2, 7, 9, &[0x33; 32]); + assert_eq!(&plain[..3], &[68, 5, 8], "two aliases, compact len 2"); + assert_eq!(&plain[3..35], &[0x11; 32]); + assert_eq!(&plain[35..67], &[0x22; 32]); + assert_eq!(plain[67], (-2i8) as u8); + assert_eq!(&plain[68..72], &7u32.to_le_bytes()); + assert_eq!(&plain[72..76], &9u32.to_le_bytes()); + assert_eq!(&plain[76..], &[0x33; 32]); + + let with_change = unload_recycler_into_external_asset_and_vouchers_call( + 68, + 9, + &aliases[..1], + 4, + 10, + 11, + &[0x44; 32], + 123, + &[NewVoucher { + coin_value: -1, + member_key: [0x55; 32], + }], + ); + assert_eq!(&with_change[..3], &[68, 9, 4], "one alias"); + let amount_offset = 3 + 32 + 1 + 4 + 4 + 32; + assert_eq!( + &with_change[amount_offset..amount_offset + 16], + &123u128.to_le_bytes() + ); + assert_eq!(with_change[amount_offset + 16], 4, "one voucher"); + assert_eq!(with_change[amount_offset + 17], (-1i8) as u8); + assert_eq!(&with_change[amount_offset + 18..], &[0x55; 32]); + } +} diff --git a/rust/crates/truapi-coinage/src/query.rs b/rust/crates/truapi-coinage/src/query.rs new file mode 100644 index 000000000..94446c198 --- /dev/null +++ b/rust/crates/truapi-coinage/src/query.rs @@ -0,0 +1,1441 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use crate::members::{self, RingPosition, RingRoot}; +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt}; +use parity_scale_codec::{Decode, DecodeAll, Encode}; +use tokio::sync::mpsc; +use tracing::warn; + +use crate::claim::SendConfirmation; +use crate::repo::VoucherRepository; +use crate::selection::RecyclerKey; +use crate::sync::OnChainCoin; +use crate::voucher_location::{ + RingPosition as VoucherRingPosition, RingStatus as VoucherRingStatus, + VoucherLocationSubscriber, VoucherLocationUpdate, +}; + +/// Semantic Coinage storage requests. The Host resolves physical keys +/// from runtime metadata pinned to the query block and the configured asset +/// instance. Recycler collections are never encoded by the portable engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CoinageStorageKey { + Coin([u8; 32]), + Recycler([u8; 32]), + RecyclerAlias { + exponent: i8, + ring_index: u32, + alias: [u8; 32], + }, + Member { + exponent: i16, + member: [u8; 32], + }, + Root { + exponent: i16, + ring_index: u32, + }, + RingKeysStatus { + exponent: i16, + ring_index: u32, + }, +} + +impl CoinageStorageKey { + fn recycler_alias( + exponent: i16, + ring_index: u32, + alias: [u8; 32], + ) -> Result { + let exponent = + i8::try_from(exponent).map_err(|_| CoinageQueryError::InvalidExponent(exponent))?; + Ok(Self::RecyclerAlias { + exponent, + ring_index, + alias, + }) + } +} + +/// The storage/finalized-head effect. Production uses a Host RPC +/// adapter; tests provide a deterministic batch source. +#[async_trait] +pub trait CoinageStorageQuery: Send + Sync { + /// One head-pinned query, resolving semantic keys against that head's + /// runtime metadata and configured asset instance, preserving request order. + /// + /// Values are canonical engine SCALE rows, not raw runtime storage bytes: + /// `Coin` is `(i8, u16)`, `Recycler` is `i8`, `RecyclerAlias` is `AliasState`, + /// `Member` is `RingPosition`, `Root` is `RingRoot`, and `RingKeysStatus` is + /// `members::RingStatus`. The Host must validate each complete runtime value + /// against metadata and the selected asset before removing runtime-specific + /// instance fields and returning the canonical row. + async fn query( + &self, + keys: &[CoinageStorageKey], + at: Option<[u8; 32]>, + ) -> Result>>, String>; + + /// The canonical finalized head used to pin a coherent multi-stage + /// query. Test/deterministic sources may supply it through their + /// finalized stream; the RPC adapter uses the direct one-shot method. + async fn finalized_head(&self) -> Result<[u8; 32], String> { + let mut heads = self.finalized_heads(); + heads + .next() + .await + .ok_or_else(|| "finalized-head subscription terminated".to_string())? + } + + /// A fresh finalized-head stream. Consumers race this against their + /// storage condition and block timeout. + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>>; + + fn observation_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + self.finalized_heads() + } +} + +/// Typed failures from the query layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoinageQueryError { + Storage(String), + Decode { + storage: &'static str, + message: String, + }, + ResponseLength { + storage: &'static str, + expected: usize, + actual: usize, + }, + SubscriptionTerminated, + Timeout { + finalized_heads: u32, + }, + InvalidExponent(i16), +} + +impl fmt::Display for CoinageQueryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Storage(message) => formatter.write_str(message), + Self::Decode { storage, message } => { + write!(formatter, "{storage} SCALE decode failed: {message}") + } + Self::ResponseLength { + storage, + expected, + actual, + } => write!( + formatter, + "{storage} returned {actual} values for {expected} keys" + ), + Self::SubscriptionTerminated => { + formatter.write_str("finalized-head subscription terminated") + } + Self::Timeout { finalized_heads } => { + write!( + formatter, + "coin query timed out after {finalized_heads} finalized heads" + ) + } + Self::InvalidExponent(exponent) => { + write!( + formatter, + "coinage exponent {exponent} does not fit the runtime i8" + ) + } + } + } +} + +impl std::error::Error for CoinageQueryError {} + +/// `Coinage.RecyclerAliasStates` value +/// (`indiv_pallet_coinage::pallet::AliasState`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum AliasState { + /// Load/unload is locked until the carried seconds timestamp + /// (`LockInfo { reason: LockReason::FailedDispatch(u8), until: u64 }`). + #[codec(index = 0)] + Locked(LockInfo), + #[codec(index = 1)] + Unloaded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct LockInfo { + pub reason: LockReason, + pub until: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum LockReason { + #[codec(index = 0)] + FailedDispatch(u8), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct RawOnChainCoin { + value: i8, + age: u16, +} + +fn decode_coin(bytes: &[u8]) -> Result { + let raw = + RawOnChainCoin::decode_all(&mut &bytes[..]).map_err(|error| CoinageQueryError::Decode { + storage: "Coinage.CoinsByOwner", + message: error.to_string(), + })?; + let age = i16::try_from(raw.age).map_err(|_| CoinageQueryError::Decode { + storage: "Coinage.CoinsByOwner", + message: format!("age {} does not fit i16", raw.age), + })?; + Ok(OnChainCoin { + exponent: i16::from(raw.value), + age, + }) +} + +fn decode_optional( + bytes: Option>, + storage: &'static str, +) -> Result, CoinageQueryError> { + bytes + .map(|bytes| { + T::decode_all(&mut &bytes[..]).map_err(|error| CoinageQueryError::Decode { + storage, + message: error.to_string(), + }) + }) + .transpose() +} + +fn ensure_response_len( + storage: &'static str, + expected: usize, + actual: usize, +) -> Result<(), CoinageQueryError> { + if expected == actual { + Ok(()) + } else { + Err(CoinageQueryError::ResponseLength { + storage, + expected, + actual, + }) + } +} + +/// Batch coin fetch + finalized-head-raced presence/claim waits. +pub struct CoinOnChainQueryService { + storage: Arc, +} + +impl CoinOnChainQueryService { + pub fn new(storage: Arc) -> Self { + Self { storage } + } + + /// Fetches N `CoinsByOwner` entries in one RPC, preserving input + /// order. `at` enables the historical anchor probe used after a fast + /// claim. + pub async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + at: Option<[u8; 32]>, + ) -> Result>, CoinageQueryError> { + if public_keys.is_empty() { + return Ok(Vec::new()); + } + let keys = public_keys + .iter() + .copied() + .map(CoinageStorageKey::Coin) + .collect::>(); + let values = self + .storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len("Coinage.CoinsByOwner", keys.len(), values.len())?; + values + .into_iter() + .map(|value| value.map(|bytes| decode_coin(&bytes)).transpose()) + .collect() + } + + /// Resolves once every key has been observed present. Presence is + /// accumulated across finalized heads because a storage subscription + /// may emit only the keys changed in a block, and an early coin may + /// already be claimed by the time a later one appears. + pub async fn await_all_coins_on_chain( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result<(), CoinageQueryError> { + self.await_all_coins_sent_or_claimed(public_keys, block_timeout) + .await + .map(|_| ()) + } + + /// Resolves once every key is absent (claimed/spent). + pub async fn await_all_coins_off_chain( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result<(), CoinageQueryError> { + self.await_condition(public_keys, block_timeout, |coins| { + coins.iter().all(Option::is_none).then_some(()) + }) + .await + } + + /// Accumulates `seen` across partial lifecycle observations: once all + /// keys have appeared at least once, returns whether any remains + /// present. This distinguishes `OnChain` from an ultra-fast + /// `AlreadyClaimed` completion and races the finalized-head count. + pub async fn await_all_coins_sent_or_claimed( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + ) -> Result { + if public_keys.is_empty() { + return Ok(true); + } + let mut heads = self.storage.finalized_heads(); + let mut seen = vec![false; public_keys.len()]; + let mut present = vec![false; public_keys.len()]; + let limit = block_timeout.max(1); + let mut count = 0u32; + while let Some(head) = heads.next().await { + let head = head.map_err(CoinageQueryError::Storage)?; + count = count.saturating_add(1); + let coins = self.fetch_coins(public_keys, Some(head)).await?; + for (index, coin) in coins.into_iter().enumerate() { + present[index] = coin.is_some(); + if present[index] { + seen[index] = true; + } + } + if seen.iter().all(|value| *value) { + return Ok(present.iter().any(|value| *value)); + } + if count >= limit { + return Err(CoinageQueryError::Timeout { + finalized_heads: limit, + }); + } + } + Err(CoinageQueryError::SubscriptionTerminated) + } + + pub async fn await_send_or_claimed( + &self, + public_keys: &[[u8; 32]], + anchor_hash: Option<[u8; 32]>, + block_timeout: u32, + ) -> Result { + if public_keys.is_empty() { + return Ok(SendConfirmation::OnChain); + } + let mut confirmed = vec![false; public_keys.len()]; + let current = self.fetch_coins(public_keys, None).await?; + let mut any_present = false; + for (index, coin) in current.into_iter().enumerate() { + if coin.is_some() { + confirmed[index] = true; + any_present = true; + } + } + + if confirmed.iter().any(|value| !*value) + && let Some(anchor_hash) = anchor_hash + { + let absent_offsets = confirmed + .iter() + .enumerate() + .filter_map(|(index, confirmed)| (!confirmed).then_some(index)) + .collect::>(); + let absent_keys = absent_offsets + .iter() + .map(|index| public_keys[*index]) + .collect::>(); + let historical = self.fetch_coins(&absent_keys, Some(anchor_hash)).await?; + for (offset, coin) in historical.into_iter().enumerate() { + if coin.is_some() { + confirmed[absent_offsets[offset]] = true; + } + } + } + + if confirmed.iter().all(|value| *value) { + return Ok(if any_present { + SendConfirmation::OnChain + } else { + SendConfirmation::AlreadyClaimed + }); + } + + let remaining = confirmed + .iter() + .enumerate() + .filter_map(|(index, confirmed)| (!confirmed).then_some(public_keys[index])) + .collect::>(); + let any_remaining_present = self + .await_all_coins_sent_or_claimed(&remaining, block_timeout) + .await?; + Ok(if any_present || any_remaining_present { + SendConfirmation::OnChain + } else { + SendConfirmation::AlreadyClaimed + }) + } + + async fn await_condition( + &self, + public_keys: &[[u8; 32]], + block_timeout: u32, + condition: impl Fn(&[Option]) -> Option, + ) -> Result { + if public_keys.is_empty() { + return condition(&[]).ok_or(CoinageQueryError::SubscriptionTerminated); + } + let mut heads = self.storage.finalized_heads(); + let limit = block_timeout.max(1); + let mut count = 0u32; + while let Some(head) = heads.next().await { + let head = head.map_err(CoinageQueryError::Storage)?; + count = count.saturating_add(1); + let coins = self.fetch_coins(public_keys, Some(head)).await?; + if let Some(output) = condition(&coins) { + return Ok(output); + } + if count >= limit { + return Err(CoinageQueryError::Timeout { + finalized_heads: limit, + }); + } + } + Err(CoinageQueryError::SubscriptionTerminated) + } +} + +/// Complete on-chain voucher observation after the four query stages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherOnChainInfo { + pub exponent: i16, + pub ring_position: RingPosition, + pub is_unloaded: bool, +} + +type KeyProvider = dyn Fn(u32) -> Result<[u8; 32], String> + Send + Sync; + +#[derive(Debug, Clone)] +enum VoucherLocationRequest { + Position { derivation_index: u32 }, + Status { derivation_index: u32 }, +} + +pub struct QueryVoucherLocationSubscriber { + spawner: crate::Spawner, + storage: Arc, + vouchers: Arc, + public_key_provider: Arc, + observation_refresh_interval: Duration, +} + +/// A head notification is the fast path, while this current-best refresh is +/// the recovery path. In particular, a rejected/stopped `chainHead` follow +/// must not strand vouchers whose ring proof set advances after their member +/// position was first observed. +const VOUCHER_LOCATION_OBSERVATION_REFRESH: Duration = Duration::from_secs(8); + +impl QueryVoucherLocationSubscriber { + pub fn new( + storage: Arc, + vouchers: Arc, + public_key_provider: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + storage, + vouchers, + public_key_provider, + observation_refresh_interval: VOUCHER_LOCATION_OBSERVATION_REFRESH, + spawner, + } + } + + #[cfg(test)] + fn with_observation_refresh_interval(mut self, interval: Duration) -> Self { + self.observation_refresh_interval = interval; + self + } + + async fn requests( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + let vouchers = self + .vouchers + .list() + .await? + .into_iter() + .map(|voucher| (voucher.derivation_index, voucher)) + .collect::>(); + let voucher = |index: u32| { + vouchers + .get(&index) + .ok_or_else(|| format!("voucher {index} disappeared before location subscribe")) + }; + + let mut requests = Vec::with_capacity(pending.len() + included.len() + degraded.len()); + for derivation_index in pending { + let voucher = voucher(derivation_index)?; + let member = (self.public_key_provider)(derivation_index)?; + requests.push(( + VoucherLocationRequest::Position { derivation_index }, + CoinageStorageKey::Member { + exponent: voucher.exponent, + member, + }, + )); + } + for (derivation_index, ring_index) in included.into_iter().chain(degraded) { + let voucher = voucher(derivation_index)?; + requests.push(( + VoucherLocationRequest::Status { derivation_index }, + CoinageStorageKey::RingKeysStatus { + exponent: voucher.exponent, + ring_index, + }, + )); + } + Ok(requests) + } +} + +async fn fetch_voucher_location_update( + storage: &dyn CoinageStorageQuery, + requests: &[(VoucherLocationRequest, CoinageStorageKey)], + at: Option<[u8; 32]>, +) -> Result { + let keys = requests.iter().map(|(_, key)| *key).collect::>(); + let values = storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Members voucher-location batch", + requests.len(), + values.len(), + )?; + + let mut update = VoucherLocationUpdate::default(); + for ((request, _), value) in requests.iter().zip(values) { + match request { + VoucherLocationRequest::Position { derivation_index } => { + let position = + decode_optional::(value, "Members.Members")?.map(|position| { + match position { + RingPosition::Included { + ring_index, + ring_position, + .. + } => VoucherRingPosition::Included { + ring_index, + included_at: ring_position, + }, + RingPosition::Onboarding { .. } | RingPosition::Suspended => { + VoucherRingPosition::Onboarding + } + } + }); + update.ring_positions.push((*derivation_index, position)); + } + VoucherLocationRequest::Status { derivation_index } => { + if let Some(status) = + decode_optional::(value, "Members.RingKeysStatus")? + { + update.ring_statuses.push(( + *derivation_index, + VoucherRingStatus { + included_members: status.included, + }, + )); + } + } + } + } + Ok(update) +} + +#[async_trait] +impl VoucherLocationSubscriber for QueryVoucherLocationSubscriber { + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + let requests = self.requests(pending, included, degraded).await?; + let storage = Arc::clone(&self.storage); + let observation_refresh_interval = self.observation_refresh_interval; + let (sender, receiver) = mpsc::channel(4); + (self.spawner)(Box::pin(async move { + // Start the live observer before the initial snapshot so a best + // change racing the query is buffered rather than lost. `None` + // makes `state_queryStorageAt` read the current best state, which + // is the same visibility used by the iOS storage subscription. + let mut heads = storage.observation_heads(); + match fetch_voucher_location_update(storage.as_ref(), &requests, None).await { + Ok(update) => { + if sender.send(update).await.is_err() { + return; + } + } + Err(error) => warn!(%error, "initial voucher location query failed"), + } + + let mut heads_open = true; + let refresh = crate::timer::sleep(observation_refresh_interval).fuse(); + futures::pin_mut!(refresh); + loop { + let open = heads_open; + let next_head = async { + if open { + heads.next().await + } else { + futures::future::pending().await + } + } + .fuse(); + let closed = sender.closed().fuse(); + futures::pin_mut!(next_head, closed); + let at = futures::select! { + head = next_head => match head { + Some(Ok(head)) => Some(head), + Some(Err(error)) => { + warn!(%error, "voucher location head update failed"); + continue; + } + None => { + heads_open = false; + continue; + } + }, + _ = refresh => { + refresh.set(crate::timer::sleep(observation_refresh_interval).fuse()); + None + }, + _ = closed => return, + }; + + if sender.is_closed() { + return; + } + match fetch_voucher_location_update(storage.as_ref(), &requests, at).await { + Ok(update) => { + if sender.send(update).await.is_err() { + return; + } + } + Err(error) => warn!(%error, "voucher location query failed"), + } + } + })); + Ok(receiver) + } +} + +/// Four-stage voucher query (`RecyclersCoinToRecycler` → `Members` → +/// `RecyclerAliasStates`), preserving the original derivation-index order. +pub struct VoucherOnChainQueryService { + storage: Arc, + public_key_provider: Arc, + alias_provider: Arc, +} + +impl VoucherOnChainQueryService { + pub fn new( + storage: Arc, + public_key_provider: Arc, + alias_provider: Arc, + ) -> Self { + Self { + storage, + public_key_provider, + alias_provider, + } + } + + pub async fn fetch_vouchers( + &self, + derivation_indices: &[u32], + at: Option<[u8; 32]>, + ) -> Result>, CoinageQueryError> { + self.fetch_voucher_observations(derivation_indices, at, false) + .await + } + + /// Recovery must observe every assigned derivation index, not only keys + /// already included in a recycler ring. Otherwise onboarding/suspended + /// keys could be allocated again after restoring the wallet. + /// + /// Every stage is pinned to the caller's finalized recovery snapshot. + /// An assignment without a Members row is ambiguous, not an empty index. + pub async fn fetch_recovery_vouchers( + &self, + derivation_indices: &[u32], + at: [u8; 32], + ) -> Result>, CoinageQueryError> { + self.fetch_voucher_observations(derivation_indices, Some(at), true) + .await + } + + async fn fetch_voucher_observations( + &self, + derivation_indices: &[u32], + at: Option<[u8; 32]>, + recovery: bool, + ) -> Result>, CoinageQueryError> { + if derivation_indices.is_empty() { + return Ok(Vec::new()); + } + let mut output = vec![None; derivation_indices.len()]; + + struct Candidate { + output_index: usize, + derivation_index: u32, + exponent: i16, + position: RingPosition, + ring_index: u32, + } + + // Step 1: key derivation. + let indexed_keys = derivation_indices + .iter() + .copied() + .enumerate() + .map(|(output_index, derivation_index)| { + (self.public_key_provider)(derivation_index) + .map(|public_key| (output_index, derivation_index, public_key)) + .map_err(CoinageQueryError::Storage) + }) + .collect::, _>>()?; + + // Step 2: recycler denomination. + let exponent_keys = indexed_keys + .iter() + .map(|(_, _, public_key)| CoinageStorageKey::Recycler(*public_key)) + .collect::>(); + let exponent_values = self + .storage + .query(&exponent_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Coinage.RecyclersCoinToRecycler", + exponent_keys.len(), + exponent_values.len(), + )?; + let mut with_exponents = Vec::new(); + for ((output_index, derivation_index, public_key), value) in + indexed_keys.into_iter().zip(exponent_values) + { + if let Some(exponent) = decode_optional::(value, "Coinage.RecyclersCoinToRecycler")? + { + with_exponents.push(( + output_index, + derivation_index, + public_key, + i16::from(exponent), + )); + } + } + if with_exponents.is_empty() { + return Ok(output); + } + + // Step 3: Members position under the denomination's recycler + // collection. Normal unload queries exclude onboarding/suspended + // rows; recovery retains them as used, non-unloadable indices. + let position_keys = with_exponents + .iter() + .map(|(_, _, public_key, exponent)| CoinageStorageKey::Member { + exponent: *exponent, + member: *public_key, + }) + .collect::>(); + let position_values = self + .storage + .query(&position_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Members.Members", + position_keys.len(), + position_values.len(), + )?; + let mut with_positions = Vec::::new(); + for ((output_index, derivation_index, _public_key, exponent), value) in + with_exponents.into_iter().zip(position_values) + { + let Some(position) = decode_optional::(value, "Members.Members")? else { + if recovery { + return Err(CoinageQueryError::Storage( + "Coinage recovery found a recycler assignment without a Members row".into(), + )); + } + continue; + }; + let RingPosition::Included { ring_index, .. } = position else { + if recovery { + output[output_index] = Some(VoucherOnChainInfo { + exponent, + ring_position: position, + is_unloaded: false, + }); + } + continue; + }; + with_positions.push(Candidate { + output_index, + derivation_index, + exponent, + position, + ring_index, + }); + } + if with_positions.is_empty() { + return Ok(output); + } + + // Step 4: alias state. A missing row is a loaded, unlocked alias; + // only an explicit `AliasState::Unloaded` counts as unloaded (a + // `Locked` row is still loaded — the lock only gates dispatch). + let state_keys = with_positions + .iter() + .map(|candidate| { + let alias = (self.alias_provider)(candidate.derivation_index) + .map_err(CoinageQueryError::Storage)?; + CoinageStorageKey::recycler_alias(candidate.exponent, candidate.ring_index, alias) + }) + .collect::, _>>()?; + let state_values = self + .storage + .query(&state_keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len( + "Coinage.RecyclerAliasStates", + state_keys.len(), + state_values.len(), + )?; + + for (candidate, state) in with_positions.into_iter().zip(state_values) { + let state = decode_optional::(state, "Coinage.RecyclerAliasStates")?; + output[candidate.output_index] = Some(VoucherOnChainInfo { + exponent: candidate.exponent, + ring_position: candidate.position, + is_unloaded: state == Some(AliasState::Unloaded), + }); + } + Ok(output) + } +} + +pub struct RecyclerReadinessLoader { + storage: Arc, +} + +/// One coherent recycler-readiness snapshot. Every revision was read from +/// the same finalized block, and callers carry that block into unload +/// preparation rather than mixing roots observed at different heads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecyclerRevisionSnapshot { + pub block_hash: [u8; 32], + pub revisions: HashMap, +} + +impl RecyclerReadinessLoader { + pub fn new(storage: Arc) -> Self { + Self { storage } + } + + /// Captures the canonical finalized head and pins the complete revision + /// batch to it. This is the production entry point for unload + /// preparation: a missing root stays missing and must fail the caller + /// rather than being replaced by a guessed revision. + pub async fn fetch_finalized_revisions( + &self, + recycler_keys: &[RecyclerKey], + ) -> Result { + let block_hash = self + .storage + .finalized_head() + .await + .map_err(CoinageQueryError::Storage)?; + let revisions = self + .fetch_revisions(recycler_keys, Some(block_hash)) + .await?; + Ok(RecyclerRevisionSnapshot { + block_hash, + revisions, + }) + } + + pub async fn fetch_revisions( + &self, + recycler_keys: &[RecyclerKey], + at: Option<[u8; 32]>, + ) -> Result, CoinageQueryError> { + if recycler_keys.is_empty() { + return Ok(HashMap::new()); + } + let keys = recycler_keys + .iter() + .map(|recycler| CoinageStorageKey::Root { + exponent: recycler.exponent, + ring_index: recycler.index, + }) + .collect::>(); + let values = self + .storage + .query(&keys, at) + .await + .map_err(CoinageQueryError::Storage)?; + ensure_response_len("Members.Root", keys.len(), values.len())?; + let mut revisions = HashMap::new(); + for (recycler, value) in recycler_keys.iter().copied().zip(values) { + if let Some(root) = decode_optional::(value, "Members.Root")? { + revisions.insert(recycler, root.revision); + } + } + Ok(revisions) + } +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + use std::collections::VecDeque; + use std::time::Duration; + + use futures::stream; + + use super::*; + + type QueryRecord = (Vec, Option<[u8; 32]>); + type QueryResponse = Result>>, String>; + type HeadBatch = Vec>; + + #[derive(Default)] + struct ScriptedStorage { + queries: Mutex>, + responses: Mutex>, + heads: Mutex>, + } + + impl ScriptedStorage { + fn push_response(&self, response: Vec>>) { + self.responses.lock().push_back(Ok(response)); + } + + fn push_heads(&self, heads: impl IntoIterator) { + self.heads + .lock() + .push_back(heads.into_iter().map(|byte| Ok([byte; 32])).collect()); + } + } + + #[async_trait] + impl CoinageStorageQuery for ScriptedStorage { + async fn query( + &self, + keys: &[CoinageStorageKey], + at: Option<[u8; 32]>, + ) -> Result>>, String> { + self.queries.lock().push((keys.to_vec(), at)); + self.responses + .lock() + .pop_front() + .expect("missing scripted response") + } + + fn finalized_heads(&self) -> BoxStream<'static, Result<[u8; 32], String>> { + Box::pin(stream::iter( + self.heads + .lock() + .pop_front() + .expect("missing scripted heads"), + )) + } + } + + fn raw_coin(value: i8, age: u16) -> Vec { + RawOnChainCoin { value, age }.encode() + } + + fn location_voucher(derivation_index: u32, exponent: i16) -> crate::Voucher { + crate::Voucher { + exponent, + derivation_index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: crate::VoucherRemoteState::Unlocated, + local_state: crate::VoucherLocalState::Available, + privacy: crate::VoucherPrivacyLevel::Degraded, + } + } + + #[test] + fn alias_query_rejects_out_of_range_exponent() { + assert!(matches!( + CoinageStorageKey::recycler_alias(500, 0, [0; 32]), + Err(CoinageQueryError::InvalidExponent(500)) + )); + } + + #[test] + fn alias_state_scale_wire_boundary() { + // AliasState wire shape: Unloaded is the bare variant 1; Locked + // carries reason + until. + assert_eq!(AliasState::Unloaded.encode(), vec![1]); + let locked = AliasState::Locked(LockInfo { + reason: LockReason::FailedDispatch(2), + until: 99, + }); + let encoded = locked.encode(); + assert_eq!(encoded[0], 0); + assert_eq!(encoded.len(), 1 + 1 + 1 + 8); + assert_eq!(AliasState::decode(&mut &encoded[..]).unwrap(), locked); + } + + #[tokio::test] + async fn voucher_location_batch_decodes_included_position_and_coverage() { + let storage = ScriptedStorage::default(); + storage.push_response(vec![ + Some( + RingPosition::Included { + ring_index: 7, + ring_page: 2, + ring_position: 4, + } + .encode(), + ), + Some( + members::RingStatus { + total: 9, + included: 5, + immutable_since: None, + } + .encode(), + ), + ]); + let requests = vec![ + ( + VoucherLocationRequest::Position { + derivation_index: 11, + }, + CoinageStorageKey::Member { + exponent: 3, + member: [11; 32], + }, + ), + ( + VoucherLocationRequest::Status { + derivation_index: 11, + }, + CoinageStorageKey::RingKeysStatus { + exponent: 3, + ring_index: 7, + }, + ), + ]; + + assert_eq!( + fetch_voucher_location_update(&storage, &requests, Some([9; 32])) + .await + .unwrap(), + VoucherLocationUpdate { + ring_positions: vec![( + 11, + Some(VoucherRingPosition::Included { + ring_index: 7, + included_at: 4, + }), + )], + ring_statuses: vec![( + 11, + VoucherRingStatus { + included_members: 5, + }, + )], + } + ); + } + + #[tokio::test] + async fn voucher_location_reads_current_best_then_follows_live_best_changes() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some( + RingPosition::Onboarding { + queue_page: 0, + queued_at: 10, + } + .encode(), + )]); + storage.push_response(vec![Some( + RingPosition::Included { + ring_index: 4, + ring_page: 0, + ring_position: 2, + } + .encode(), + )]); + storage.push_heads([0xA5]); + let vouchers = Arc::new(crate::InMemoryVoucherRepository::with_vouchers([ + location_voucher(7, 3), + ])); + let subscriber = QueryVoucherLocationSubscriber::new( + Arc::clone(&storage) as Arc, + vouchers, + Arc::new(|_| Ok([7; 32])), + crate::test_spawner(), + ); + + let mut updates = subscriber.subscribe(vec![7], vec![], vec![]).await.unwrap(); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![(7, Some(VoucherRingPosition::Onboarding))] + ); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![( + 7, + Some(VoucherRingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )] + ); + + let queries = storage.queries.lock(); + assert_eq!(queries.len(), 2); + assert_eq!(queries[0].1, None, "initial snapshot is current best"); + assert_eq!( + queries[1].1, + Some([0xA5; 32]), + "live best change pins the follow-up batch" + ); + } + + /// A `chainHead` follow can be rejected or terminate after the first + /// snapshot. The location observer must still re-read current best state, + /// otherwise a member position seen before `RingKeysStatus.included` + /// advances remains pending forever. + #[tokio::test] + async fn voucher_location_periodically_reconciles_after_head_stream_ends() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some( + RingPosition::Onboarding { + queue_page: 0, + queued_at: 10, + } + .encode(), + )]); + storage.push_response(vec![Some( + RingPosition::Included { + ring_index: 4, + ring_page: 0, + ring_position: 2, + } + .encode(), + )]); + storage.push_heads([]); + let vouchers = Arc::new(crate::InMemoryVoucherRepository::with_vouchers([ + location_voucher(7, 3), + ])); + let subscriber = QueryVoucherLocationSubscriber::new( + Arc::clone(&storage) as Arc, + vouchers, + Arc::new(|_| Ok([7; 32])), + crate::test_spawner(), + ) + .with_observation_refresh_interval(Duration::from_millis(10)); + + let mut updates = subscriber.subscribe(vec![7], vec![], vec![]).await.unwrap(); + assert_eq!( + updates.recv().await.unwrap().ring_positions, + vec![(7, Some(VoucherRingPosition::Onboarding))] + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), updates.recv()) + .await + .expect("periodic current-best reconciliation timed out") + .unwrap() + .ring_positions, + vec![( + 7, + Some(VoucherRingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )] + ); + + let queries = storage.queries.lock(); + assert_eq!(queries.len(), 2); + assert_eq!(queries[0].1, None); + assert_eq!(queries[1].1, None, "fallback re-reads current best"); + } + + #[tokio::test] + async fn coin_batch_fetch_preserves_order_and_strictly_decodes() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![Some(raw_coin(4, 12)), None, Some(raw_coin(-2, 1))]); + let service = CoinOnChainQueryService::new(storage.clone()); + let keys = [[1; 32], [2; 32], [3; 32]]; + assert_eq!( + service.fetch_coins(&keys, Some([9; 32])).await.unwrap(), + vec![ + Some(OnChainCoin { + exponent: 4, + age: 12 + }), + None, + Some(OnChainCoin { + exponent: -2, + age: 1 + }) + ] + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1, "one batch RPC"); + assert_eq!(calls[0].0.len(), 3); + assert_eq!(calls[0].1, Some([9; 32])); + } + + #[tokio::test] + async fn sent_or_claimed_accumulates_seen_after_an_earlier_coin_is_claimed() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([1, 2, 3]); + storage.push_response(vec![Some(raw_coin(1, 0)), None]); + storage.push_response(vec![None, Some(raw_coin(1, 0))]); + let service = CoinOnChainQueryService::new(storage); + let keys = [[1; 32], [2; 32]]; + assert!( + service + .await_all_coins_sent_or_claimed(&keys, 3) + .await + .unwrap(), + "the first key stays confirmed after it is spent; the second remains present" + ); + } + + #[tokio::test] + async fn sent_or_claimed_races_the_finalized_head_timeout() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([1, 2]); + storage.push_response(vec![None]); + storage.push_response(vec![None]); + let service = CoinOnChainQueryService::new(storage); + assert_eq!( + service + .await_all_coins_sent_or_claimed(&[[1; 32]], 2) + .await + .unwrap_err(), + CoinageQueryError::Timeout { finalized_heads: 2 } + ); + } + + #[tokio::test] + async fn historical_probe_confirms_a_fast_claim_without_a_live_wait() { + let storage = Arc::new(ScriptedStorage::default()); + // Current: both absent. Anchor: both were present. + storage.push_response(vec![None, None]); + storage.push_response(vec![Some(raw_coin(1, 0)), Some(raw_coin(2, 0))]); + let service = CoinOnChainQueryService::new(storage.clone()); + let result = service + .await_send_or_claimed(&[[1; 32], [2; 32]], Some([7; 32]), 10) + .await + .unwrap(); + assert_eq!(result, SendConfirmation::AlreadyClaimed); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].1, None); + assert_eq!(calls[1].1, Some([7; 32])); + } + + #[tokio::test] + async fn voucher_query_is_four_stages_and_preserves_nil_slots() { + let storage = Arc::new(ScriptedStorage::default()); + // Exponents: index 11 is not a recycler member. + storage.push_response(vec![Some(3i8.encode()), None, Some(5i8.encode())]); + // Positions for indices 10 and 12: index 12 is onboarding. + storage.push_response(vec![ + Some( + RingPosition::Included { + ring_index: 7, + ring_page: 0, + ring_position: 4, + } + .encode(), + ), + Some( + RingPosition::Onboarding { + queue_page: 1, + queued_at: 2, + } + .encode(), + ), + ]); + // An explicit `AliasState::Unloaded` row means unloaded. + storage.push_response(vec![Some(AliasState::Unloaded.encode())]); + + let public_key_provider: Arc = + Arc::new(|index| Ok([u8::try_from(index).unwrap(); 32])); + let alias_provider: Arc = + Arc::new(|index| Ok([u8::try_from(index + 1).unwrap(); 32])); + let service = + VoucherOnChainQueryService::new(storage.clone(), public_key_provider, alias_provider); + let result = service + .fetch_vouchers(&[10, 11, 12], Some([4; 32])) + .await + .unwrap(); + assert_eq!( + result, + vec![ + Some(VoucherOnChainInfo { + exponent: 3, + ring_position: RingPosition::Included { + ring_index: 7, + ring_page: 0, + ring_position: 4, + }, + is_unloaded: true, + }), + None, + None, + ] + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 3, "derivation plus three serial RPC batches"); + assert_eq!( + calls.iter().map(|call| call.0.len()).collect::>(), + [3, 2, 1] + ); + assert!(calls.iter().all(|call| call.1 == Some([4; 32]))); + } + + #[tokio::test] + async fn voucher_query_short_circuits_when_no_recycler_rows_exist() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_response(vec![None, None]); + let service = VoucherOnChainQueryService::new( + storage.clone(), + Arc::new(|index| Ok([index as u8; 32])), + Arc::new(|index| Ok([(index + 1) as u8; 32])), + ); + assert_eq!( + service.fetch_vouchers(&[1, 2], None).await.unwrap(), + vec![None, None] + ); + assert_eq!( + storage.queries.lock().len(), + 1, + "an empty first stage must not issue empty RPC batches" + ); + } + + #[tokio::test] + async fn recycler_revision_fetch_omits_missing_roots() { + let storage = Arc::new(ScriptedStorage::default()); + let root = RingRoot { + root: [1; 288], + revision: 42, + intermediate: [2; 848], + }; + storage.push_response(vec![Some(root.encode()), None]); + let loader = RecyclerReadinessLoader::new(storage.clone()); + let first = RecyclerKey { + exponent: 3, + index: 7, + }; + let second = RecyclerKey { + exponent: 5, + index: 9, + }; + let revisions = loader + .fetch_revisions(&[first, second], Some([8; 32])) + .await + .unwrap(); + assert_eq!(revisions, HashMap::from([(first, 42)])); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0.len(), 2); + assert_eq!(calls[0].1, Some([8; 32])); + } + + #[tokio::test] + async fn recycler_readiness_uses_one_finalized_head_for_the_batch() { + let storage = Arc::new(ScriptedStorage::default()); + storage.push_heads([0xA5]); + storage.push_response(vec![ + Some( + RingRoot { + root: [1; 288], + revision: 17, + intermediate: [2; 848], + } + .encode(), + ), + Some( + RingRoot { + root: [3; 288], + revision: 23, + intermediate: [4; 848], + } + .encode(), + ), + ]); + let loader = RecyclerReadinessLoader::new(storage.clone()); + let first = RecyclerKey { + exponent: 2, + index: 4, + }; + let second = RecyclerKey { + exponent: 7, + index: 9, + }; + + let snapshot = loader + .fetch_finalized_revisions(&[first, second]) + .await + .unwrap(); + + assert_eq!(snapshot.block_hash, [0xA5; 32]); + assert_eq!( + snapshot.revisions, + HashMap::from([(first, 17), (second, 23)]) + ); + let calls = storage.queries.lock(); + assert_eq!(calls.len(), 1, "one Members.Root batch"); + assert_eq!(calls[0].0.len(), 2); + assert_eq!(calls[0].1, Some([0xA5; 32])); + } +} diff --git a/rust/crates/truapi-coinage/src/recipient.rs b/rust/crates/truapi-coinage/src/recipient.rs new file mode 100644 index 000000000..32ca9feaa --- /dev/null +++ b/rust/crates/truapi-coinage/src/recipient.rs @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use {parking_lot::Mutex, std::sync::Arc}; + +use futures::future::AbortHandle; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::claim::{ + ClaimError, ClaimOrchestrator, ClaimStatus, ClaimStatusStore, IncomingClaim, SendConfirmation, + TransferSendVerifying, +}; +use crate::claim_plan::{ClaimPlan, ClaimPlanStatus, ClaimPlanStore}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::tasks::ActiveTaskRegistry; + +/// One `coinageSend` chat message, already decoded by the chat layer: +/// `memo_key = TransferMemo::identifier` and the memo's declared total. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoinageSendMessage { + pub message_id: String, + pub memo_key: [u8; 32], + pub total_value: u128, +} + +pub struct TransferRecipientService { + spawner: crate::Spawner, + orchestrator: Arc, + plans: Arc, + verifier: Arc, + statuses: Arc, + registry: Arc, + loops: Mutex>, +} + +impl TransferRecipientService { + /// `statuses` must be the same store injected into `orchestrator`, so + /// incoming and outgoing pushes land on one per-message channel. + pub fn new( + orchestrator: Arc, + plans: Arc, + verifier: Arc, + statuses: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + orchestrator, + plans, + verifier, + statuses, + registry: Arc::new(ActiveTaskRegistry::new(Arc::clone(&spawner))), + spawner, + loops: Mutex::new(Vec::new()), + } + } + + pub fn registry(&self) -> Arc { + Arc::clone(&self.registry) + } + + pub async fn start( + self: &Arc, + incoming: mpsc::Receiver, + outgoing: mpsc::Receiver, + ) -> Result<(), String> { + self.orchestrator.restore_persisted_statuses().await?; + let mut loops = self.loops.lock(); + loops.push(self.spawn_loop(incoming, Direction::Incoming)); + loops.push(self.spawn_loop(outgoing, Direction::Outgoing)); + Ok(()) + } + + pub fn throttle(&self) { + for handle in self.loops.lock().drain(..) { + handle.abort(); + } + self.registry.cancel_all(); + } + + fn spawn_loop( + self: &Arc, + mut messages: mpsc::Receiver, + direction: Direction, + ) -> AbortHandle { + let service = Arc::clone(self); + crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(message) = messages.recv().await { + service.dispatch(message, direction); + } + }) + } + + fn dispatch(self: &Arc, message: CoinageSendMessage, direction: Direction) { + if matches!( + self.statuses.status(&message.message_id), + Some(ClaimStatus::Finished { .. }) + ) { + return; + } + let service = Arc::clone(self); + let id = message.message_id.clone(); + self.registry.try_start(&id, async move { + match direction { + Direction::Incoming => service.run_incoming(message).await, + Direction::Outgoing => service.run_outgoing(message).await, + } + }); + } + + async fn run_incoming(&self, message: CoinageSendMessage) { + let outcome = self + .orchestrator + .claim_incoming(IncomingClaim { + memo_key: message.memo_key, + message_id: message.message_id.clone(), + total_value: message.total_value, + }) + .await; + match outcome { + Ok(claimed) => info!( + message_id = message.message_id, + claimed, "incoming coinage send claimed" + ), + Err(ClaimError::AlreadyClaiming) => { + info!( + message_id = message.message_id, + "claim already in flight for this memo" + ); + } + Err(ClaimError::Failed(error)) => { + warn!( + error, + message_id = message.message_id, + "incoming claim failed" + ); + } + } + } + + async fn run_outgoing(&self, message: CoinageSendMessage) { + if let Err(error) = self.verify_outgoing(&message).await { + warn!( + error, + message_id = message.message_id, + "outgoing send verification failed" + ); + if let Err(stamp_error) = self + .plans + .update_status(&message.memo_key, ClaimPlanStatus::Error, None) + .await + { + warn!(stamp_error, "outgoing error status stamp failed"); + } + self.statuses + .update_status(&message.message_id, ClaimStatus::Error); + } + } + + async fn verify_outgoing(&self, message: &CoinageSendMessage) -> Result<(), String> { + let existing = self.plans.plan(&message.memo_key).await?; + match &existing { + // Finished on a prior run: report only. + Some(plan) if plan.status == ClaimPlanStatus::Finished => { + self.statuses.update_status( + &message.message_id, + ClaimStatus::Finished { + claimed_amount: plan.claimed_amount.unwrap_or(plan.total_value), + }, + ); + return Ok(()); + } + Some(plan) if plan.status == ClaimPlanStatus::Detected => { + self.statuses + .update_status(&message.message_id, ClaimStatus::Sent); + self.verifier + .await_claim_on_chain(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await?; + } + other => { + if other.is_none() { + self.plans + .save(&ClaimPlan { + memo_key: message.memo_key, + message_id: Some(message.message_id.clone()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: message.total_value, + markers: crate::claim_plan::ClaimMarkers::default(), + }) + .await?; + } + self.statuses + .update_status(&message.message_id, ClaimStatus::Detecting); + match self + .verifier + .await_send_or_claimed(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await? + { + SendConfirmation::OnChain => { + self.plans + .update_status(&message.memo_key, ClaimPlanStatus::Detected, None) + .await?; + self.statuses + .update_status(&message.message_id, ClaimStatus::Sent); + self.verifier + .await_claim_on_chain(&message.memo_key, SEND_VERIFY_BLOCK_TIMEOUT) + .await?; + } + // Consumed before the watch saw them — the recipient + // already claimed; terminal. + SendConfirmation::AlreadyClaimed => {} + } + } + } + self.plans + .update_status( + &message.memo_key, + ClaimPlanStatus::Finished, + Some(message.total_value), + ) + .await?; + self.statuses.update_status( + &message.message_id, + ClaimStatus::Finished { + claimed_amount: message.total_value, + }, + ); + Ok(()) + } +} + +#[derive(Clone, Copy)] +enum Direction { + Incoming, + Outgoing, +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + + use super::*; + use crate::claim::ClaimExecutor; + use crate::claim_plan::CodableClaimPlanEntry; + use crate::denomination::DenominationBreakdownContext; + + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + #[derive(Default)] + struct MemPlans { + plans: Mutex>, + } + + #[async_trait] + impl ClaimPlanStore for MemPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.plans.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.plans.lock().get(memo_key).cloned()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.plans.lock().values().cloned().collect()) + } + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.plans.lock(); + let plan = plans.get_mut(memo_key).ok_or("plan not found")?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.plans.lock().remove(memo_key); + Ok(()) + } + } + + struct ScriptedVerifier { + plans: Arc, + send_or_claimed: Result, + claim_outcome: Result<(), String>, + send_or_claimed_calls: AtomicUsize, + claim_calls: AtomicUsize, + } + + impl ScriptedVerifier { + fn new( + plans: Arc, + send_or_claimed: Result, + claim_outcome: Result<(), String>, + ) -> Arc { + Arc::new(Self { + plans, + send_or_claimed, + claim_outcome, + send_or_claimed_calls: AtomicUsize::new(0), + claim_calls: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl TransferSendVerifying for ScriptedVerifier { + async fn await_send_on_chain( + &self, + _memo_key: &[u8; 32], + _block_timeout: u32, + ) -> Result<(), String> { + Ok(()) + } + async fn await_claim_on_chain( + &self, + _memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result<(), String> { + assert_eq!(block_timeout, 100, "COINM-024"); + self.claim_calls.fetch_add(1, Ordering::SeqCst); + self.claim_outcome.clone() + } + async fn await_send_or_claimed( + &self, + memo_key: &[u8; 32], + block_timeout: u32, + ) -> Result { + assert_eq!(block_timeout, 100, "COINM-024"); + self.send_or_claimed_calls.fetch_add(1, Ordering::SeqCst); + assert!( + self.plans.plan(memo_key).await.unwrap().is_some(), + "the .processing placeholder must exist before the send race (COINA-016)" + ); + self.send_or_claimed.clone() + } + } + + struct NoopExecutor; + + #[async_trait] + impl ClaimExecutor for NoopExecutor { + async fn claim( + &self, + _memo_key: &[u8; 32], + _message_id: &str, + ) -> Result, String> { + Ok(vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 0, + derivation_index: 1, + }]) + } + } + + fn service( + plans: Arc, + verifier: Arc, + ) -> (Arc, Arc) { + let statuses = Arc::new(ClaimStatusStore::default()); + let orchestrator = Arc::new(ClaimOrchestrator::new( + Arc::clone(&plans) as Arc, + Arc::clone(&verifier) as Arc, + Arc::new(NoopExecutor), + Arc::clone(&statuses), + ctx(), + )); + ( + Arc::new(TransferRecipientService::new( + orchestrator, + plans, + verifier, + Arc::clone(&statuses), + crate::test_spawner(), + )), + statuses, + ) + } + + fn message() -> CoinageSendMessage { + CoinageSendMessage { + message_id: "m1".into(), + memo_key: [7; 32], + total_value: 990, + } + } + + fn detected_plan() -> ClaimPlan { + ClaimPlan { + memo_key: [7; 32], + message_id: Some("m1".into()), + entries: Vec::new(), + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Detected, + claimed_amount: None, + total_value: 990, + markers: crate::claim_plan::ClaimMarkers::default(), + } + } + + async fn settle(statuses: &ClaimStatusStore, id: &str) -> ClaimStatus { + for _ in 0..1_000 { + if let Some(status) = statuses.status(id) + && status.is_terminal() + { + return status; + } + tokio::task::yield_now().await; + } + panic!("status never became terminal"); + } + + #[tokio::test] + async fn detected_plan_jumps_to_await_claim() { + let plans = Arc::new(MemPlans::default()); + plans.save(&detected_plan()).await.unwrap(); + let verifier = + ScriptedVerifier::new(Arc::clone(&plans), Ok(SendConfirmation::OnChain), Ok(())); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.send_or_claimed_calls.load(Ordering::SeqCst), 0); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 1); + let plan = plans.plan(&[7; 32]).await.unwrap().unwrap(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, Some(990)); + } + + #[tokio::test] + async fn fresh_outgoing_send_walks_the_full_path() { + let plans = Arc::new(MemPlans::default()); + let verifier = + ScriptedVerifier::new(Arc::clone(&plans), Ok(SendConfirmation::OnChain), Ok(())); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.send_or_claimed_calls.load(Ordering::SeqCst), 1); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 1); + } + + /// `AlreadyClaimed`: the coins were consumed before the watch — the + /// claim await is skipped entirely and the send finishes. + #[tokio::test] + async fn already_claimed_short_circuits_the_claim_await() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + assert_eq!(verifier.claim_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn timeout_is_a_terminal_error() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Err("timeout after 100 blocks".into()), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + service.run_outgoing(message()).await; + assert_eq!(settle(&statuses, "m1").await, ClaimStatus::Error); + assert_eq!( + plans.plan(&[7; 32]).await.unwrap().unwrap().status, + ClaimPlanStatus::Error + ); + } + + #[tokio::test] + async fn loops_dedup_and_skip_finished_messages() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + + let (incoming_tx, incoming_rx) = mpsc::channel(8); + let (outgoing_tx, outgoing_rx) = mpsc::channel(8); + service.start(incoming_rx, outgoing_rx).await.unwrap(); + + outgoing_tx.send(message()).await.unwrap(); + outgoing_tx.send(message()).await.unwrap(); + assert_eq!( + settle(&statuses, "m1").await, + ClaimStatus::Finished { + claimed_amount: 990 + } + ); + // Re-emission after finish: skipped by the terminal guard. + outgoing_tx.send(message()).await.unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!( + verifier.send_or_claimed_calls.load(Ordering::SeqCst), + 1, + "one verification for three emissions" + ); + + // Incoming path delegates to the orchestrator. + incoming_tx + .send(CoinageSendMessage { + message_id: "m2".into(), + memo_key: [8; 32], + total_value: 10, + }) + .await + .unwrap(); + assert_eq!( + settle(&statuses, "m2").await, + ClaimStatus::Finished { claimed_amount: 10 } + ); + service.throttle(); + } + + /// `throttle` stops the loops: messages sent afterwards are never + /// processed. + #[tokio::test] + async fn throttle_stops_the_subscription_loops() { + let plans = Arc::new(MemPlans::default()); + let verifier = ScriptedVerifier::new( + Arc::clone(&plans), + Ok(SendConfirmation::AlreadyClaimed), + Ok(()), + ); + let (service, statuses) = service(Arc::clone(&plans), Arc::clone(&verifier)); + let (_incoming_tx, incoming_rx) = mpsc::channel::(8); + let (outgoing_tx, outgoing_rx) = mpsc::channel(8); + service.start(incoming_rx, outgoing_rx).await.unwrap(); + service.throttle(); + + outgoing_tx.send(message()).await.unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!(statuses.status("m1"), None, "loop is dead after throttle"); + } +} diff --git a/rust/crates/truapi-coinage/src/recovery.rs b/rust/crates/truapi-coinage/src/recovery.rs new file mode 100644 index 000000000..c7f277335 --- /dev/null +++ b/rust/crates/truapi-coinage/src/recovery.rs @@ -0,0 +1,1296 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::warn; + +use crate::model::{ + Coin, CoinState, Voucher, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState, +}; +use crate::repo::{CoinRepository, VoucherRepository}; +use crate::wal::{TransferWalEntry, WalOperation, WalStore}; + +/// The chain-lookup effect. Presence answers are positional (same order +/// as the input indices); implementations typically batch these through +/// `chain::get_head_storage`. +#[async_trait] +pub trait RecoveryChainProbe: Send + Sync { + /// Current finalized head number. + async fn finalized_block(&self) -> Result; + + /// Canonical block hash at a height; `None` when unavailable. + async fn canonical_hash(&self, block_number: u64) -> Result, String>; + + /// Whether each coin derivation index currently has an on-chain + /// `CoinsByOwner` entry. + async fn coins_present(&self, derivation_indices: &[u32]) -> Result, String>; + + /// Exact denomination at the finalized snapshot of this recovery sweep. + /// Responses are positional and must cover every requested index. `None` + /// proves absence; query errors or truncated batches are not absence. + async fn coin_exponents(&self, derivation_indices: &[u32]) -> Result>, String>; + + /// Whether each voucher derivation index is currently present + /// on-chain (in a recycler / member set). + async fn vouchers_present(&self, derivation_indices: &[u32]) -> Result, String>; +} + +/// One sweep's outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RecoveryReport { + /// Entries whose extrinsic landed (outputs materialized). + pub landed: usize, + /// Entries resolved by input consumption without visible outputs. + pub inputs_consumed: usize, + /// Entries dead (expired / forked / never broadcast) — inputs + /// reverted. + pub reverted: usize, + /// Entries left journaled for the next sweep. + pub still_pending: usize, + pub orphans_restored: usize, + pub orphans_deleted: usize, +} + +pub struct TransferRecoveryService { + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: Arc, +} + +impl TransferRecoveryService { + pub fn new( + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: Arc, + ) -> Self { + Self { + wal, + coins, + vouchers, + probe, + } + } + + /// One recovery pass: resolve every WAL entry, then sweep orphans. + /// Never guesses a terminal state for money in flight — an entry + /// stays pending until the chain proves it landed or died. + pub async fn recover(&self) -> Result { + let entries = self.wal.load_all().await?; + let finalized = self.probe.finalized_block().await?; + let mut report = RecoveryReport::default(); + let parents = entries + .iter() + .filter(|entry| entry.operation.is_transfer_receipt()) + .map(|entry| (entry.entry_id.as_str(), entry.operation)) + .collect::>(); + + for entry in &entries { + let parent = entry.operation_parent_id(); + let parent_state = parent.as_deref().and_then(|id| parents.get(id)).copied(); + match self.resolve_entry(entry, finalized, parent_state).await { + Ok(Resolution::Landed) => report.landed += 1, + Ok(Resolution::InputsConsumed) => report.inputs_consumed += 1, + Ok(Resolution::Reverted) => report.reverted += 1, + Ok(Resolution::StillPending) => report.still_pending += 1, + Ok(Resolution::Receipt) => {} + Err(error) => { + warn!( + error, + entry_id = entry.entry_id, + "wal recovery probe failed" + ); + report.still_pending += 1; + } + } + } + + let remaining = self.wal.load_all().await?; + for parent in remaining + .iter() + .filter(|entry| entry.operation == WalOperation::TransferAccepted) + { + if !remaining.iter().any(|entry| { + entry.entry_id != parent.entry_id + && entry.operation_parent_id().as_deref() == Some(parent.entry_id.as_str()) + }) { + let mut completed = parent.clone(); + completed.operation = WalOperation::TransferCompleted; + self.wal.save(&completed).await?; + } + } + let (protected_coins, protected_vouchers) = referenced_indices(&remaining); + + for coin in self.coins.list().await? { + let orphaned = matches!( + coin.state, + CoinState::PendingTransfer | CoinState::Recycling + ) && !protected_coins.contains(&coin.derivation_index); + if orphaned { + self.coins + .set_state(coin.derivation_index, CoinState::Available) + .await?; + report.orphans_restored += 1; + } + } + for voucher in self.vouchers.list().await? { + if protected_vouchers.contains(&voucher.derivation_index) { + continue; + } + match voucher.local_state { + VoucherLocalState::PendingTransfer => { + self.vouchers + .set_local_state(voucher.derivation_index, VoucherLocalState::Available) + .await?; + report.orphans_restored += 1; + } + VoucherLocalState::PendingOnboarding => { + self.vouchers.remove(voucher.derivation_index).await?; + report.orphans_deleted += 1; + } + _ => {} + } + } + Ok(report) + } + + async fn resolve_entry( + &self, + entry: &TransferWalEntry, + finalized: u64, + parent_state: Option, + ) -> Result { + if entry.operation.is_transfer_receipt() { + // Receipts preserve idempotency after children disappear. Prepared + // is deliberately not interpreted as accepted or rejected. + return Ok(Resolution::Receipt); + } + let correlated = entry.operation_parent_id().is_some(); + if correlated { + match parent_state { + Some(WalOperation::TransferRejected) + if matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) => + { + self.revert_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Some(WalOperation::TransferAccepted) => {} + // Missing/ambiguous parent evidence must never unlock inputs. + _ => return Ok(Resolution::StillPending), + } + } + let dead = match entry.checkpoint { + crate::wal::CheckpointBlock::Pending => true, + crate::wal::CheckpointBlock::Known { number, .. } => { + let canonical = self.probe.canonical_hash(number).await?; + entry.is_forked(canonical.as_ref()) || entry.is_expired(finalized) + } + }; + + match entry.operation { + // A handed-off expanded secret never expires. The recipient + // may claim it long after an extrinsic mortality window, so + // presence always remains reserved and only all-input absence + // is terminal. + WalOperation::SecretHandoff => { + if correlated || self.inputs_consumed(entry).await? { + self.retire_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + Ok(Resolution::StillPending) + } + WalOperation::IntoCoins | WalOperation::Split => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) { + let output_indices: Vec = entry + .payload + .output_coins + .iter() + .map(|c| c.derivation_index) + .collect(); + let outputs = if correlated { + let exponents = self.probe.coin_exponents(&output_indices).await?; + if exponents.len() != output_indices.len() { + return Err("coin recovery probe returned an incomplete batch".into()); + } + if exponents.iter().zip(&entry.payload.output_coins).any( + |(actual, expected)| { + actual.is_some_and(|exponent| exponent != expected.exponent) + }, + ) { + return Err( + "recovered output denomination differs from the approved plan" + .into(), + ); + } + exponents + .into_iter() + .map(|exponent| exponent.is_some()) + .collect::>() + } else { + self.probe.coins_present(&output_indices).await? + }; + if outputs.len() != output_indices.len() { + return Err("coin recovery probe returned an incomplete batch".into()); + } + let landed = if correlated { + !outputs.is_empty() && outputs.iter().all(|present| *present) + } else { + outputs.iter().any(|present| *present) + }; + if correlated + && outputs.iter().any(|present| *present) + && (!landed || !self.inputs_consumed(entry).await?) + { + // Partial/mixed evidence cannot certify this allocation + // and must never authorize rebroadcast after mortality. + return Ok(Resolution::StillPending); + } + if landed { + // Landed: materialize outputs, retire inputs. + for (reference, present) in entry.payload.output_coins.iter().zip(&outputs) + { + if *present { + let destination = + entry.payload.destination_coins.iter().any(|coin| { + coin.derivation_index == reference.derivation_index + }); + self.coins + .upsert(&Coin { + exponent: reference.exponent, + derivation_index: reference.derivation_index, + age: None, + state: if destination { + CoinState::Spent + } else { + CoinState::Available + }, + }) + .await?; + } + } + self.retire_transfer_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Landed); + } + if self.inputs_consumed(entry).await? { + if correlated { + // Consumption alone does not prove this operation + // produced its promised outputs. Retain the receipt + // and checkpoint rather than reporting completion. + return Ok(Resolution::StillPending); + } + self.retire_transfer_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + } + if dead { + if correlated { + // Keep the SAME allocated outputs and input reservation. + // Only explicit host resume may recreate the extrinsic. + // Mixed input presence is ambiguous, not permission to + // spend the remaining inputs in a second transaction. + if matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + || self.inputs_present(entry).await? + { + self.wal + .update_checkpoint( + &entry.entry_id, + crate::wal::CheckpointBlock::Pending, + ) + .await?; + } + return Ok(Resolution::StillPending); + } + self.revert_inputs(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::IntoExternalAsset => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + && self.inputs_consumed(entry).await? + { + self.retire_inputs(entry).await?; + self.confirm_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::InputsConsumed); + } + if dead { + self.revert_inputs(entry).await?; + self.delete_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::RecycleIntoVoucher => { + if !matches!(entry.checkpoint, crate::wal::CheckpointBlock::Pending) + && self.inputs_consumed(entry).await? + { + self.retire_inputs(entry).await?; + self.confirm_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Landed); + } + if dead { + self.revert_inputs(entry).await?; + self.delete_surplus_vouchers(entry).await?; + self.wal.delete(&entry.entry_id).await?; + return Ok(Resolution::Reverted); + } + Ok(Resolution::StillPending) + } + WalOperation::TransferPrepared + | WalOperation::TransferAccepted + | WalOperation::TransferCompleted + | WalOperation::TransferRejected => Ok(Resolution::Receipt), + } + } + + async fn inputs_consumed(&self, entry: &TransferWalEntry) -> Result { + let coin_indices: Vec = entry + .payload + .input_coins + .iter() + .map(|c| c.derivation_index) + .collect(); + let voucher_indices: Vec = entry + .payload + .input_vouchers + .iter() + .map(|v| v.derivation_index) + .collect(); + if coin_indices.is_empty() && voucher_indices.is_empty() { + return Ok(false); + } + let coins = self.probe.coins_present(&coin_indices).await?; + let vouchers = self.probe.vouchers_present(&voucher_indices).await?; + if coins.len() != coin_indices.len() || vouchers.len() != voucher_indices.len() { + return Err("input recovery probe returned an incomplete batch".into()); + } + Ok(coins.iter().all(|present| !present) && vouchers.iter().all(|present| !present)) + } + + async fn inputs_present(&self, entry: &TransferWalEntry) -> Result { + let coin_indices = entry + .payload + .input_coins + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + let voucher_indices = entry + .payload + .input_vouchers + .iter() + .map(|voucher| voucher.derivation_index) + .collect::>(); + let coins = self.probe.coins_present(&coin_indices).await?; + let vouchers = self.probe.vouchers_present(&voucher_indices).await?; + if coins.len() != coin_indices.len() || vouchers.len() != voucher_indices.len() { + return Err("input recovery probe returned an incomplete batch".into()); + } + Ok(coins.iter().all(|present| *present) && vouchers.iter().all(|present| *present)) + } + + async fn retire_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Spent) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state(input.derivation_index, VoucherLocalState::Spent) + .await?; + } + Ok(()) + } + + /// Split/unload parity: consumed vouchers are removed from the local + /// purse rather than retained as spent tombstones. Destination outputs + /// remain spent rows so sync/monitoring can observe their lifecycle. + async fn retire_transfer_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Spent) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers.remove(input.derivation_index).await?; + } + Ok(()) + } + + async fn revert_inputs(&self, entry: &TransferWalEntry) -> Result<(), String> { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Available) + .await?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state(input.derivation_index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } + + async fn confirm_surplus_vouchers(&self, entry: &TransferWalEntry) -> Result<(), String> { + let indices: Vec = entry + .payload + .output_vouchers + .iter() + .map(|v| v.derivation_index) + .collect(); + if indices.is_empty() { + return Ok(()); + } + let present = self.probe.vouchers_present(&indices).await?; + let known: HashSet = self + .vouchers + .list() + .await? + .into_iter() + .map(|v| v.derivation_index) + .collect(); + for (reference, present) in entry.payload.output_vouchers.iter().zip(&present) { + if !*present { + continue; + } + if known.contains(&reference.derivation_index) { + self.vouchers + .set_local_state(reference.derivation_index, VoucherLocalState::Available) + .await?; + } else { + // Materialize a minimal record; the voucher location + // service reconciles remote state and readiness + self.vouchers + .upsert(&Voucher { + exponent: reference.exponent, + derivation_index: reference.derivation_index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Degraded, + }) + .await?; + } + } + Ok(()) + } + + async fn delete_surplus_vouchers(&self, entry: &TransferWalEntry) -> Result<(), String> { + for output in &entry.payload.output_vouchers { + self.vouchers.remove(output.derivation_index).await?; + } + Ok(()) + } +} + +enum Resolution { + Landed, + InputsConsumed, + Reverted, + StillPending, + Receipt, +} + +/// The coin/voucher indices any journaled entry still references. +fn referenced_indices(entries: &[TransferWalEntry]) -> (HashSet, HashSet) { + let mut coins = HashSet::new(); + let mut vouchers = HashSet::new(); + for entry in entries { + coins.extend(entry.payload.input_coins.iter().map(|c| c.derivation_index)); + vouchers.extend( + entry + .payload + .input_vouchers + .iter() + .map(|v| v.derivation_index), + ); + vouchers.extend( + entry + .payload + .output_vouchers + .iter() + .map(|v| v.derivation_index), + ); + } + (coins, vouchers) +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + + use super::*; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + use crate::wal::{CheckpointBlock, WalCoinRef, WalPayload}; + + /// In-memory WAL store for the sweep tests. + #[derive(Default)] + struct MemWal { + entries: Mutex>, + } + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.entries.lock(); + entries.retain(|existing| existing.entry_id != entry.entry_id); + entries.push(entry.clone()); + Ok(()) + } + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + let mut stored = self.entries.lock(); + stored.retain(|existing| { + !entries + .iter() + .any(|entry| entry.entry_id == existing.entry_id) + }); + stored.extend_from_slice(entries); + Ok(()) + } + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.entries.lock(); + let entry = entries + .iter_mut() + .find(|e| e.entry_id == entry_id) + .ok_or("wal entry not found")?; + entry.checkpoint = checkpoint; + Ok(()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.entries.lock().clone()) + } + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.entries.lock().retain(|e| e.entry_id != entry_id); + Ok(()) + } + } + + struct MockProbe { + finalized: u64, + canonical: Option<[u8; 32]>, + coins_on_chain: HashSet, + vouchers_on_chain: HashSet, + output_exponents: HashMap, + } + + impl Default for MockProbe { + fn default() -> Self { + Self { + finalized: 100, + canonical: Some([1; 32]), + coins_on_chain: HashSet::new(), + vouchers_on_chain: HashSet::new(), + output_exponents: HashMap::new(), + } + } + } + + #[async_trait] + impl RecoveryChainProbe for MockProbe { + async fn finalized_block(&self) -> Result { + Ok(self.finalized) + } + async fn canonical_hash(&self, _block_number: u64) -> Result, String> { + Ok(self.canonical) + } + async fn coins_present(&self, indices: &[u32]) -> Result, String> { + Ok(indices + .iter() + .map(|i| self.coins_on_chain.contains(i)) + .collect()) + } + async fn coin_exponents(&self, indices: &[u32]) -> Result>, String> { + Ok(indices + .iter() + .map(|index| self.output_exponents.get(index).copied()) + .collect()) + } + async fn vouchers_present(&self, indices: &[u32]) -> Result, String> { + Ok(indices + .iter() + .map(|i| self.vouchers_on_chain.contains(i)) + .collect()) + } + } + + fn coin(index: u32, state: CoinState) -> Coin { + Coin { + exponent: 1, + derivation_index: index, + age: Some(1), + state, + } + } + + fn service( + wal: Arc, + coins: Arc, + vouchers: Arc, + probe: MockProbe, + ) -> TransferRecoveryService { + TransferRecoveryService::new(wal, coins, vouchers, Arc::new(probe)) + } + + fn into_coins_entry(checkpoint: CheckpointBlock) -> TransferWalEntry { + TransferWalEntry { + entry_id: "e1".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + input_vouchers: vec![], + output_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 0, + }], + output_vouchers: vec![], + destination_coins: vec![], + }, + checkpoint, + created_at_ms: 0, + } + } + + async fn coin_state(repo: &InMemoryCoinRepository, index: u32) -> CoinState { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + .state + } + + /// A `Pending` checkpoint means the extrinsic never left the device: + /// the input coin reverts to `.available` and the entry is dropped. + #[tokio::test] + async fn crash_between_wal_write_and_broadcast_reverts_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Pending)) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + assert!(wal.load_all().await.unwrap().is_empty(), "entry retired"); + } + + #[tokio::test] + async fn into_coins_landed_materializes_outputs_and_retires_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let probe = MockProbe { + coins_on_chain: HashSet::from([50]), + ..MockProbe::default() + }; + let service = service(Arc::clone(&wal), Arc::clone(&coins), vouchers, probe); + + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + let listed = coins.list().await.unwrap(); + let output = listed.iter().find(|c| c.derivation_index == 50).unwrap(); + assert_eq!(output.state, CoinState::Available); + assert_eq!(output.age, None, "age unknown until first sync"); + assert!(wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn unload_recovery_removes_consumed_voucher_and_keeps_destination_spent() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "unload".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload { + input_coins: vec![], + input_vouchers: vec![WalCoinRef { + derivation_index: 10, + exponent: 2, + }], + output_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 2, + }], + output_vouchers: vec![], + destination_coins: vec![WalCoinRef { + derivation_index: 50, + exponent: 2, + }], + }, + checkpoint: CheckpointBlock::Known { + number: 90, + hash: [1; 32], + }, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::default()); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([Voucher { + exponent: 2, + derivation_index: 10, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 1 }, + local_state: VoucherLocalState::PendingTransfer, + privacy: VoucherPrivacyLevel::Full, + }])); + let probe = MockProbe { + coins_on_chain: HashSet::from([50]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert!(vouchers.list().await.unwrap().is_empty()); + assert_eq!(coin_state(&coins, 50).await, CoinState::Spent); + assert!(wal.load_all().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn forked_checkpoint_reverts_immediately() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [9; 32], // stored hash ≠ canonical [1; 32] + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + // Input still on-chain (not consumed), output absent, + // finalized 100 < 90 + 300 (not expired) — ONLY the fork + // can resolve this entry. + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn unresolved_entry_stays_pending() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + // Input still on-chain, output absent, not expired. + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.still_pending, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn expired_entry_reverts_inputs() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + finalized: 391, // > 90 + 300 + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn recycle_into_voucher_confirms_the_surplus_voucher() { + use crate::model::{VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "r1".into(), + operation: WalOperation::RecycleIntoVoucher, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + input_vouchers: vec![], + output_coins: vec![], + output_vouchers: vec![WalCoinRef { + derivation_index: 70, + exponent: 1, + }], + destination_coins: vec![], + }, + checkpoint: CheckpointBlock::Known { + number: 90, + hash: [1; 32], + }, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::Recycling, + )])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([Voucher { + exponent: 1, + derivation_index: 70, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::PendingOnboarding, + privacy: VoucherPrivacyLevel::Full, + }])); + let probe = MockProbe { + vouchers_on_chain: HashSet::from([70]), + // Input coin 1 absent → consumed. + ..MockProbe::default() + }; + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::clone(&vouchers), + probe, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.landed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + } + + #[tokio::test] + async fn orphaned_pending_assets_are_restored_or_deleted() { + use crate::model::{VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + let coins = Arc::new(InMemoryCoinRepository::with_coins([ + coin(1, CoinState::PendingTransfer), + coin(2, CoinState::Recycling), + coin(3, CoinState::Spent), + ])); + let orphan_voucher = |index, local_state| Voucher { + exponent: 0, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Unlocated, + local_state, + privacy: VoucherPrivacyLevel::Full, + }; + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([ + orphan_voucher(10, VoucherLocalState::PendingTransfer), + orphan_voucher(11, VoucherLocalState::PendingOnboarding), + ])); + let service = service( + Arc::new(MemWal::default()), + Arc::clone(&coins), + Arc::clone(&vouchers), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.orphans_restored, 3, "two coins + one voucher"); + assert_eq!(report.orphans_deleted, 1, "pending-onboarding voucher"); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + assert_eq!(coin_state(&coins, 2).await, CoinState::Available); + assert_eq!( + coin_state(&coins, 3).await, + CoinState::Spent, + "spent untouched" + ); + let listed = vouchers.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].derivation_index, 10); + assert_eq!(listed[0].local_state, VoucherLocalState::Available); + } + + /// A journaled still-pending entry PROTECTS its assets from the + /// orphan sweep. + #[tokio::test] + async fn journaled_assets_are_not_swept_as_orphans() { + let wal = Arc::new(MemWal::default()); + wal.save(&into_coins_entry(CheckpointBlock::Known { + number: 90, + hash: [1; 32], + })) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let probe = MockProbe { + coins_on_chain: HashSet::from([1]), // unresolved + ..MockProbe::default() + }; + let service = service( + wal, + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + let report = service.recover().await.unwrap(); + assert_eq!(report.orphans_restored, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + /// Expanded secrets have no mortality. Even a very old pending + /// checkpoint remains protected while its coin is present. + #[tokio::test] + async fn secret_handoff_never_mortality_reverts_a_present_coin() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "secret-1".into(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + MockProbe { + finalized: u64::MAX, + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }, + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.still_pending, 1); + assert_eq!(report.reverted, 0); + assert_eq!(report.orphans_restored, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(wal.load_all().await.unwrap().len(), 1); + } + + /// Chain absence is the sole terminal proof for an out-of-band + /// secret handoff. + #[tokio::test] + async fn secret_handoff_absence_retires_inputs_and_journal() { + let wal = Arc::new(MemWal::default()); + wal.save(&TransferWalEntry { + entry_id: "secret-2".into(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 1, + }], + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }) + .await + .unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let service = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + MockProbe::default(), + ); + + let report = service.recover().await.unwrap(); + assert_eq!(report.inputs_consumed, 1); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert!(wal.load_all().await.unwrap().is_empty()); + } + async fn operation_fixture( + state: WalOperation, + checkpoint: CheckpointBlock, + probe: MockProbe, + ) -> ( + TransferRecoveryService, + Arc, + Arc, + ) { + let mut child = into_coins_entry(checkpoint); + child.entry_id = crate::wal::operation_entry_id("host-payment", "split"); + child.operation = WalOperation::Split; + child.payload.output_coins.push(WalCoinRef { + derivation_index: 51, + exponent: 0, + }); + child.payload.destination_coins = child.payload.output_coins.clone(); + let parent = TransferWalEntry { + entry_id: crate::wal::operation_entry_id("host-payment", "parent"), + operation: state, + payload: WalPayload { + output_coins: child.payload.destination_coins.clone(), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: 0, + }; + let wal = Arc::new(MemWal::default()); + wal.save_all(&[parent, child]).await.unwrap(); + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin( + 1, + CoinState::PendingTransfer, + )])); + let recovery = service( + Arc::clone(&wal), + Arc::clone(&coins), + Arc::new(InMemoryVoucherRepository::default()), + probe, + ); + (recovery, wal, coins) + } + + #[tokio::test] + async fn prepared_transport_ambiguity_never_releases_a_pending_chain_input() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferPrepared, + CheckpointBlock::Pending, + MockProbe { + finalized: 10_000, + ..MockProbe::default() + }, + ) + .await; + let report = service.recover().await.unwrap(); + assert_eq!(report.reverted, 0); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferPrepared) + ); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::Split) + ); + } + + #[tokio::test] + async fn expired_accepted_plan_is_resumeable_without_unlocking_or_reallocating() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }, + MockProbe { + finalized: 1_000, + coins_on_chain: HashSet::from([1]), + ..MockProbe::default() + }, + ) + .await; + let original = wal + .load_operation("host-payment") + .await + .unwrap() + .into_iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + service.recover().await.unwrap(); + service.recover().await.unwrap(); + let restored = wal + .load_operation("host-payment") + .await + .unwrap() + .into_iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + assert_eq!(restored.entry_id, original.entry_id); + assert_eq!(restored.payload, original.payload); + assert_eq!(restored.checkpoint, CheckpointBlock::Pending); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + #[tokio::test] + async fn consumed_inputs_without_output_evidence_cannot_complete_an_operation() { + let checkpoint = CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }; + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + checkpoint, + MockProbe { + finalized: 1_000, + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + let child = rows + .iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap(); + assert_eq!(child.checkpoint, checkpoint); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + + #[tokio::test] + async fn landed_operation_keeps_its_receipt_after_repeated_recovery() { + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }, + MockProbe { + coins_on_chain: HashSet::from([50, 51]), + output_exponents: HashMap::from([(50, 0), (51, 0)]), + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + service.recover().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!(coin_state(&coins, 50).await, CoinState::Spent); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].operation, WalOperation::TransferCompleted); + assert_eq!(rows[0].payload.output_coins[0].derivation_index, 50); + } + + #[tokio::test] + async fn partial_or_wrong_output_evidence_cannot_complete_or_rebroadcast() { + for outputs in [HashMap::from([(50, 0)]), HashMap::from([(50, 0), (51, 4)])] { + let checkpoint = CheckpointBlock::Known { + number: 1, + hash: [1; 32], + }; + let (service, wal, coins) = operation_fixture( + WalOperation::TransferAccepted, + checkpoint, + MockProbe { + finalized: 1_000, + coins_on_chain: HashSet::from([1, 50, 51]), + output_exponents: outputs, + ..MockProbe::default() + }, + ) + .await; + service.recover().await.unwrap(); + let rows = wal.load_operation("host-payment").await.unwrap(); + assert!( + rows.iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + assert_eq!( + rows.iter() + .find(|entry| entry.operation == WalOperation::Split) + .unwrap() + .checkpoint, + checkpoint + ); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + } + } +} diff --git a/rust/crates/truapi-coinage/src/repo.rs b/rust/crates/truapi-coinage/src/repo.rs new file mode 100644 index 000000000..4f992363c --- /dev/null +++ b/rust/crates/truapi-coinage/src/repo.rs @@ -0,0 +1,571 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Coin/voucher repository contracts and the transfer reservation +//! +//! The repositories are the local-DB effect boundary: durable implementations +//! belong to the Host; in-memory implementations here serve tests and the +//! executable contract. [`TransferContext`] is the reserve → process / +//! revert lifecycle wrapped around one transfer: reserve marks inputs +//! `PendingTransfer` before any extrinsic, process retires spent inputs, +//! revert restores whatever is still pending after a failure. + +use parking_lot::Mutex; +use std::collections::HashMap; + +use async_trait::async_trait; + +use crate::model::{Coin, CoinState, Voucher, VoucherLocalState, VoucherRemoteState}; + +/// Local coin persistence effect. +#[async_trait] +pub trait CoinRepository: Send + Sync { + async fn list(&self) -> Result, String>; + + /// Insert or replace by `derivation_index`. + async fn upsert(&self, coin: &Coin) -> Result<(), String>; + + /// Moves one coin's state; unknown index is an error (a recovery + /// sweep must never silently miss). + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String>; + + async fn remove(&self, derivation_index: u32) -> Result<(), String>; +} + +/// Local voucher persistence effect. +#[async_trait] +pub trait VoucherRepository: Send + Sync { + async fn list(&self) -> Result, String>; + + async fn upsert(&self, voucher: &Voucher) -> Result<(), String>; + + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String>; + + /// Updates only the chain-location projection. Unknown indices are + /// errors so a subscription cannot silently lose a mapper request. + async fn set_remote_state( + &self, + derivation_index: u32, + state: VoucherRemoteState, + ) -> Result<(), String>; + + async fn remove(&self, derivation_index: u32) -> Result<(), String>; +} + +/// Optional persistence fast path for one logical strategy/group commit. +/// SQLite uses this to make output insertion, input retirement, and voucher +/// deletion one transaction; in-memory/domain-only compositions can retain +/// the repository fallback. +#[async_trait] +pub trait TransferStateCommitter: Send + Sync { + async fn reserve(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String>; + + async fn revert(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String>; + + async fn commit( + &self, + spent_coins: &[u32], + spent_vouchers: &[u32], + change: &[Coin], + destination: &[Coin], + ) -> Result<(), String>; +} + +/// Mutex-guarded in-memory [`CoinRepository`]. +#[derive(Default)] +pub struct InMemoryCoinRepository { + coins: Mutex>, +} + +impl InMemoryCoinRepository { + pub fn with_coins(coins: impl IntoIterator) -> Self { + Self { + coins: Mutex::new(coins.into_iter().map(|c| (c.derivation_index, c)).collect()), + } + } +} + +#[async_trait] +impl CoinRepository for InMemoryCoinRepository { + async fn list(&self) -> Result, String> { + let mut coins: Vec = self.coins.lock().values().cloned().collect(); + coins.sort_by_key(|c| c.derivation_index); + Ok(coins) + } + + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.coins + .lock() + .insert(coin.derivation_index, coin.clone()); + Ok(()) + } + + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + match self.coins.lock().get_mut(&derivation_index) { + Some(coin) => { + coin.state = state; + Ok(()) + } + None => Err(format!("coin {derivation_index} not found")), + } + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.coins.lock().remove(&derivation_index); + Ok(()) + } +} + +/// Mutex-guarded in-memory [`VoucherRepository`]. +#[derive(Default)] +pub struct InMemoryVoucherRepository { + vouchers: Mutex>, +} + +impl InMemoryVoucherRepository { + pub fn with_vouchers(vouchers: impl IntoIterator) -> Self { + Self { + vouchers: Mutex::new( + vouchers + .into_iter() + .map(|v| (v.derivation_index, v)) + .collect(), + ), + } + } +} + +#[async_trait] +impl VoucherRepository for InMemoryVoucherRepository { + async fn list(&self) -> Result, String> { + let mut vouchers: Vec = self.vouchers.lock().values().cloned().collect(); + vouchers.sort_by_key(|v| v.derivation_index); + Ok(vouchers) + } + + async fn upsert(&self, voucher: &Voucher) -> Result<(), String> { + self.vouchers + .lock() + .insert(voucher.derivation_index, voucher.clone()); + Ok(()) + } + + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String> { + match self.vouchers.lock().get_mut(&derivation_index) { + Some(voucher) => { + voucher.local_state = state; + Ok(()) + } + None => Err(format!("voucher {derivation_index} not found")), + } + } + + async fn set_remote_state( + &self, + derivation_index: u32, + state: VoucherRemoteState, + ) -> Result<(), String> { + match self.vouchers.lock().get_mut(&derivation_index) { + Some(voucher) => { + voucher.remote_state = state; + Ok(()) + } + None => Err(format!("voucher {derivation_index} not found")), + } + } + + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.vouchers.lock().remove(&derivation_index); + Ok(()) + } +} + +use std::sync::Arc; + +/// The reserve → process / revert lifecycle around one transfer +/// +/// - [`reserve`](Self::reserve) marks inputs `PendingTransfer` BEFORE the +/// strategy runs — a process death here is caught by orphan recovery; +/// - [`process`](Self::process) retires spent inputs, removing them from +/// the pending sets before the first await; if a later `set_state` call +/// fails, the removed ids are re-appended so `revert` can still find +/// them; +/// - [`revert`](Self::revert) restores everything still pending back to +/// `Available`. +pub struct TransferContext { + coins: Arc, + vouchers: Arc, + pending_coins: Mutex>, + pending_vouchers: Mutex>, + committer: Option>, +} + +impl TransferContext { + pub fn new(coins: Arc, vouchers: Arc) -> Self { + Self { + coins, + vouchers, + pending_coins: Mutex::new(Vec::new()), + pending_vouchers: Mutex::new(Vec::new()), + committer: None, + } + } + + pub fn with_committer(mut self, committer: Arc) -> Self { + self.committer = Some(committer); + self + } + + pub async fn reserve(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + if let Some(committer) = &self.committer { + committer.reserve(coins, vouchers).await?; + self.pending_coins.lock().extend_from_slice(coins); + self.pending_vouchers.lock().extend_from_slice(vouchers); + return Ok(()); + } + let mut reserved_coins = Vec::new(); + for &index in coins { + if let Err(error) = self + .coins + .set_state(index, CoinState::PendingTransfer) + .await + { + for reserved in reserved_coins { + let _ = self.coins.set_state(reserved, CoinState::Available).await; + } + self.pending_coins.lock().clear(); + return Err(error); + } + reserved_coins.push(index); + self.pending_coins.lock().push(index); + } + let mut reserved_vouchers = Vec::new(); + for &index in vouchers { + if let Err(error) = self + .vouchers + .set_local_state(index, VoucherLocalState::PendingTransfer) + .await + { + for reserved in reserved_vouchers { + let _ = self + .vouchers + .set_local_state(reserved, VoucherLocalState::Available) + .await; + } + for reserved in reserved_coins { + let _ = self.coins.set_state(reserved, CoinState::Available).await; + } + self.pending_coins.lock().clear(); + self.pending_vouchers.lock().clear(); + return Err(error); + } + reserved_vouchers.push(index); + self.pending_vouchers.lock().push(index); + } + Ok(()) + } + + pub async fn process(&self, spent_coins: &[u32], spent_vouchers: &[u32]) -> Result<(), String> { + let removed_coins: Vec = { + let mut pending = self.pending_coins.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_coins.contains(index)) + .collect(); + pending.retain(|index| !spent_coins.contains(index)); + removed + }; + let removed_vouchers: Vec = { + let mut pending = self.pending_vouchers.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_vouchers.contains(index)) + .collect(); + pending.retain(|index| !spent_vouchers.contains(index)); + removed + }; + + let restore = |this: &Self| { + this.pending_coins.lock().extend_from_slice(&removed_coins); + this.pending_vouchers + .lock() + .extend_from_slice(&removed_vouchers); + }; + for &index in spent_coins { + if let Err(error) = self.coins.set_state(index, CoinState::Spent).await { + restore(self); + return Err(error); + } + } + for &index in spent_vouchers { + if let Err(error) = self + .vouchers + .set_local_state(index, VoucherLocalState::Spent) + .await + { + restore(self); + return Err(error); + } + } + Ok(()) + } + + pub async fn process_outputs( + &self, + spent_coins: &[u32], + spent_vouchers: &[u32], + change: &[Coin], + destination: &[Coin], + ) -> Result<(), String> { + let removed_coins: Vec = { + let mut pending = self.pending_coins.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_coins.contains(index)) + .collect(); + pending.retain(|index| !spent_coins.contains(index)); + removed + }; + let removed_vouchers: Vec = { + let mut pending = self.pending_vouchers.lock(); + let removed = pending + .iter() + .copied() + .filter(|index| spent_vouchers.contains(index)) + .collect(); + pending.retain(|index| !spent_vouchers.contains(index)); + removed + }; + let restore = |this: &Self| { + this.pending_coins.lock().extend_from_slice(&removed_coins); + this.pending_vouchers + .lock() + .extend_from_slice(&removed_vouchers); + }; + + let commit = async { + if let Some(committer) = &self.committer { + return committer + .commit(spent_coins, spent_vouchers, change, destination) + .await; + } + for coin in change { + let mut coin = coin.clone(); + coin.state = CoinState::Available; + self.coins.upsert(&coin).await?; + } + for coin in destination { + let mut coin = coin.clone(); + coin.state = CoinState::Spent; + self.coins.upsert(&coin).await?; + } + for &index in spent_coins { + self.coins.set_state(index, CoinState::Spent).await?; + } + for &index in spent_vouchers { + self.vouchers.remove(index).await?; + } + Ok::<(), String>(()) + } + .await; + if let Err(error) = commit { + restore(self); + return Err(error); + } + Ok(()) + } + + /// Restores only the supplied still-pending inputs. Used after a + /// definitive pre-broadcast failure so another recycler group with a + /// known checkpoint remains reserved for recovery. + pub async fn revert_inputs(&self, coins: &[u32], vouchers: &[u32]) -> Result<(), String> { + let coins = { + let mut pending = self.pending_coins.lock(); + let selected = pending + .iter() + .copied() + .filter(|index| coins.contains(index)) + .collect::>(); + pending.retain(|index| !coins.contains(index)); + selected + }; + let vouchers = { + let mut pending = self.pending_vouchers.lock(); + let selected = pending + .iter() + .copied() + .filter(|index| vouchers.contains(index)) + .collect::>(); + pending.retain(|index| !vouchers.contains(index)); + selected + }; + if let Some(committer) = &self.committer { + if let Err(error) = committer.revert(&coins, &vouchers).await { + self.pending_coins.lock().extend_from_slice(&coins); + self.pending_vouchers.lock().extend_from_slice(&vouchers); + return Err(error); + } + return Ok(()); + } + for index in coins { + self.coins.set_state(index, CoinState::Available).await?; + } + for index in vouchers { + self.vouchers + .set_local_state(index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } + + pub async fn revert(&self) -> Result<(), String> { + let coins: Vec = std::mem::take(&mut *self.pending_coins.lock()); + let vouchers: Vec = std::mem::take(&mut *self.pending_vouchers.lock()); + if let Some(committer) = &self.committer { + if let Err(error) = committer.revert(&coins, &vouchers).await { + self.pending_coins.lock().extend_from_slice(&coins); + self.pending_vouchers.lock().extend_from_slice(&vouchers); + return Err(error); + } + return Ok(()); + } + for index in coins { + self.coins.set_state(index, CoinState::Available).await?; + } + for index in vouchers { + self.vouchers + .set_local_state(index, VoucherLocalState::Available) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{VoucherPrivacyLevel, VoucherRemoteState}; + + fn coin(index: u32) -> Coin { + Coin { + exponent: 0, + derivation_index: index, + age: Some(1), + state: CoinState::Available, + } + } + + fn voucher(index: u32) -> Voucher { + Voucher { + exponent: 0, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { recycler_index: 0 }, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + async fn coin_state(repo: &InMemoryCoinRepository, index: u32) -> CoinState { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + .state + } + + #[tokio::test] + async fn reserve_process_revert_lifecycle() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1), coin(2)])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([voucher(10)])); + let context = TransferContext::new( + Arc::clone(&coins) as Arc<_>, + Arc::clone(&vouchers) as Arc<_>, + ); + + context.reserve(&[1, 2], &[10]).await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::PendingTransfer); + assert_eq!(coin_state(&coins, 2).await, CoinState::PendingTransfer); + + // One coin is processed (spent); the rest reverts. + context.process(&[1], &[]).await.unwrap(); + context.revert().await.unwrap(); + + assert_eq!( + coin_state(&coins, 1).await, + CoinState::Spent, + "processed stays spent" + ); + assert_eq!( + coin_state(&coins, 2).await, + CoinState::Available, + "unprocessed reverts" + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available, + "unprocessed voucher reverts" + ); + } + + #[tokio::test] + async fn failed_process_keeps_items_revertable() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let context = TransferContext::new(Arc::clone(&coins) as Arc<_>, vouchers); + + context.reserve(&[1], &[]).await.unwrap(); + // Index 99 does not exist — set_state fails mid-process. + assert!(context.process(&[99], &[]).await.is_err()); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn double_revert_is_a_no_op() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let context = TransferContext::new(Arc::clone(&coins) as Arc<_>, vouchers); + context.reserve(&[1], &[]).await.unwrap(); + context.revert().await.unwrap(); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Available); + } + + #[tokio::test] + async fn process_outputs_persists_change_spends_destination_and_deletes_vouchers() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([coin(1)])); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers([voucher(10)])); + let context = TransferContext::new( + Arc::clone(&coins) as Arc<_>, + Arc::clone(&vouchers) as Arc<_>, + ); + context.reserve(&[1], &[10]).await.unwrap(); + context + .process_outputs(&[1], &[10], &[coin(2)], &[coin(3)]) + .await + .unwrap(); + + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + assert_eq!(coin_state(&coins, 2).await, CoinState::Available); + assert_eq!(coin_state(&coins, 3).await, CoinState::Spent); + assert!(vouchers.list().await.unwrap().is_empty()); + context.revert().await.unwrap(); + assert_eq!(coin_state(&coins, 1).await, CoinState::Spent); + } +} diff --git a/rust/crates/truapi-coinage/src/ring_proof.rs b/rust/crates/truapi-coinage/src/ring_proof.rs new file mode 100644 index 000000000..a9f033254 --- /dev/null +++ b/rust/crates/truapi-coinage/src/ring_proof.rs @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use parity_scale_codec::Encode; + +use crate::selection::RecyclerKey; + +/// `"pop:polkadot.network/coinrecyclr"` — the recycler alias context. +pub const RECYCLER_ALIAS_CONTEXT: &[u8; 32] = b"pop:polkadot.network/coinrecyclr"; +/// Prefix of the free unload-token context. +pub const FREE_UNLOAD_TOKEN_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/coinftk"; +/// A single-context Bandersnatch ring-VRF proof is fixed at 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonOriginKind { + Full, + Lite, +} + +/// One finalized ring snapshot. The exponent is the on-chain +/// `Members.CollectionInfo.ring_size` exponent (9/10/14), not the +/// Bandersnatch PCS exponent (11/12/16). `ring_revision` is the live +/// `Members.Root.revision` of the snapshot — the 2026-08 runtime +/// (spec 1000032) requires it inside every proof-bearing extension. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RingProofParams { + pub ring_exponent: u8, + pub ring_index: u32, + pub ring_revision: u32, + pub ring_members: Vec<[u8; 32]>, +} + +/// One distinct free unload-token slot selected for a recycler group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedUnloadToken { + pub period: u32, + pub counter: u32, +} + +impl ResolvedUnloadToken { + /// `coinftk || period(le u32) || counter(le u32)`. + pub fn context(self) -> Vec { + let mut context = Vec::with_capacity(FREE_UNLOAD_TOKEN_CONTEXT_PREFIX.len() + 8); + context.extend_from_slice(FREE_UNLOAD_TOKEN_CONTEXT_PREFIX); + context.extend_from_slice(&self.period.to_le_bytes()); + context.extend_from_slice(&self.counter.to_le_bytes()); + context + } +} + +/// Everything the proof signer needs after the transaction builder has +/// produced the inherited implication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadProofRequest { + pub recycler: RecyclerKey, + pub voucher_derivation_indices: Vec, + pub recycler_ring: RingProofParams, + pub person_origin: PersonOriginKind, + pub people_ring: RingProofParams, + pub token: ResolvedUnloadToken, + pub inherited_implication: Vec, +} + +/// The complete proof-bearing `AsCoinage` payload plus the aliases carried by +/// the unload call itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadTokenProof { + pub person_origin: PersonOriginKind, + pub people_proof: Vec, + pub people_ring_index: u32, + pub people_ring_revision: u32, + pub period: u32, + pub counter: u32, + pub aliases: Vec<[u8; 32]>, + pub alias_proofs: Vec>, +} + +impl UnloadTokenProof { + /// SCALE-ready `AsCoinage(Some(…))` value for installation into the + /// prepared transaction extension. + pub fn as_coinage_extension(&self) -> crate::tx_extensions::AsCoinage { + use crate::tx_extensions::{AsCoinage, CoinagePeopleProof}; + + let proof = CoinagePeopleProof { + proof: self.people_proof.clone(), + ring: self.people_ring_index, + revision: self.people_ring_revision, + }; + match self.person_origin { + PersonOriginKind::Full => AsCoinage::unload_token_people( + proof, + self.period, + self.counter, + self.alias_proofs.clone(), + ), + PersonOriginKind::Lite => AsCoinage::unload_token_lite_people( + proof, + self.period, + self.counter, + self.alias_proofs.clone(), + ), + } + } +} + +/// A fail-closed proof construction error. Every variant carries a +/// concrete runtime/data cause (empty input, mismatched ring parameters, +/// or an underlying proof-generation failure) rather than standing in for +/// missing crypto support. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RingProofError { + EmptyVoucherGroup, + RecyclerMismatch, + EmptyRing(&'static str), + Proof(String), +} + +impl std::fmt::Display for RingProofError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyVoucherGroup => f.write_str("cannot prove an empty voucher group"), + Self::RecyclerMismatch => { + f.write_str("recycler proof parameters do not match the voucher group") + } + Self::EmptyRing(label) => write!(f, "{label} ring has no included members"), + Self::Proof(message) => f.write_str(message), + } + } +} + +impl std::error::Error for RingProofError {} + +/// The signing effect consumed by an offboard transaction submitter. +/// Chain-state preparation (person-origin selection, ring pages, and free +/// token slot resolution) stays outside this secret-holding trait. Both +/// methods are intentionally synchronous: once those inputs and the +/// implication exist, proof generation is local CPU work only. +pub trait RingProofProvider: Send + Sync { + /// Derives the public aliases needed to build the unload call. Aliases do + /// not depend on the transaction implication, so this is the first half of + /// the submitter's two-step flow. + fn unload_aliases( + &self, + voucher_derivation_indices: &[u32], + ) -> Result, RingProofError>; + + /// Creates the proof-bearing extension after the aliases are in the call + /// and the resulting inherited implication has been derived. + fn unload_proof( + &self, + request: &UnloadProofRequest, + ) -> Result; +} + +/// The production local signer. Root entropy is retained only in zeroizing +/// memory and no derived secret appears in the returned payload. +pub struct BandersnatchRingProofProvider { + vouchers: crate::keys::VoucherKeypairFactory, + crypto: std::sync::Arc, + people: std::sync::Arc, +} + +impl BandersnatchRingProofProvider { + pub fn new( + entropy: &[u8], + crypto: std::sync::Arc, + people: std::sync::Arc, + ) -> Self { + Self { + vouchers: crate::keys::VoucherKeypairFactory::new(entropy), + crypto, + people, + } + } +} + +impl RingProofProvider for BandersnatchRingProofProvider { + fn unload_aliases( + &self, + voucher_derivation_indices: &[u32], + ) -> Result, RingProofError> { + if voucher_derivation_indices.is_empty() { + return Err(RingProofError::EmptyVoucherGroup); + } + let vouchers = &self.vouchers; + voucher_derivation_indices + .iter() + .map(|index| { + vouchers + .alias(*index, RECYCLER_ALIAS_CONTEXT, self.crypto.as_ref()) + .map_err(RingProofError::Proof) + }) + .collect() + } + + fn unload_proof( + &self, + request: &UnloadProofRequest, + ) -> Result { + if request.voucher_derivation_indices.is_empty() { + return Err(RingProofError::EmptyVoucherGroup); + } + if request.recycler_ring.ring_index != request.recycler.index { + return Err(RingProofError::RecyclerMismatch); + } + if request.recycler_ring.ring_members.is_empty() { + return Err(RingProofError::EmptyRing("recycler")); + } + if request.people_ring.ring_members.is_empty() { + return Err(RingProofError::EmptyRing("People")); + } + + let alias_message = blake2b_256(&request.inherited_implication); + let vouchers = &self.vouchers; + let aliases = self.unload_aliases(&request.voucher_derivation_indices)?; + let mut alias_proofs = Vec::with_capacity(request.voucher_derivation_indices.len()); + for index in &request.voucher_derivation_indices { + alias_proofs.push( + vouchers + .ring_vrf_proof( + *index, + request.recycler_ring.ring_exponent, + &request.recycler_ring.ring_members, + RECYCLER_ALIAS_CONTEXT, + &alias_message, + self.crypto.as_ref(), + ) + .map_err(RingProofError::Proof)?, + ); + } + + let mut people_payload = alias_proofs.encode(); + people_payload.extend_from_slice(&request.inherited_implication); + let people_message = blake2b_256(&people_payload); + let people_proof = self + .people + .ring_vrf_proof( + request.person_origin, + &request.people_ring, + &request.token.context(), + &people_message, + ) + .map_err(RingProofError::Proof)?; + if alias_proofs + .iter() + .any(|proof| proof.len() != RING_VRF_PROOF_LEN) + || people_proof.len() != RING_VRF_PROOF_LEN + { + return Err(RingProofError::Proof( + "Bandersnatch proof has an invalid length".into(), + )); + } + + debug_assert!( + alias_proofs + .iter() + .all(|proof| proof.len() == RING_VRF_PROOF_LEN) + ); + debug_assert_eq!(people_proof.len(), RING_VRF_PROOF_LEN); + Ok(UnloadTokenProof { + person_origin: request.person_origin, + people_proof, + people_ring_index: request.people_ring.ring_index, + people_ring_revision: request.people_ring.ring_revision, + period: request.token.period, + counter: request.token.counter, + aliases, + alias_proofs, + }) + } +} + +fn blake2b_256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Authority-owned People signer. The adapter chooses the deployed full/lite +/// identity derivation for its network; Coinage never exports that identity. +pub trait PersonRingProofSigner: Send + Sync { + /// Sign the exact ring/context/implication supplied by the unload builder. + fn ring_vrf_proof( + &self, + origin: PersonOriginKind, + ring: &RingProofParams, + context: &[u8], + message: &[u8], + ) -> Result, String>; +} diff --git a/rust/crates/truapi-coinage/src/secret_claim.rs b/rust/crates/truapi-coinage/src/secret_claim.rs new file mode 100644 index 000000000..9ad55b5a8 --- /dev/null +++ b/rust/crates/truapi-coinage/src/secret_claim.rs @@ -0,0 +1,1869 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Chain-backed claiming of externally supplied Coinage secrets. +//! [`ExternalMemoClaiming`] is the secret-preserving claim effect. This module implements that effect without ever projecting a +//! secret through FFI: +//! 1. validate every expanded sr25519 secret and reject duplicate sources; +//! 2. read the live Coinage denomination context and source/destination +//! state in ordered batches; +//! 3. allocate one session-derived destination with the same denomination; +//! 4. treat a destination present on-chain while its source is absent as +//! already complete; the inverse is safe to retry. Both present is a +//! recipient collision and both absent is ambiguous, so both cases fail +//! closed; +//! 5. submit one `Coinage.transfer` per source under its own `AsCoin` +//! origin; and +//! 6. save only destinations whose finalized on-chain value was verified. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::COIN_MAX_AGE; +use crate::allocator::CoinAllocator; +use crate::claim_plan::{ + ClaimMarkers, ClaimPlan, ClaimPlanStatus, ClaimPlanStore, CodableClaimPlanEntry, +}; +use crate::constants::SEND_VERIFY_BLOCK_TIMEOUT; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState}; +use crate::repo::CoinRepository; +use crate::sync::OnChainCoin; + +type SecretSource = Option<(MemoEntry, [u8; 32])>; + +/// One source-coin transfer. This type intentionally has no `Debug` +/// implementation: `source_secret` is seed-phrase-tier material. +pub struct ExternalCoinTransferRequest { + pub source_secret: MemoEntry, + pub source_public: [u8; 32], + pub recipient: [u8; 32], + pub exponent: i16, + pub asset_unit: u128, + pub amount_planks: u128, +} + +/// The chain edge used by [`ExternalSecretClaimService`]. +/// A production implementation must revalidate the request against live +/// metadata/state immediately before signing and return only after the +/// submitted extrinsic finalized successfully and the destination coin was +/// observed with the requested exponent. +#[async_trait] +pub trait ExternalCoinTransferBackend: Send + Sync { + async fn denomination_context(&self) -> Result; + + /// Ordered `Coinage.CoinsByOwner` batch. + async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + ) -> Result>, String>; + + /// Consumes the raw secret and returns the finalized destination row. + async fn submit_transfer( + &self, + request: ExternalCoinTransferRequest, + ) -> Result; +} + +/// Session-scoped recipient for W3S/external Coinage secrets. +/// Construct a fresh instance from the active wallet entropy. The +/// destination key factory owns no FFI-visible surface and zeroizes its root +/// material on drop. +pub struct ExternalSecretClaimService { + key_factory: Arc, + allocator: Arc, + coins: Arc, + plans: Arc, + backend: Arc, + operation: Mutex<()>, +} + +impl ExternalSecretClaimService { + pub fn new( + root_entropy: &[u8], + allocator: Arc, + coins: Arc, + plans: Arc, + backend: Arc, + ) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + allocator, + coins, + plans, + backend, + operation: Mutex::new(()), + } + } + + pub async fn await_memo_sources_on_chain(&self, memo: &TransferMemo) -> Result<(), String> { + self.await_memo_sources_on_chain_with( + memo, + SEND_VERIFY_BLOCK_TIMEOUT, + std::time::Duration::from_secs(6), + ) + .await + } + + /// Timeout-parameterized body of [`Self::await_memo_sources_on_chain`] + /// (attempt count ≙ finalized blocks, interval ≙ block time). + pub async fn await_memo_sources_on_chain_with( + &self, + memo: &TransferMemo, + attempts: u32, + interval: std::time::Duration, + ) -> Result<(), String> { + if memo.entries.is_empty() { + return Err("external coin claim requires at least one secret".into()); + } + let mut publics = Vec::with_capacity(memo.entries.len()); + for (index, entry) in memo.entries.iter().enumerate() { + let public = schnorrkel::SecretKey::from_bytes(&entry.0) + .map_err(|error| format!("external coin secret {index} is invalid: {error}"))? + .to_public() + .to_bytes(); + publics.push(public); + } + for attempt in 0..attempts.max(1) { + if attempt > 0 { + crate::timer::sleep(interval).await; + } + let rows = + fetch_exact(self.backend.as_ref(), &publics, "external send detection").await?; + if rows.iter().all(Option::is_some) { + return Ok(()); + } + } + Err(format!( + "external send not detected within {} blocks", + attempts.max(1) + )) + } + + /// Validate and persist the complete destination plan without submitting + /// any transaction. Hosts call this before acknowledging custody of a memo. + pub async fn prepare_memo( + &self, + memo: &TransferMemo, + message_id: String, + ) -> Result { + let _operation = self.operation.lock().await; + self.prepare_locked(memo, message_id).await + } + + async fn prepare_locked( + &self, + memo: &TransferMemo, + message_id: String, + ) -> Result { + if memo.entries.is_empty() { + return Err("external coin claim requires at least one secret".into()); + } + let memo_key = memo.identifier(); + + // A Finished plan is authoritative: this memo was claimed on a + // prior run. Report the stored amount instead of re-executing — + // the claimed coins may since have been spent or recycled, so + // re-verification against live chain state can no longer prove + // anything and must not overwrite the terminal outcome. + let existing = self.plans.plan(&memo_key).await?; + if let Some(plan) = &existing + && plan.status == ClaimPlanStatus::Finished + { + if plan.memo_key != memo_key + || plan.total_value != memo.total_value + || plan.claimed_amount != Some(memo.total_value) + || plan.entries.len() != memo.entries.len() + { + return Err("finished external coin claim does not match its memo".into()); + } + return Ok(plan.clone()); + } + + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + + let total_value = memo.total_value; + let sources = source_entries(memo.entries.clone())?; + let plan = match existing { + Some(plan) => { + validate_existing_plan( + &plan, + &memo_key, + &message_id, + total_value, + sources.len(), + &context, + )?; + plan + } + None => { + let source_public = sources + .iter() + .map(|source| source.as_ref().expect("source installed").1) + .collect::>(); + let source_rows = fetch_exact( + self.backend.as_ref(), + &source_public, + "external source coin query", + ) + .await?; + let mut exponents = Vec::with_capacity(source_rows.len()); + let mut computed_total = 0u128; + for (index, row) in source_rows.into_iter().enumerate() { + let row = row.ok_or_else(|| { + format!("external source coin {index} is no longer on-chain") + })?; + validate_exponent(row.exponent, &context)?; + computed_total = computed_total + .checked_add(context.value_in_planks(row.exponent)) + .ok_or_else(|| "external coin claim total exceeds u128".to_string())?; + exponents.push(row.exponent); + } + if computed_total != total_value { + return Err(format!( + "external coin claim amount mismatch: memo {total_value}, chain {computed_total}" + )); + } + + let plan = self + .allocate_plan( + memo_key, + message_id.clone(), + total_value, + &source_public, + &exponents, + ) + .await?; + self.plans.save(&plan).await?; + plan + } + }; + + Ok(plan) + } + + async fn claim(&self, mut memo: TransferMemo, message_id: String) -> Result { + let _operation = self.operation.lock().await; + let plan = self.prepare_locked(&memo, message_id).await?; + if plan.status == ClaimPlanStatus::Finished { + return credited(&plan); + } + let memo_key = plan.memo_key; + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + let mut sources = source_entries(std::mem::take(&mut memo.entries))?; + let outcome = self.execute_plan(&plan, &context, &mut sources).await; + match outcome { + Ok(claimed) => { + self.plans + .update_status(&memo_key, ClaimPlanStatus::Finished, Some(claimed)) + .await?; + let finished = + self.plans.plan(&memo_key).await?.ok_or_else(|| { + "finished external coin claim plan disappeared".to_string() + })?; + credited(&finished) + } + Err(error) => { + let confirmed = self + .plans + .plan(&memo_key) + .await? + .and_then(|plan| plan.claimed_amount); + if let Err(status_error) = self + .plans + .update_status(&memo_key, ClaimPlanStatus::Error, confirmed) + .await + { + tracing::warn!( + %status_error, + "external coin claim error status could not be persisted" + ); + } + Err(error) + } + } + } + + async fn allocate_plan( + &self, + memo_key: [u8; 32], + message_id: String, + total_value: u128, + source_public: &[[u8; 32]], + exponents: &[i16], + ) -> Result { + let mut entries = Vec::with_capacity(exponents.len()); + let mut recipients = Vec::with_capacity(exponents.len()); + let source_set = source_public.iter().copied().collect::>(); + let mut recipient_set = HashSet::with_capacity(exponents.len()); + + for (entry_index, exponent) in exponents.iter().copied().enumerate() { + let entry_index = i16::try_from(entry_index) + .map_err(|_| "external coin claim has too many entries".to_string())?; + let destination = self.allocator.allocate(exponent).await?; + let recipient = self.key_factory.public_key(destination.derivation_index)?; + if recipient == [0; 32] + || source_set.contains(&recipient) + || !recipient_set.insert(recipient) + { + return Err("external coin claim destination key collision".into()); + } + recipients.push(recipient); + entries.push(CodableClaimPlanEntry { + entry_index, + exponent, + derivation_index: destination.derivation_index, + }); + } + + let destination_rows = fetch_exact( + self.backend.as_ref(), + &recipients, + "external destination collision query", + ) + .await?; + if destination_rows.iter().any(Option::is_some) { + return Err("external coin claim destination already exists on-chain".into()); + } + + Ok(ClaimPlan { + memo_key, + message_id: Some(message_id), + entries, + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value, + markers: ClaimMarkers::default(), + }) + } + + /// Persist the plan's markers, and with `claimed` its processed prefix, in + /// one write so a forfeit and its prefix advance never separate. + async fn save_markers( + &self, + memo_key: &[u8; 32], + markers: &ClaimMarkers, + claimed: Option, + ) -> Result<(), String> { + let mut current = self + .plans + .plan(memo_key) + .await? + .ok_or_else(|| "external coin claim plan disappeared".to_string())?; + current.markers = markers.clone(); + if let Some(claimed) = claimed { + current.status = ClaimPlanStatus::Processing; + current.claimed_amount = Some(claimed); + } + self.plans.save(¤t).await + } + + async fn execute_plan( + &self, + plan: &ClaimPlan, + context: &DenominationBreakdownContext, + sources: &mut [SecretSource], + ) -> Result { + let entries = ordered_plan_entries(plan, sources.len())?; + let source_public = sources + .iter() + .map(|source| source.as_ref().expect("validated source").1) + .collect::>(); + let recipients = entries + .iter() + .map(|entry| self.key_factory.public_key(entry.derivation_index)) + .collect::, _>>()?; + validate_recipients(&source_public, &recipients)?; + + let mut all_keys = source_public.clone(); + all_keys.extend_from_slice(&recipients); + let mut rows = fetch_exact( + self.backend.as_ref(), + &all_keys, + "external claim recovery query", + ) + .await?; + let destination_rows = rows.split_off(source_public.len()); + let source_rows = rows; + let local_states = self + .coins + .list() + .await? + .into_iter() + .map(|coin| (coin.derivation_index, coin.state)) + .collect::>(); + + let mut markers = plan.markers.clone(); + let mut claimed = 0u128; + for (position, entry) in entries.into_iter().enumerate() { + validate_exponent(entry.exponent, context)?; + let expected_amount = context.value_in_planks(entry.exponent); + claimed = claimed + .checked_add(expected_amount) + .ok_or_else(|| "external coin claim total exceeds u128".to_string())?; + // Each sequential prefix was durably recorded only after exact + // finalized destination verification. Missing both keys without + // that evidence is ambiguous, never proof of a successful claim. + if claimed <= plan.claimed_amount.unwrap_or(0) { + continue; + } + let source = source_rows[position]; + let destination = destination_rows[position]; + let landed = match (source, destination) { + (None, Some(destination)) => { + if destination.exponent != entry.exponent { + return Err(format!( + "external claim destination {position} denomination mismatch: expected {}, found {}", + entry.exponent, destination.exponent + )); + } + destination + } + (Some(source), None) => { + if source.exponent != entry.exponent { + return Err(format!( + "external claim source {position} denomination changed: expected {}, found {}", + entry.exponent, source.exponent + )); + } + // Record the submission before it can reach the chain, so a + // transfer that lands unobserved is never read as a spend. + if !markers.submitted.contains(&entry.entry_index) { + markers.submitted.push(entry.entry_index); + self.save_markers(&plan.memo_key, &markers, None).await?; + } + let (source_secret, source_public) = sources[position] + .take() + .expect("validated source entry consumed once"); + let recipient = recipients[position]; + self.backend + .submit_transfer(ExternalCoinTransferRequest { + source_secret, + source_public, + recipient, + exponent: entry.exponent, + asset_unit: context.asset_unit, + amount_planks: expected_amount, + }) + .await? + } + (Some(_), Some(_)) => { + return Err( + "external claim destination collision: source remains unconsumed".into(), + ); + } + // Finalized absence of an entry this wallet never submitted is + // proof the source was spent elsewhere: forfeit it and continue. + (None, None) if !markers.submitted.contains(&entry.entry_index) => { + markers.forfeited.push(entry.entry_index); + markers.forfeited_value = markers + .forfeited_value + .checked_add(expected_amount) + .ok_or_else(|| "external coin claim total exceeds u128".to_string())?; + self.save_markers(&plan.memo_key, &markers, Some(claimed)) + .await?; + continue; + } + (None, None) => { + return Err("external claim source and destination are both absent without finalized evidence".into()); + } + }; + if landed.exponent != entry.exponent { + return Err(format!( + "external claim finalized destination {position} denomination mismatch: expected {}, found {}", + entry.exponent, landed.exponent + )); + } + let state = local_states + .get(&entry.derivation_index) + .copied() + .unwrap_or(CoinState::Available); + self.coins + .upsert(&Coin { + exponent: entry.exponent, + derivation_index: entry.derivation_index, + age: Some(landed.age), + state, + }) + .await?; + self.plans + .update_status(&plan.memo_key, ClaimPlanStatus::Processing, Some(claimed)) + .await?; + } + if claimed != plan.total_value { + return Err(format!( + "external coin claim plan amount mismatch: plan {}, entries {claimed}", + plan.total_value + )); + } + Ok(claimed) + } +} + +#[async_trait] +impl ExternalMemoClaiming for ExternalSecretClaimService { + async fn claim_external_memo( + &self, + memo: TransferMemo, + message_id: String, + ) -> Result { + self.claim(memo, message_id).await + } +} + +/// Exact result of one local spent-coin recovery pass. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SpentCoinTransferRecoveryReport { + /// Present max-age coins restored locally plus younger coins moved to + /// fresh destinations. + pub recovered_count: u32, + pub recovered_planks: u128, + /// Subset restored locally because `Coinage.transfer` would reject a + /// coin at or beyond `COIN_MAX_AGE`. + pub restored_max_age_count: u32, + /// Subset claimed through finalized `Coinage.transfer` extrinsics. + pub transferred_count: u32, +} + +pub struct SpentCoinTransferRecoveryService { + key_factory: Arc, + coins: Arc, + backend: Arc, + claimer: Arc, + operation: Mutex<()>, +} + +impl SpentCoinTransferRecoveryService { + pub fn new( + root_entropy: &[u8], + coins: Arc, + backend: Arc, + claimer: Arc, + ) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins, + backend, + claimer, + operation: Mutex::new(()), + } + } + + pub async fn recover( + &self, + spent: Vec, + ) -> Result { + let _operation = self.operation.lock().await; + if spent.is_empty() { + return Ok(SpentCoinTransferRecoveryReport::default()); + } + let mut indices = HashSet::with_capacity(spent.len()); + for coin in &spent { + if coin.state != CoinState::Spent { + return Err(format!( + "spent recovery received non-spent coin {}", + coin.derivation_index + )); + } + if !indices.insert(coin.derivation_index) { + return Err(format!( + "spent recovery coin {} is duplicated", + coin.derivation_index + )); + } + } + + let context = self.backend.denomination_context().await?; + validate_context(&context)?; + let public_keys = spent + .iter() + .map(|coin| self.key_factory.public_key(coin.derivation_index)) + .collect::, _>>()?; + let rows = fetch_exact( + self.backend.as_ref(), + &public_keys, + "spent Coinage recovery query", + ) + .await?; + + let mut report = SpentCoinTransferRecoveryReport::default(); + let mut transfer_entries = Vec::new(); + let mut transfer_total = 0u128; + for (coin, row) in spent.into_iter().zip(rows) { + let Some(row) = row else { + continue; + }; + validate_exponent(row.exponent, &context)?; + if row.exponent != coin.exponent { + return Err(format!( + "spent coin {} denomination mismatch: local {}, chain {}", + coin.derivation_index, coin.exponent, row.exponent + )); + } + let value = context.value_in_planks(row.exponent); + if row.age >= COIN_MAX_AGE { + self.coins + .upsert(&Coin { + exponent: row.exponent, + derivation_index: coin.derivation_index, + age: Some(row.age), + state: CoinState::Available, + }) + .await?; + report.recovered_count = report.recovered_count.saturating_add(1); + report.restored_max_age_count = report.restored_max_age_count.saturating_add(1); + report.recovered_planks = report + .recovered_planks + .checked_add(value) + .ok_or_else(|| "spent Coinage recovery total exceeds u128".to_string())?; + } else { + transfer_entries.push(MemoEntry( + self.key_factory.secret_bytes(coin.derivation_index)?, + )); + transfer_total = transfer_total + .checked_add(value) + .ok_or_else(|| "spent Coinage transfer total exceeds u128".to_string())?; + report.transferred_count = report.transferred_count.saturating_add(1); + } + } + + if !transfer_entries.is_empty() { + let memo = TransferMemo { + entries: transfer_entries, + total_value: transfer_total, + }; + let memo_key = memo.identifier(); + self.claimer + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await?; + report.recovered_count = report + .recovered_count + .checked_add(report.transferred_count) + .ok_or_else(|| "spent Coinage recovered count exceeds u32".to_string())?; + report.recovered_planks = report + .recovered_planks + .checked_add(transfer_total) + .ok_or_else(|| "spent Coinage recovery total exceeds u128".to_string())?; + } + Ok(report) + } +} + +#[async_trait] +impl SpentCoinsRecovering for SpentCoinTransferRecoveryService { + async fn recover_spent_coins(&self, spent: Vec) -> Result { + self.recover(spent) + .await + .map(|report| report.recovered_planks) + } +} + +/// The only accepted external-claim id. It is public for host composition +/// and tests, but contains no secret material. +pub fn external_claim_message_id(memo_key: &[u8; 32]) -> String { + format!("w3s-coins-{}", hex::encode(memo_key)) +} + +fn source_entries(entries: Vec) -> Result, String> { + let mut seen = HashSet::with_capacity(entries.len()); + entries + .into_iter() + .enumerate() + .map(|(index, entry)| { + let secret = schnorrkel::SecretKey::from_bytes(&entry.0) + .map_err(|error| format!("external coin secret {index} is invalid: {error}"))?; + let public = secret.to_public().to_bytes(); + if !seen.insert(public) { + return Err(format!("external coin source {index} is duplicated")); + } + Ok(Some((entry, public))) + }) + .collect() +} + +fn validate_context(context: &DenominationBreakdownContext) -> Result<(), String> { + if context.asset_unit == 0 { + return Err("Coinage asset unit must be non-zero".into()); + } + if context.max_exponent < context.min_exponent { + return Err("Coinage denomination range is inverted".into()); + } + Ok(()) +} + +fn validate_exponent(exponent: i16, context: &DenominationBreakdownContext) -> Result<(), String> { + if exponent < context.min_exponent || exponent > context.max_exponent { + return Err(format!( + "Coinage exponent {exponent} is outside {}..={}", + context.min_exponent, context.max_exponent + )); + } + // The live pallet's transfer preserves its signed i8 `value`. + i8::try_from(exponent) + .map(|_| ()) + .map_err(|_| format!("Coinage exponent {exponent} does not fit the runtime i8")) +} + +fn validate_existing_plan( + plan: &ClaimPlan, + memo_key: &[u8; 32], + message_id: &str, + total_value: u128, + entry_count: usize, + context: &DenominationBreakdownContext, +) -> Result<(), String> { + // Plans written before chat claims switched to chat message ids carry + // the memo-derived external id; accept either spelling. + let message_id_matches = plan.message_id.as_deref() == Some(message_id) + || plan.message_id.as_deref() == Some(external_claim_message_id(memo_key).as_str()); + if plan.memo_key != *memo_key || !message_id_matches || plan.total_value != total_value { + return Err("persisted external coin claim plan does not match its memo".into()); + } + if plan.entries.len() != entry_count { + return Err(format!( + "persisted external coin claim plan has {} entries; expected {entry_count}", + plan.entries.len() + )); + } + let entries = ordered_plan_entries(plan, entry_count)?; + let mut total = 0u128; + let confirmed = plan.claimed_amount.unwrap_or(0); + let mut confirmed_boundary = confirmed == 0; + let mut forfeited_value = 0u128; + for entry in entries { + validate_exponent(entry.exponent, context)?; + let value = context.value_in_planks(entry.exponent); + total = total + .checked_add(value) + .ok_or_else(|| "external coin claim plan total exceeds u128".to_string())?; + confirmed_boundary |= confirmed == total; + if plan.markers.forfeited.contains(&entry.entry_index) { + // A forfeit is recorded together with the prefix that passes it. + if total > confirmed { + return Err("persisted external claim forfeit lies beyond its progress".into()); + } + forfeited_value = forfeited_value + .checked_add(value) + .ok_or_else(|| "external coin claim plan total exceeds u128".to_string())?; + } + } + if forfeited_value != plan.markers.forfeited_value { + return Err("persisted external claim forfeits do not match their value".into()); + } + if total != total_value { + return Err(format!( + "persisted external coin claim amount mismatch: memo {total_value}, plan {total}" + )); + } + if !confirmed_boundary { + return Err("persisted external claim progress is not a finalized entry boundary".into()); + } + Ok(()) +} + +/// The credited value of a finished plan, never its forfeited entries. +fn credited(plan: &ClaimPlan) -> Result { + plan.credited_amount() + .ok_or_else(|| "external coin claim credit exceeds its processed prefix".to_string()) +} + +fn ordered_plan_entries( + plan: &ClaimPlan, + entry_count: usize, +) -> Result, String> { + let mut ordered = vec![None; entry_count]; + for entry in &plan.entries { + let index = usize::try_from(entry.entry_index) + .map_err(|_| "external coin claim plan has a negative entry index".to_string())?; + let slot = ordered.get_mut(index).ok_or_else(|| { + format!("external coin claim plan entry index {index} is out of bounds") + })?; + if slot.replace(*entry).is_some() { + return Err(format!( + "external coin claim plan entry index {index} is duplicated" + )); + } + } + ordered + .into_iter() + .enumerate() + .map(|(index, entry)| { + entry.ok_or_else(|| format!("external coin claim plan entry index {index} is missing")) + }) + .collect() +} + +fn validate_recipients(sources: &[[u8; 32]], recipients: &[[u8; 32]]) -> Result<(), String> { + if sources.len() != recipients.len() { + return Err("external coin source/recipient count mismatch".into()); + } + let source_set = sources.iter().copied().collect::>(); + let mut recipient_set = HashSet::with_capacity(recipients.len()); + for recipient in recipients { + if *recipient == [0; 32] + || source_set.contains(recipient) + || !recipient_set.insert(*recipient) + { + return Err("external coin claim recipient key collision".into()); + } + } + Ok(()) +} + +async fn fetch_exact( + backend: &dyn ExternalCoinTransferBackend, + keys: &[[u8; 32]], + label: &str, +) -> Result>, String> { + let rows = backend.fetch_coins(keys).await?; + if rows.len() != keys.len() { + return Err(format!( + "{label} returned {} values for {} keys", + rows.len(), + keys.len() + )); + } + Ok(rows) +} + +/// Secret-preserving claim operation; implementations do not expose memo bytes +/// through a product or transport API. +#[async_trait] +pub trait ExternalMemoClaiming: Send + Sync { + /// Execute or resume a durable memo-keyed claim into this wallet. + async fn claim_external_memo( + &self, + memo: TransferMemo, + message_id: String, + ) -> Result; +} + +/// Recover locally spent coins whose finalized on-chain value remains owned. +#[async_trait] +pub trait SpentCoinsRecovering: Send + Sync { + /// Reconcile and recover the supplied spent inventory. + async fn recover_spent_coins(&self, spent: Vec) -> Result; +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex as StdMutex; + + use super::*; + use crate::index_store::InMemoryCoinageIndexStore; + use crate::repo::InMemoryCoinRepository; + + fn context() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 2, + } + } + + fn expanded_secret(seed: u8) -> MemoEntry { + MemoEntry( + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .unwrap() + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_bytes(), + ) + } + + fn public(entry: &MemoEntry) -> [u8; 32] { + schnorrkel::SecretKey::from_bytes(&entry.0) + .unwrap() + .to_public() + .to_bytes() + } + + #[derive(Default)] + struct MemoryPlans(StdMutex>); + + #[async_trait] + impl ClaimPlanStore for MemoryPlans { + async fn save(&self, plan: &ClaimPlan) -> Result<(), String> { + self.0.lock().insert(plan.memo_key, plan.clone()); + Ok(()) + } + + async fn plan(&self, memo_key: &[u8; 32]) -> Result, String> { + Ok(self.0.lock().get(memo_key).cloned()) + } + + async fn load_all(&self) -> Result, String> { + Ok(self.0.lock().values().cloned().collect()) + } + + async fn update_status( + &self, + memo_key: &[u8; 32], + status: ClaimPlanStatus, + claimed_amount: Option, + ) -> Result<(), String> { + let mut plans = self.0.lock(); + let plan = plans + .get_mut(memo_key) + .ok_or_else(|| "claim plan is missing".to_string())?; + plan.status = status; + plan.claimed_amount = claimed_amount; + Ok(()) + } + + async fn remove(&self, memo_key: &[u8; 32]) -> Result<(), String> { + self.0.lock().remove(memo_key); + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct RecordedTransfer { + source: [u8; 32], + recipient: [u8; 32], + exponent: i16, + asset_unit: u128, + amount_planks: u128, + } + + struct MockBackend { + context: DenominationBreakdownContext, + state: StdMutex>, + transfers: StdMutex>, + plans: Arc, + expected_plan: StdMutex>, + /// Consume the source but report no outcome, like a transfer whose + /// finality this wallet has not yet observed. + lose_transfers: StdMutex, + } + + #[async_trait] + impl ExternalCoinTransferBackend for MockBackend { + async fn denomination_context(&self) -> Result { + Ok(self.context.clone()) + } + + async fn fetch_coins( + &self, + public_keys: &[[u8; 32]], + ) -> Result>, String> { + let state = self.state.lock(); + Ok(public_keys + .iter() + .map(|public| state.get(public).copied()) + .collect()) + } + + async fn submit_transfer( + &self, + request: ExternalCoinTransferRequest, + ) -> Result { + if let Some(expected_plan) = *self.expected_plan.lock() { + assert!( + self.plans.0.lock().contains_key(&expected_plan), + "claim plan must be durable before the first transfer" + ); + } + let derived = public(&request.source_secret); + assert_eq!(derived, request.source_public); + let mut state = self.state.lock(); + let source = state + .remove(&request.source_public) + .ok_or_else(|| "source disappeared".to_string())?; + assert_eq!(source.exponent, request.exponent); + if *self.lose_transfers.lock() { + return Err("transfer outcome not yet observed".into()); + } + assert!(!state.contains_key(&request.recipient)); + let landed = OnChainCoin { + exponent: request.exponent, + age: 0, + }; + state.insert(request.recipient, landed); + self.transfers.lock().push(RecordedTransfer { + source: request.source_public, + recipient: request.recipient, + exponent: request.exponent, + asset_unit: request.asset_unit, + amount_planks: request.amount_planks, + }); + Ok(landed) + } + } + + struct Rig { + service: Arc, + plans: Arc, + coins: Arc, + backend: Arc, + } + + fn rig(source_rows: impl IntoIterator) -> Rig { + let plans = Arc::new(MemoryPlans::default()); + let coins = Arc::new(InMemoryCoinRepository::default()); + let backend = Arc::new(MockBackend { + context: context(), + state: StdMutex::new(source_rows.into_iter().collect()), + transfers: StdMutex::new(Vec::new()), + plans: Arc::clone(&plans), + expected_plan: StdMutex::new(None), + lose_transfers: StdMutex::new(false), + }); + let service = Arc::new(ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + Arc::clone(&coins) as Arc<_>, + Arc::clone(&plans) as Arc<_>, + Arc::clone(&backend) as Arc<_>, + )); + Rig { + service, + plans, + coins, + backend, + } + } + + #[tokio::test] + async fn await_memo_sources_waits_until_all_sources_land() { + let entry_a = expanded_secret(1); + let entry_b = expanded_secret(2); + let coin = OnChainCoin { + exponent: 1, + age: 0, + }; + // Only A is on-chain at start; B lands while the wait is polling. + let rig = rig([(public(&entry_a), coin)]); + let waiter = { + let service = Arc::clone(&rig.service); + let memo = TransferMemo { + entries: vec![entry_a.clone(), entry_b.clone()], + total_value: 20, + }; + tokio::spawn(async move { + service + .await_memo_sources_on_chain_with( + &memo, + 200, + std::time::Duration::from_millis(2), + ) + .await + }) + }; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + rig.backend.state.lock().insert(public(&entry_b), coin); + waiter + .await + .unwrap() + .expect("resolves once every source landed"); + + // A memo whose sources never land times out into an error. + let missing = TransferMemo { + entries: vec![expanded_secret(3)], + total_value: 10, + }; + let outcome = rig + .service + .await_memo_sources_on_chain_with(&missing, 3, std::time::Duration::from_millis(1)) + .await; + assert!(outcome.unwrap_err().contains("not detected")); + } + + #[tokio::test] + async fn claim_persists_plan_before_exact_denominated_transfers() { + let first = expanded_secret(1); + let second = expanded_secret(2); + let memo = TransferMemo { + entries: vec![first.clone(), second.clone()], + total_value: 60, + }; + let memo_key = memo.identifier(); + let rig = rig([ + ( + public(&first), + OnChainCoin { + exponent: 1, + age: 7, + }, + ), + ( + public(&second), + OnChainCoin { + exponent: 2, + age: 9, + }, + ), + ]); + *rig.backend.expected_plan.lock() = Some(memo_key); + + rig.service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap(); + + let transfers = rig.backend.transfers.lock().clone(); + assert_eq!(transfers.len(), 2); + assert_eq!( + transfers + .iter() + .map(|transfer| ( + transfer.exponent, + transfer.asset_unit, + transfer.amount_planks, + )) + .collect::>(), + [(1, 10, 20), (2, 10, 40)] + ); + let plan = rig.plans.0.lock().get(&memo_key).unwrap().clone(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, Some(60)); + assert_eq!(plan.entries.len(), 2); + let coins = rig.coins.list().await.unwrap(); + assert_eq!(coins.len(), 2); + assert!(coins.iter().all(|coin| coin.state == CoinState::Available)); + } + + #[tokio::test] + async fn amount_mismatch_never_allocates_or_submits() { + let source = expanded_secret(3); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 21, + }; + let memo_key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + + let error = rig + .service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap_err(); + assert!(error.contains("amount mismatch")); + assert!(rig.plans.0.lock().is_empty()); + assert!(rig.backend.transfers.lock().is_empty()); + } + + #[tokio::test] + async fn duplicate_source_fails_before_chain_mutation() { + let source = expanded_secret(4); + let duplicate = TransferMemo { + entries: vec![source.clone(), source.clone()], + total_value: 40, + }; + let duplicate_key = duplicate.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + assert!( + rig.service + .claim_external_memo(duplicate, external_claim_message_id(&duplicate_key)) + .await + .unwrap_err() + .contains("duplicated") + ); + assert!(rig.backend.transfers.lock().is_empty()); + } + + /// Chat claims pass the chat row's message id; it must be accepted and + /// recorded on the plan so startup hydration restores the status under + /// the id the chat UI actually reads. + #[tokio::test] + async fn chat_message_id_is_accepted_and_persisted_on_the_plan() { + let source = expanded_secret(5); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + )]); + + let claimed = rig + .service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .unwrap(); + assert_eq!(claimed, 20); + let plan = rig.plans.0.lock().get(&memo_key).unwrap().clone(); + assert_eq!(plan.message_id.as_deref(), Some("chat-message-1")); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + } + + #[tokio::test] + async fn existing_landed_destination_recovers_without_resubmission() { + let source = expanded_secret(6); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([]); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + rig.backend.state.lock().insert( + recipient, + OnChainCoin { + exponent: 1, + age: 3, + }, + ); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some(external_claim_message_id(&memo_key)), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Error, + claimed_amount: None, + total_value: 20, + markers: ClaimMarkers::default(), + }, + ); + + rig.service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap(); + assert!(rig.backend.transfers.lock().is_empty()); + let coins = rig.coins.list().await.unwrap(); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].age, Some(3)); + assert_eq!( + rig.plans.0.lock().get(&memo_key).unwrap().status, + ClaimPlanStatus::Finished + ); + } + + #[tokio::test] + async fn prepared_claim_is_durable_without_spending_and_survives_restart() { + let source = expanded_secret(8); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + )]); + let plan = rig + .service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + assert!(rig.backend.transfers.lock().is_empty()); + assert_eq!(rig.plans.plan(&key).await.unwrap(), Some(plan.clone())); + let restarted = ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + rig.coins.clone(), + rig.plans.clone(), + rig.backend.clone(), + ); + assert_eq!( + restarted + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(), + plan + ); + assert_eq!( + restarted + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 20 + }, + external_claim_message_id(&key), + ) + .await + .unwrap(), + 20 + ); + rig.backend.state.lock().clear(); + assert_eq!( + restarted + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 20 + ); + assert_eq!(rig.backend.transfers.lock().len(), 1); + } + + #[tokio::test] + async fn source_spent_elsewhere_after_planning_finishes_with_zero_credit() { + let source = expanded_secret(9); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + )]); + rig.service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + // The sender spends the source before this wallet submits anything. + rig.backend.state.lock().clear(); + assert_eq!( + rig.service + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 20 + }, + external_claim_message_id(&key) + ) + .await + .unwrap(), + 0 + ); + let plan = rig.plans.plan(&key).await.unwrap().unwrap(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.claimed_amount, Some(20)); + assert_eq!(plan.markers.forfeited, vec![0]); + assert_eq!(plan.credited_amount(), Some(0)); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.coins.list().await.unwrap().is_empty()); + // The terminal outcome is stable across retries. + assert_eq!( + rig.service + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 0 + ); + } + + #[tokio::test] + async fn one_source_spent_elsewhere_credits_only_what_landed() { + let first = expanded_secret(21); + let second = expanded_secret(22); + let memo = TransferMemo { + entries: vec![first.clone(), second.clone()], + total_value: 60, + }; + let key = memo.identifier(); + let rig = rig([ + ( + public(&first), + OnChainCoin { + exponent: 1, + age: 2, + }, + ), + ( + public(&second), + OnChainCoin { + exponent: 2, + age: 2, + }, + ), + ]); + rig.service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + rig.backend.state.lock().remove(&public(&second)); + assert_eq!( + rig.service + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 20 + ); + let plan = rig.plans.plan(&key).await.unwrap().unwrap(); + assert_eq!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.markers.submitted, vec![0]); + assert_eq!(plan.markers.forfeited, vec![1]); + assert_eq!(plan.markers.forfeited_value, 40); + assert_eq!(plan.credited_amount(), Some(20)); + assert_eq!(rig.coins.list().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn unobserved_own_transfer_stays_ambiguous_across_restart() { + let source = expanded_secret(23); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + let rig = rig([( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + )]); + *rig.backend.lose_transfers.lock() = true; + assert!( + rig.service + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 20 + }, + external_claim_message_id(&key) + ) + .await + .is_err() + ); + let plan = rig.plans.plan(&key).await.unwrap().unwrap(); + assert_eq!(plan.markers.submitted, vec![0]); + assert!(plan.markers.forfeited.is_empty()); + // Source consumed, destination unobserved: never a forfeit, even after + // a restart reads the durable marker back. + let restarted = ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + rig.coins.clone(), + rig.plans.clone(), + rig.backend.clone(), + ); + assert!( + restarted + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .is_err() + ); + let plan = rig.plans.plan(&key).await.unwrap().unwrap(); + assert_ne!(plan.status, ClaimPlanStatus::Finished); + assert_eq!(plan.markers.submitted, vec![0]); + assert!(plan.markers.forfeited.is_empty()); + assert_eq!(plan.claimed_amount, None); + } + + #[tokio::test] + async fn absent_source_before_planning_records_nothing() { + let source = expanded_secret(25); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let key = memo.identifier(); + // Unfunded and spent-elsewhere look the same before any observation. + let rig = rig(std::iter::empty()); + assert!( + rig.service + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .is_err() + ); + assert!(rig.plans.plan(&key).await.unwrap().is_none()); + } + + #[tokio::test] + async fn partial_finalized_prefix_survives_spent_destination_and_retry() { + let first = expanded_secret(14); + let second = expanded_secret(15); + let memo = TransferMemo { + entries: vec![first.clone(), second.clone()], + total_value: 60, + }; + let key = memo.identifier(); + let rig = rig([ + ( + public(&first), + OnChainCoin { + exponent: 1, + age: 2, + }, + ), + ( + public(&second), + OnChainCoin { + exponent: 2, + age: 2, + }, + ), + ]); + let plan = rig + .service + .prepare_memo(&memo, external_claim_message_id(&key)) + .await + .unwrap(); + let destination = CoinKeypairFactory::new(&[0x44; 16]) + .public_key(plan.entries[0].derivation_index) + .unwrap(); + // Entry 1's transfer was submitted but not yet observed, so its + // absence stays ambiguous rather than a forfeit. + let mut submitted = plan.clone(); + submitted.markers.submitted = vec![1]; + rig.plans.save(&submitted).await.unwrap(); + { + let mut state = rig.backend.state.lock(); + state.remove(&public(&first)); + state.remove(&public(&second)); + state.insert( + destination, + OnChainCoin { + exponent: 1, + age: 0, + }, + ); + } + assert!( + rig.service + .claim_external_memo( + TransferMemo { + entries: memo.entries.clone(), + total_value: 60 + }, + external_claim_message_id(&key), + ) + .await + .is_err() + ); + assert_eq!( + rig.plans.plan(&key).await.unwrap().unwrap().claimed_amount, + Some(20) + ); + { + let mut state = rig.backend.state.lock(); + state.remove(&destination); + state.insert( + public(&second), + OnChainCoin { + exponent: 2, + age: 2, + }, + ); + } + let restarted = ExternalSecretClaimService::new( + &[0x44; 16], + Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + rig.coins.clone(), + rig.plans.clone(), + rig.backend.clone(), + ); + assert_eq!( + restarted + .claim_external_memo(memo, external_claim_message_id(&key)) + .await + .unwrap(), + 60 + ); + assert_eq!( + rig.backend + .transfers + .lock() + .iter() + .map(|transfer| transfer.source) + .collect::>(), + vec![public(&second)] + ); + assert_eq!( + rig.plans.plan(&key).await.unwrap().unwrap().status, + ClaimPlanStatus::Finished + ); + } + + /// At one finalized snapshot, both keys present is a destination collision, + /// not proof this memo's source was transferred. + #[tokio::test] + async fn landed_destination_with_unconsumed_source_never_clears() { + let source = expanded_secret(10); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + let rig = rig([ + ( + public(&source), + OnChainCoin { + exponent: 1, + age: 2, + }, + ), + ( + recipient, + OnChainCoin { + exponent: 1, + age: 5, + }, + ), + ]); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some("chat-message-1".into()), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Processing, + claimed_amount: None, + total_value: 20, + markers: ClaimMarkers::default(), + }, + ); + + assert!( + rig.service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .is_err() + ); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.coins.list().await.unwrap().is_empty()); + assert_eq!( + rig.plans + .plan(&memo_key) + .await + .unwrap() + .unwrap() + .claimed_amount, + None + ); + } + + /// A plan persisted before chat claims carried chat message ids keeps + /// working when the claim now arrives under the chat row's id. + #[tokio::test] + async fn legacy_external_id_plan_accepts_the_chat_message_id() { + let source = expanded_secret(11); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let rig = rig([]); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + rig.backend.state.lock().insert( + recipient, + OnChainCoin { + exponent: 1, + age: 3, + }, + ); + rig.plans.0.lock().insert( + memo_key, + ClaimPlan { + memo_key, + message_id: Some(external_claim_message_id(&memo_key)), + entries: vec![CodableClaimPlanEntry { + entry_index: 0, + exponent: 1, + derivation_index: 0, + }], + outgoing_public_keys: Vec::new(), + detection_anchor: None, + status: ClaimPlanStatus::Error, + claimed_amount: None, + total_value: 20, + markers: ClaimMarkers::default(), + }, + ); + + let claimed = rig + .service + .claim_external_memo(memo, "chat-message-1".into()) + .await + .unwrap(); + assert_eq!(claimed, 20); + assert_eq!( + rig.plans.0.lock().get(&memo_key).unwrap().status, + ClaimPlanStatus::Finished, + "a persisted error heals once the destination is proven" + ); + } + + #[tokio::test] + async fn source_and_recipient_presence_collision_fails_closed() { + let source = expanded_secret(7); + let memo = TransferMemo { + entries: vec![source.clone()], + total_value: 20, + }; + let memo_key = memo.identifier(); + let recipient = CoinKeypairFactory::new(&[0x44; 16]).public_key(0).unwrap(); + let rig = rig([ + ( + public(&source), + OnChainCoin { + exponent: 1, + age: 0, + }, + ), + ( + recipient, + OnChainCoin { + exponent: 1, + age: 0, + }, + ), + ]); + + let error = rig + .service + .claim_external_memo(memo, external_claim_message_id(&memo_key)) + .await + .unwrap_err(); + assert!(error.contains("destination already exists")); + assert!(rig.backend.transfers.lock().is_empty()); + assert!(rig.plans.0.lock().is_empty()); + } + + #[tokio::test] + async fn spent_recovery_restores_max_age_and_reuses_the_claim_transfer_lane() { + let factory = CoinKeypairFactory::new(&[0x44; 16]); + let max_age = Coin { + exponent: 1, + derivation_index: 3, + age: Some(1), + state: CoinState::Spent, + }; + let transferable = Coin { + exponent: 2, + derivation_index: 4, + age: Some(1), + state: CoinState::Spent, + }; + let absent = Coin { + exponent: 0, + derivation_index: 5, + age: Some(1), + state: CoinState::Spent, + }; + let rig = rig([ + ( + factory.public_key(max_age.derivation_index).unwrap(), + OnChainCoin { + exponent: max_age.exponent, + age: COIN_MAX_AGE, + }, + ), + ( + factory.public_key(transferable.derivation_index).unwrap(), + OnChainCoin { + exponent: transferable.exponent, + age: COIN_MAX_AGE - 1, + }, + ), + ]); + for coin in [&max_age, &transferable, &absent] { + rig.coins.upsert(coin).await.unwrap(); + } + let recovery = SpentCoinTransferRecoveryService::new( + &[0x44; 16], + Arc::clone(&rig.coins) as Arc<_>, + Arc::clone(&rig.backend) as Arc<_>, + Arc::clone(&rig.service), + ); + + let report = recovery + .recover(vec![max_age.clone(), transferable.clone(), absent]) + .await + .unwrap(); + assert_eq!( + report, + SpentCoinTransferRecoveryReport { + recovered_count: 2, + recovered_planks: 60, + restored_max_age_count: 1, + transferred_count: 1, + } + ); + assert_eq!(rig.backend.transfers.lock().len(), 1); + let coins = rig.coins.list().await.unwrap(); + assert_eq!( + coins + .iter() + .find(|coin| coin.derivation_index == max_age.derivation_index) + .unwrap() + .state, + CoinState::Available + ); + assert_eq!( + coins + .iter() + .find(|coin| coin.derivation_index == transferable.derivation_index) + .unwrap() + .state, + CoinState::Spent + ); + assert!( + coins.iter().any(|coin| coin.derivation_index == 0 + && coin.exponent == transferable.exponent + && coin.state == CoinState::Available), + "the younger source lands in the allocator's fresh destination" + ); + } + + #[tokio::test] + async fn spent_recovery_rejects_non_spent_inputs_before_querying() { + let rig = rig([]); + let recovery = SpentCoinTransferRecoveryService::new( + &[0x44; 16], + Arc::clone(&rig.coins) as Arc<_>, + Arc::clone(&rig.backend) as Arc<_>, + Arc::clone(&rig.service), + ); + let error = recovery + .recover(vec![Coin { + exponent: 0, + derivation_index: 0, + age: Some(0), + state: CoinState::Available, + }]) + .await + .unwrap_err(); + assert!(error.contains("non-spent")); + assert!(rig.backend.transfers.lock().is_empty()); + } +} diff --git a/rust/crates/truapi-coinage/src/selection.rs b/rust/crates/truapi-coinage/src/selection.rs new file mode 100644 index 000000000..990a32c2a --- /dev/null +++ b/rust/crates/truapi-coinage/src/selection.rs @@ -0,0 +1,911 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use crate::denomination::{Denomination, DenominationBreakdownContext}; +use crate::model::{Coin, EffectivePrivacy, Voucher, VoucherRemoteState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivacyLevel { + Full, + Degraded, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoinSelectionError { + ZeroAmount, + EmptyWallet, + /// The amount does not decompose exactly into denominations at this + /// context's granularity (below `min_exponent`). + AmountNotRepresentable { + remainder: u128, + }, + /// Vouchers exist but none are ready — distinct from + /// `InsufficientFunds` so the UI can show "wait for maturity" + NoReadyVouchers, + TooManyVouchersInGroup { + count: usize, + max: usize, + }, + InsufficientFunds, +} + +impl std::fmt::Display for CoinSelectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CoinSelectionError::ZeroAmount => write!(f, "amount must be greater than zero"), + CoinSelectionError::EmptyWallet => write!(f, "no coins or vouchers available"), + CoinSelectionError::AmountNotRepresentable { remainder } => { + write!( + f, + "amount not representable ({remainder} planks below the smallest denomination)" + ) + } + CoinSelectionError::NoReadyVouchers => write!(f, "vouchers are not ready yet"), + CoinSelectionError::TooManyVouchersInGroup { count, max } => { + write!(f, "recycler group holds {count} vouchers, maximum {max}") + } + CoinSelectionError::InsufficientFunds => write!(f, "insufficient funds"), + } + } +} + +impl std::error::Error for CoinSelectionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RecyclerKey { + pub exponent: i16, + pub index: u32, +} + +/// One unload group: all selected vouchers of one recycler, with the +/// group's output split into recipient + change denominations whose +/// combined value always equals the group's total voucher input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherGroup { + pub recycler: RecyclerKey, + pub vouchers: Vec, + pub recipient_denominations: Vec, + pub change_denominations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransferStrategy { + /// Whole coins pass to the recipient via the memo; nothing on-chain. + ExactMatch { coins: Vec }, + /// `partial_coins` pass whole; `split_coin` is split into + /// `target_denominations` (recipient) + `change_denominations` + /// (sender), signed with the coin's own key (AsCoin origin). + Split { + partial_coins: Vec, + split_coin: Coin, + target_denominations: Vec, + change_denominations: Vec, + }, + /// `coins` pass whole; each group is one + /// `UnloadRecyclerIntoCoins` extrinsic (Ring-VRF origin). + UnloadIntoCoins { + coins: Vec, + groups: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoinSelectionResult { + pub strategy: TransferStrategy, + pub privacy_level: PrivacyLevel, +} + +pub fn find_exact_match( + amount: u128, + coins: &[Coin], + context: &DenominationBreakdownContext, +) -> Option> { + let (selected, remaining) = greedy_take(amount, coins, context); + (remaining == 0).then_some(selected) +} + +/// Greedy largest-first pass: takes coins whose value fits the remaining +/// amount; returns the take and what is left uncovered. +fn greedy_take( + amount: u128, + coins: &[Coin], + context: &DenominationBreakdownContext, +) -> (Vec, u128) { + let mut pool: Vec<&Coin> = coins.iter().filter(|c| c.is_selectable()).collect(); + pool.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(b.age.unwrap_or(-1).cmp(&a.age.unwrap_or(-1))) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut remaining = amount; + let mut selected = Vec::new(); + for coin in pool { + let value = context.value_in_planks(coin.exponent); + if value <= remaining { + remaining -= value; + selected.push(coin.clone()); + } + } + (selected, remaining) +} + +pub struct CoinSelector { + context: DenominationBreakdownContext, + max_consolidation: usize, +} + +impl CoinSelector { + pub fn new(context: DenominationBreakdownContext, max_consolidation: usize) -> Self { + Self { + context, + max_consolidation, + } + } + + /// Runs the three strategies in priority order. `now_ms` drives + /// voucher effective-privacy evaluation. + pub fn select( + &self, + amount: u128, + coins: &[Coin], + vouchers: &[Voucher], + now_ms: i64, + ) -> Result { + if amount == 0 { + return Err(CoinSelectionError::ZeroAmount); + } + let unloadable: Vec<&Voucher> = vouchers.iter().filter(|v| v.is_unloadable()).collect(); + let any_available_coin = coins + .iter() + .any(|c| c.state == crate::model::CoinState::Available); + if !any_available_coin && unloadable.is_empty() { + return Err(CoinSelectionError::EmptyWallet); + } + let amount_breakdown = self.context.breakdown(amount); + if !amount_breakdown.is_exact() { + return Err(CoinSelectionError::AmountNotRepresentable { + remainder: amount_breakdown.remainder, + }); + } + + if let Some(selected) = find_exact_match(amount, coins, &self.context) { + return Ok(CoinSelectionResult { + strategy: TransferStrategy::ExactMatch { coins: selected }, + privacy_level: PrivacyLevel::Full, + }); + } + + if let Some(result) = self.try_split_coin(amount, coins) { + return Ok(CoinSelectionResult { + strategy: result, + privacy_level: PrivacyLevel::Full, + }); + } + + let full_pool: Vec<&Voucher> = unloadable + .iter() + .copied() + .filter(|v| v.privacy == crate::model::VoucherPrivacyLevel::Full) + .collect(); + if let Some(strategy) = self.try_unload(amount, coins, &full_pool)? { + let privacy_level = strategy_privacy(&strategy, now_ms); + return Ok(CoinSelectionResult { + strategy, + privacy_level, + }); + } + + if unloadable.len() > full_pool.len() + && let Some(strategy) = self.try_unload(amount, coins, &unloadable)? + { + let privacy_level = strategy_privacy(&strategy, now_ms); + return Ok(CoinSelectionResult { + strategy, + privacy_level, + }); + } + + if !unloadable.is_empty() && full_pool.is_empty() { + return Err(CoinSelectionError::NoReadyVouchers); + } + Err(CoinSelectionError::InsufficientFunds) + } + + fn try_split_coin(&self, amount: u128, coins: &[Coin]) -> Option { + let mut selectable = coins + .iter() + .filter(|c| c.is_selectable()) + .cloned() + .collect::>(); + let sufficient = selectable + .iter() + .filter(|coin| self.context.value_in_planks(coin.exponent) > amount) + .min_by_key(|coin| { + ( + self.context.value_in_planks(coin.exponent), + coin.derivation_index, + ) + }) + .cloned(); + if let Some(split_coin) = sufficient { + return Some(self.split_strategy(Vec::new(), split_coin, amount)); + } + + selectable.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut partial = Vec::new(); + let mut accumulated = 0u128; + for coin in selectable { + let value = self.context.value_in_planks(coin.exponent); + let next = accumulated.saturating_add(value); + if next < amount { + partial.push(coin); + accumulated = next; + continue; + } + return Some(self.split_strategy(partial, coin, amount - accumulated)); + } + None + } + + fn split_strategy( + &self, + partial: Vec, + split_coin: Coin, + remaining: u128, + ) -> TransferStrategy { + let coin_value = self.context.value_in_planks(split_coin.exponent); + let target = self.context.breakdown(remaining); + let change = self.context.breakdown(coin_value - remaining); + debug_assert!( + target.is_exact() && change.is_exact(), + "powers of two split exactly" + ); + TransferStrategy::Split { + partial_coins: partial, + split_coin, + target_denominations: target.denominations, + change_denominations: change.denominations, + } + } + + fn try_unload( + &self, + amount: u128, + coins: &[Coin], + pool: &[&Voucher], + ) -> Result, CoinSelectionError> { + if pool.is_empty() { + return Ok(None); + } + let (partial, needed) = greedy_take(amount, coins, &self.context); + debug_assert!(needed > 0); + + let chosen: Vec<&Voucher> = match pool + .iter() + .filter(|v| self.context.value_in_planks(v.exponent) >= needed) + .min_by_key(|v| (self.context.value_in_planks(v.exponent), v.derivation_index)) + { + Some(single) => vec![single], + None => { + // Greedy largest-first accumulation until covered. + let mut sorted: Vec<&Voucher> = pool.to_vec(); + sorted.sort_by(|a, b| { + b.exponent + .cmp(&a.exponent) + .then(a.derivation_index.cmp(&b.derivation_index)) + }); + let mut sum = 0u128; + let mut chosen = Vec::new(); + for voucher in sorted { + if sum >= needed { + break; + } + sum = sum.saturating_add(self.context.value_in_planks(voucher.exponent)); + chosen.push(voucher); + } + if sum < needed { + return Ok(None); + } + chosen + } + }; + + let mut groups: Vec<(RecyclerKey, Vec)> = Vec::new(); + for voucher in chosen { + let VoucherRemoteState::InRecycler { recycler_index } = voucher.remote_state else { + // is_unloadable filtered already; defensive. + continue; + }; + let key = RecyclerKey { + exponent: voucher.exponent, + index: recycler_index, + }; + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, members)) => members.push(voucher.clone()), + None => groups.push((key, vec![voucher.clone()])), + } + } + + groups.sort_by(|(left, _), (right, _)| { + right + .exponent + .cmp(&left.exponent) + .then(left.index.cmp(&right.index)) + }); + + let mut needed_left = needed; + let mut allocated = Vec::with_capacity(groups.len()); + for (recycler, members) in groups { + if members.len() >= self.max_consolidation { + return Err(CoinSelectionError::TooManyVouchersInGroup { + count: members.len(), + max: self.max_consolidation, + }); + } + let input: u128 = members.iter().fold(0u128, |acc, v| { + acc.saturating_add(self.context.value_in_planks(v.exponent)) + }); + let recipient_amount = needed_left.min(input); + needed_left -= recipient_amount; + let recipient = self.context.breakdown(recipient_amount); + let change = self.context.breakdown(input - recipient_amount); + debug_assert!(recipient.is_exact() && change.is_exact()); + allocated.push(VoucherGroup { + recycler, + vouchers: members, + recipient_denominations: recipient.denominations, + change_denominations: change.denominations, + }); + } + debug_assert_eq!(needed_left, 0); + Ok(Some(TransferStrategy::UnloadIntoCoins { + coins: partial, + groups: allocated, + })) + } +} + +fn strategy_privacy(strategy: &TransferStrategy, now_ms: i64) -> PrivacyLevel { + match strategy { + TransferStrategy::UnloadIntoCoins { groups, .. } + if groups + .iter() + .flat_map(|group| &group.vouchers) + .any(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Degraded) => + { + PrivacyLevel::Degraded + } + _ => PrivacyLevel::Full, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{CoinState, VoucherLocalState, VoucherPrivacyLevel}; + + /// Unit 10, exponents 0..=4 → coin values 10..160. + fn ctx() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 10, + max_exponent: 4, + min_exponent: 0, + precision: 10, + } + } + + fn selector() -> CoinSelector { + CoinSelector::new(ctx(), 8) + } + + fn coin(index: u32, exponent: i16, age: Option) -> Coin { + Coin { + exponent, + derivation_index: index, + age, + state: CoinState::Available, + } + } + + fn voucher( + index: u32, + exponent: i16, + recycler: u32, + privacy: VoucherPrivacyLevel, + ready_at_ms: i64, + ) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms, + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + local_state: VoucherLocalState::Available, + privacy, + } + } + + fn full_voucher(index: u32, exponent: i16) -> Voucher { + voucher(index, exponent, 0, VoucherPrivacyLevel::Full, 0) + } + + const NOW: i64 = 1_000_000; + + #[test] + fn zero_amount_is_rejected() { + assert_eq!( + selector().select(0, &[coin(1, 0, None)], &[], NOW), + Err(CoinSelectionError::ZeroAmount) + ); + } + + #[test] + fn empty_wallet_is_rejected() { + assert_eq!( + selector().select(10, &[], &[], NOW), + Err(CoinSelectionError::EmptyWallet) + ); + // A wallet of only spent coins is empty too. + let spent = Coin { + state: CoinState::Spent, + ..coin(1, 4, None) + }; + assert_eq!( + selector().select(10, &[spent], &[], NOW), + Err(CoinSelectionError::EmptyWallet) + ); + } + + #[test] + fn sub_denomination_amount_is_rejected() { + assert_eq!( + selector().select(15, &[coin(1, 4, None)], &[], NOW), + Err(CoinSelectionError::AmountNotRepresentable { remainder: 5 }) + ); + } + + #[test] + fn exact_match_selects_minimum_coins_largest_first() { + let coins = [ + coin(1, 0, None), + coin(2, 1, None), + coin(3, 2, None), + coin(4, 4, None), + ]; + // 230 = 160 + 40 + 20 + 10 — all four; 200 = 160 + 40. + let result = selector().select(200, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!( + selected + .iter() + .map(|c| c.derivation_index) + .collect::>(), + [4, 3] + ); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + } + + #[test] + fn exact_match_prefers_older_coins_within_a_denomination() { + let coins = [coin(1, 0, Some(2)), coin(2, 0, Some(9)), coin(3, 0, None)]; + let result = selector().select(10, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!(selected[0].derivation_index, 2, "age 9 spends before age 2"); + } + + #[test] + fn expiring_coins_never_enter_selection() { + let coins = [coin(1, 0, Some(14)), coin(2, 0, Some(13))]; + // 20 would need both — the expiring one is invisible. + assert_eq!( + selector().select(20, &coins, &[], NOW), + Err(CoinSelectionError::InsufficientFunds) + ); + // 10 matches with the young coin only. + let result = selector().select(10, &coins, &[], NOW).unwrap(); + let TransferStrategy::ExactMatch { coins: selected } = result.strategy else { + panic!("expected exact match"); + }; + assert_eq!(selected[0].derivation_index, 2); + } + + #[test] + fn find_exact_match_returns_none_when_no_exact_subset_exists() { + assert!(find_exact_match(30, &[coin(1, 2, None)], &ctx()).is_none()); + assert!(find_exact_match(30, &[coin(1, 1, None), coin(2, 0, None)], &ctx()).is_some()); + } + + #[test] + fn split_picks_smallest_coin_larger_than_the_remainder() { + // Amount 30: no exact match from {160, 80}; split the 80? No — + // smallest coin larger than 30 is 80 (overshoot 50) vs 160 + // (overshoot 130) → split coin 2 (exp 3). + let coins = [coin(1, 4, None), coin(2, 3, None)]; + let result = selector().select(30, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } = result.strategy + else { + panic!("expected split"); + }; + assert!(partial_coins.is_empty()); + assert_eq!(split_coin.derivation_index, 2); + // target 30 = 20 + 10; change 50 = 40 + 10. + assert_eq!( + target_denominations, + [Denomination { exponent: 1 }, Denomination { exponent: 0 }] + ); + assert_eq!( + change_denominations, + [Denomination { exponent: 2 }, Denomination { exponent: 0 }] + ); + // Split invariant: partial + target == amount; target + change == coin. + assert_eq!(ctx().total_value(&target_denominations), 30); + assert_eq!(ctx().total_value(&change_denominations), 50); + } + + /// Smaller whole coins must not be added when one coin can fund the full + /// split. + #[test] + fn split_uses_one_smallest_coin_larger_than_the_whole_request() { + let coins = [ + coin(1, 4, None), // 160 + coin(2, 2, None), // 40 + coin(3, 0, None), // 10 + ]; + let result = selector().select(30, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + .. + } = result.strategy + else { + panic!("expected split"); + }; + assert!(partial_coins.is_empty()); + assert_eq!(split_coin.derivation_index, 2); + } + + #[test] + fn split_takes_a_partial_contribution_first() { + // Amount 50: greedy takes the 40 (exp 2), remainder 10 needs a + // split of the 20 (exp 1) into 10 + 10. + let coins = [coin(1, 2, None), coin(2, 1, None)]; + let result = selector().select(50, &coins, &[], NOW).unwrap(); + let TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } = result.strategy + else { + panic!("expected split"); + }; + assert_eq!(partial_coins[0].derivation_index, 1); + assert_eq!(split_coin.derivation_index, 2); + assert_eq!(ctx().total_value(&target_denominations), 10); + assert_eq!(ctx().total_value(&change_denominations), 10); + } + + #[test] + fn unload_prefers_single_smallest_sufficient_voucher() { + let coins: [Coin; 0] = []; + let vouchers = [full_voucher(1, 4), full_voucher(2, 2), full_voucher(3, 3)]; + // Need 40: the exp-2 voucher (value 40) is the minimal cover — + // not the 80, not the 160, not a combination. + let result = selector().select(40, &coins, &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } = result.strategy + else { + panic!("expected unload"); + }; + assert!(partial.is_empty()); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].vouchers[0].derivation_index, 2); + assert_eq!(ctx().total_value(&groups[0].recipient_denominations), 40); + assert!(groups[0].change_denominations.is_empty()); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + } + + #[test] + fn unload_groups_by_recycler_and_balances_output_to_input() { + let vouchers = [ + voucher(1, 2, 7, VoucherPrivacyLevel::Full, 0), // 40, recycler (2,7) + voucher(2, 2, 9, VoucherPrivacyLevel::Full, 0), // 40, recycler (2,9) + ]; + // Need 60 → both vouchers (no single is sufficient): group (2,7) + // gives 40 to the recipient, group (2,9) gives 20 + 20 change. + let result = selector().select(60, &[], &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { groups, .. } = result.strategy else { + panic!("expected unload"); + }; + assert_eq!(groups.len(), 2); + for group in &groups { + let input: u128 = group + .vouchers + .iter() + .map(|v| ctx().value_in_planks(v.exponent)) + .sum(); + let output = ctx().total_value(&group.recipient_denominations) + + ctx().total_value(&group.change_denominations); + assert_eq!(input, output, "pallet invariant: group output == input"); + } + let recipient_total: u128 = groups + .iter() + .map(|g| ctx().total_value(&g.recipient_denominations)) + .sum(); + assert_eq!(recipient_total, 60); + } + + #[test] + fn unload_groups_are_ordered_by_descending_exponent() { + let vouchers = [ + voucher(1, 0, 7, VoucherPrivacyLevel::Full, 0), // 10 + voucher(2, 2, 9, VoucherPrivacyLevel::Full, 0), // 40 + ]; + let result = selector().select(50, &[], &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { groups, .. } = result.strategy else { + panic!("expected unload"); + }; + assert_eq!( + groups + .iter() + .map(|group| group.recycler.exponent) + .collect::>(), + [2, 0] + ); + } + + #[test] + fn unload_uses_partial_coins_before_vouchers() { + let coins = [coin(1, 1, None)]; // 20 + let vouchers = [full_voucher(10, 2)]; // 40 + // Need 60 = coin 20 + voucher 40. + let result = selector().select(60, &coins, &vouchers, NOW).unwrap(); + let TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } = result.strategy + else { + panic!("expected unload"); + }; + assert_eq!(partial[0].derivation_index, 1); + assert_eq!(groups[0].vouchers[0].derivation_index, 10); + } + + #[test] + fn degraded_fallback_triggers_when_full_privacy_is_insufficient() { + let vouchers = [ + full_voucher(1, 1), // 20 full + voucher(2, 2, 0, VoucherPrivacyLevel::Degraded, 0), // 40 degraded + ]; + // 20 is coverable full-privacy → Full. + let result = selector().select(20, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Full); + // 60 needs the degraded voucher too → Degraded. + let result = selector().select(60, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Degraded); + } + + #[test] + fn not_yet_ready_full_voucher_counts_as_degraded() { + let vouchers = [voucher(1, 1, 0, VoucherPrivacyLevel::Full, NOW + 1)]; + let result = selector().select(20, &[], &vouchers, NOW).unwrap(); + assert_eq!(result.privacy_level, PrivacyLevel::Degraded); + } + + #[test] + fn no_ready_vouchers_error_when_pool_is_all_unready() { + let vouchers = [voucher(1, 0, 0, VoucherPrivacyLevel::Degraded, 0)]; // 10 + assert_eq!( + selector().select(160, &[], &vouchers, NOW), + Err(CoinSelectionError::NoReadyVouchers) + ); + } + + #[test] + fn too_many_vouchers_in_group_is_a_hard_stop() { + let tight = CoinSelector::new(ctx(), 2); + let vouchers: Vec = (0..3).map(|i| full_voucher(i, 0)).collect(); // 3 × 10, one recycler + assert_eq!( + tight.select(30, &[], &vouchers, NOW), + Err(CoinSelectionError::TooManyVouchersInGroup { count: 3, max: 2 }) + ); + } + + #[test] + fn voucher_group_equal_to_max_is_rejected_like_ios_v2() { + let tight = CoinSelector::new(ctx(), 2); + let vouchers = [full_voucher(1, 0), full_voucher(2, 0)]; + assert_eq!( + tight.select(20, &[], &vouchers, NOW), + Err(CoinSelectionError::TooManyVouchersInGroup { count: 2, max: 2 }) + ); + } + + #[test] + fn insufficient_funds_when_everything_together_cannot_cover() { + let coins = [coin(1, 0, None)]; + let vouchers = [full_voucher(2, 0)]; + assert_eq!( + selector().select(160, &coins, &vouchers, NOW), + Err(CoinSelectionError::InsufficientFunds) + ); + } + + /// Deterministic pseudo-random sweep: every outcome upholds the value + /// invariants, and all three strategy paths are exercised. + #[test] + fn random_sweep_covers_all_strategies_and_holds_invariants() { + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + let mut rng = StdRng::seed_from_u64(0x_C01_A6E); + let context = ctx(); + let selector = selector(); + let (mut exact, mut split, mut unload) = (0, 0, 0); + + for _ in 0..300 { + let coins: Vec = (0..rng.gen_range(0..6)) + .map(|i| { + coin( + i, + rng.gen_range(0..=4), + if rng.gen_bool(0.3) { + None + } else { + Some(rng.gen_range(0..16)) + }, + ) + }) + .collect(); + let vouchers: Vec = (0..rng.gen_range(0..5)) + .map(|i| { + voucher( + 100 + i, + rng.gen_range(0..=4), + rng.gen_range(0..3), + if rng.gen_bool(0.7) { + VoucherPrivacyLevel::Full + } else { + VoucherPrivacyLevel::Degraded + }, + if rng.gen_bool(0.8) { 0 } else { NOW + 1 }, + ) + }) + .collect(); + let amount = u128::from(rng.gen_range(1..60u32)) * 10; + + let first = selector.select(amount, &coins, &vouchers, NOW); + assert_eq!( + first, + selector.select(amount, &coins, &vouchers, NOW), + "identical snapshots must select deterministically" + ); + + match first { + Ok(result) => match result.strategy { + TransferStrategy::ExactMatch { coins: selected } => { + let unique: std::collections::HashSet<_> = + selected.iter().map(|coin| coin.derivation_index).collect(); + assert_eq!(unique.len(), selected.len(), "coin inputs are unique"); + exact += 1; + let total: u128 = selected + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + assert_eq!(total, amount); + assert!(selected.iter().all(|c| c.is_selectable())); + } + TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } => { + split += 1; + let mut unique: std::collections::HashSet<_> = partial_coins + .iter() + .map(|coin| coin.derivation_index) + .collect(); + assert!( + unique.insert(split_coin.derivation_index), + "the split coin cannot also be a partial input" + ); + assert!(partial_coins.iter().all(Coin::is_selectable)); + assert!(split_coin.is_selectable()); + let partial: u128 = partial_coins + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + assert_eq!( + partial + context.total_value(&target_denominations), + amount, + "partial + split target == amount" + ); + assert_eq!( + context.total_value(&target_denominations) + + context.total_value(&change_denominations), + context.value_in_planks(split_coin.exponent), + "split conserves the coin's value" + ); + } + TransferStrategy::UnloadIntoCoins { + coins: partial, + groups, + } => { + unload += 1; + let unique_coins: std::collections::HashSet<_> = + partial.iter().map(|coin| coin.derivation_index).collect(); + assert_eq!(unique_coins.len(), partial.len(), "coin inputs are unique"); + assert!(partial.iter().all(Coin::is_selectable)); + let mut unique_vouchers = std::collections::HashSet::new(); + let partial: u128 = partial + .iter() + .map(|c| context.value_in_planks(c.exponent)) + .sum(); + let recipient: u128 = groups + .iter() + .map(|g| context.total_value(&g.recipient_denominations)) + .sum(); + assert_eq!(partial + recipient, amount, "unload covers exactly"); + for group in &groups { + let input: u128 = group + .vouchers + .iter() + .map(|v| context.value_in_planks(v.exponent)) + .sum(); + assert_eq!( + input, + context.total_value(&group.recipient_denominations) + + context.total_value(&group.change_denominations) + ); + assert!( + group.vouchers.len() < 8, + "reference max-consolidation check is strict" + ); + assert!(group.vouchers.iter().all(Voucher::is_unloadable)); + assert!( + group.vouchers.iter().all(|voucher| { + unique_vouchers.insert(voucher.derivation_index) + }), + "voucher inputs are unique across recycler groups" + ); + } + } + }, + Err(error) => { + assert!( + matches!( + error, + CoinSelectionError::EmptyWallet + | CoinSelectionError::InsufficientFunds + | CoinSelectionError::NoReadyVouchers + | CoinSelectionError::TooManyVouchersInGroup { .. } + ), + "unexpected error class: {error:?}" + ); + } + } + } + assert!(exact > 10, "exact-match path exercised ({exact})"); + assert!(split > 10, "split path exercised ({split})"); + assert!(unload > 10, "unload path exercised ({unload})"); + } +} diff --git a/rust/crates/truapi-coinage/src/sync.rs b/rust/crates/truapi-coinage/src/sync.rs new file mode 100644 index 000000000..038fc20fd --- /dev/null +++ b/rust/crates/truapi-coinage/src/sync.rs @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::AbortHandle; +use tokio::sync::{mpsc, watch}; +use tracing::warn; + +use crate::model::{Coin, CoinState, Voucher, VoucherLocalState}; +use crate::repo::{CoinRepository, VoucherRepository}; + +/// One coin's on-chain reading (`CoinsByOwner` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OnChainCoin { + pub exponent: i16, + pub age: i16, +} + +/// One (possibly partial) emission: `None` means the coin's entry is +/// absent on-chain. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CoinStateUpdate { + pub coins: Vec<(u32, Option)>, +} + +/// The chain effect: ONE batched storage subscription over the given +/// derivation indices (key derivation happens inside the impl). +/// Re-calling replaces the previous subscription. +#[async_trait] +pub trait CoinStateSubscriber: Send + Sync { + async fn subscribe( + &self, + derivation_indices: Vec, + ) -> Result, String>; +} + +pub struct CoinStateSyncService { + spawner: crate::Spawner, + coins: Arc, + chain: Arc, + subscribed: Mutex>, + task: Mutex>, +} + +impl CoinStateSyncService { + pub fn new( + coins: Arc, + chain: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + coins, + chain, + subscribed: Mutex::new(Vec::new()), + spawner, + task: Mutex::new(None), + } + } + + /// (Re)builds the batch subscription over the monitored set. Call at + /// startup and whenever local coins change; the service also resyncs + /// itself when an applied update shrinks the monitored set. + pub async fn sync(self: &Arc) -> Result<(), String> { + let monitored = self.monitored_indices().await?; + if monitored.is_empty() { + self.stop(); + return Ok(()); + } + *self.subscribed.lock() = monitored.clone(); + let receiver = self.chain.subscribe(monitored).await?; + self.replace_task(receiver); + Ok(()) + } + + pub fn stop(&self) { + if let Some(handle) = self.task.lock().take() { + handle.abort(); + } + self.subscribed.lock().clear(); + } + + async fn monitored_indices(&self) -> Result, String> { + let mut indices: Vec = self + .coins + .list() + .await? + .into_iter() + .filter(|c| c.state != CoinState::Spent && c.age.is_none()) + .map(|c| c.derivation_index) + .collect(); + indices.sort_unstable(); + Ok(indices) + } + + fn replace_task(self: &Arc, mut receiver: mpsc::Receiver) { + let service = Arc::clone(self); + let handle = crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(update) = receiver.recv().await { + match service.handle_update(update).await { + Ok(true) => { + // The monitored set changed — rebuild. + let service = Arc::clone(&service); + crate::tasks::spawn_abortable(&service.spawner.clone(), async move { + if let Err(error) = service.sync().await { + warn!(error, "coin state resync failed"); + } + }); + return; + } + Ok(false) => {} + Err(error) => warn!(error, "coin state update failed"), + } + } + }); + let previous = self.task.lock().replace(handle); + if let Some(previous) = previous { + previous.abort(); + } + } + + /// Applies one emission; resolves `true` when the monitored set + /// changed (resync needed). + async fn handle_update(&self, update: CoinStateUpdate) -> Result { + let coins = self.coins.list().await?; + for (index, on_chain) in &update.coins { + let Some(coin) = coins + .iter() + .find(|c| c.derivation_index == *index && c.state != CoinState::Spent) + else { + continue; + }; + match on_chain { + Some(reading) => { + if coin.age != Some(reading.age) { + let mut updated = coin.clone(); + updated.age = Some(reading.age); + self.coins.upsert(&updated).await?; + } + } + None => { + if coin.age.is_some() { + self.coins + .set_state(coin.derivation_index, CoinState::Spent) + .await?; + } + } + } + } + let monitored = self.monitored_indices().await?; + Ok(monitored != *self.subscribed.lock()) + } +} + +/// [`CoinRepository`] decorator bumping a watch counter on every +/// successful mutation — the reactive change stream consumers re-fetch +/// on. +pub struct NotifyingCoinRepository { + inner: Arc, + changes: watch::Sender, +} + +impl NotifyingCoinRepository { + pub fn new(inner: Arc) -> Self { + Self { + inner, + changes: watch::channel(0).0, + } + } + + pub fn subscribe_changes(&self) -> watch::Receiver { + self.changes.subscribe() + } + + fn bump(&self) { + self.changes.send_modify(|generation| *generation += 1); + } + + pub fn notify_changed(&self) { + self.bump(); + } +} + +#[async_trait] +impl CoinRepository for NotifyingCoinRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + async fn upsert(&self, coin: &Coin) -> Result<(), String> { + self.inner.upsert(coin).await?; + self.bump(); + Ok(()) + } + async fn set_state(&self, derivation_index: u32, state: CoinState) -> Result<(), String> { + self.inner.set_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await?; + self.bump(); + Ok(()) + } +} + +/// [`VoucherRepository`] decorator with the same change stream. +pub struct NotifyingVoucherRepository { + inner: Arc, + changes: watch::Sender, +} + +impl NotifyingVoucherRepository { + pub fn new(inner: Arc) -> Self { + Self { + inner, + changes: watch::channel(0).0, + } + } + + pub fn subscribe_changes(&self) -> watch::Receiver { + self.changes.subscribe() + } + + fn bump(&self) { + self.changes.send_modify(|generation| *generation += 1); + } + + pub fn notify_changed(&self) { + self.bump(); + } +} + +#[async_trait] +impl VoucherRepository for NotifyingVoucherRepository { + async fn list(&self) -> Result, String> { + self.inner.list().await + } + async fn upsert(&self, voucher: &Voucher) -> Result<(), String> { + self.inner.upsert(voucher).await?; + self.bump(); + Ok(()) + } + async fn set_local_state( + &self, + derivation_index: u32, + state: VoucherLocalState, + ) -> Result<(), String> { + self.inner.set_local_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn set_remote_state( + &self, + derivation_index: u32, + state: crate::model::VoucherRemoteState, + ) -> Result<(), String> { + self.inner.set_remote_state(derivation_index, state).await?; + self.bump(); + Ok(()) + } + async fn remove(&self, derivation_index: u32) -> Result<(), String> { + self.inner.remove(derivation_index).await?; + self.bump(); + Ok(()) + } +} + +pub struct CoinageDatabaseDependencyFactory { + coins: Arc, + vouchers: Arc, +} + +impl CoinageDatabaseDependencyFactory { + pub fn new(coins: Arc, vouchers: Arc) -> Self { + Self { + coins: Arc::new(NotifyingCoinRepository::new(coins)), + vouchers: Arc::new(NotifyingVoucherRepository::new(vouchers)), + } + } + + pub fn coin_repository(&self) -> Arc { + Arc::clone(&self.coins) + } + + pub fn voucher_repository(&self) -> Arc { + Arc::clone(&self.vouchers) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + #[derive(Default)] + struct MockSubscriber { + calls: Mutex>>, + senders: Mutex>>, + } + + impl MockSubscriber { + fn latest_sender(&self) -> mpsc::Sender { + self.senders.lock().last().expect("no subscription").clone() + } + } + + #[async_trait] + impl CoinStateSubscriber for MockSubscriber { + async fn subscribe( + &self, + derivation_indices: Vec, + ) -> Result, String> { + self.calls.lock().push(derivation_indices); + let (tx, rx) = mpsc::channel(8); + self.senders.lock().push(tx); + Ok(rx) + } + } + + fn coin(index: u32, age: Option, state: CoinState) -> Coin { + Coin { + exponent: 1, + derivation_index: index, + age, + state, + } + } + + fn rig( + coins: Vec, + ) -> ( + Arc, + Arc, + Arc, + ) { + let repo = Arc::new(InMemoryCoinRepository::with_coins(coins)); + let subscriber = Arc::new(MockSubscriber::default()); + ( + Arc::new(CoinStateSyncService::new( + Arc::clone(&repo) as Arc, + Arc::clone(&subscriber) as Arc, + crate::test_spawner(), + )), + repo, + subscriber, + ) + } + + async fn read_coin(repo: &InMemoryCoinRepository, index: u32) -> Coin { + repo.list() + .await + .unwrap() + .into_iter() + .find(|c| c.derivation_index == index) + .unwrap() + } + + async fn settle(mut probe: impl AsyncFnMut() -> bool) { + for _ in 0..1_000 { + if probe().await { + return; + } + tokio::task::yield_now().await; + } + panic!("never settled"); + } + + #[tokio::test] + async fn age_lands_and_the_monitored_set_shrinks() { + let (service, repo, subscriber) = rig(vec![ + coin(1, None, CoinState::Available), + coin(2, Some(4), CoinState::Available), // known age — not monitored + coin(3, None, CoinState::Spent), // spent — not monitored + ]); + service.sync().await.unwrap(); + assert_eq!(subscriber.calls.lock().as_slice(), [vec![1]]); + + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![( + 1, + Some(OnChainCoin { + exponent: 1, + age: 0, + }), + )], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.age == Some(0)).await; + // Monitored set now empty → the resync stops the subscription. + settle(async || service.task.lock().is_none()).await; + } + + #[tokio::test] + async fn absence_spends_only_previously_observed_coins() { + let (service, repo, subscriber) = rig(vec![ + coin(1, None, CoinState::Available), + coin(2, None, CoinState::Available), + ]); + service.sync().await.unwrap(); + + // Coin 1 lands with an age… + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![( + 1, + Some(OnChainCoin { + exponent: 1, + age: 2, + }), + )], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.age == Some(2)).await; + settle(async || subscriber.calls.lock().len() == 2).await; + + // …then disappears (claimed by a recipient) while coin 2 is + // still absent because it never landed. + subscriber + .latest_sender() + .send(CoinStateUpdate { + coins: vec![(1, None), (2, None)], + }) + .await + .unwrap(); + settle(async || read_coin(&repo, 1).await.state == CoinState::Spent).await; + let unlanded = read_coin(&repo, 2).await; + assert_eq!( + unlanded.state, + CoinState::Available, + "unknown-age absence is 'not landed yet', never 'spent'" + ); + assert_eq!(unlanded.age, None); + } + + #[tokio::test] + async fn empty_monitored_set_never_subscribes() { + let (service, _repo, subscriber) = rig(vec![coin(1, Some(3), CoinState::Available)]); + service.sync().await.unwrap(); + assert!(subscriber.calls.lock().is_empty()); + assert!(service.task.lock().is_none()); + } + + #[tokio::test] + async fn factory_change_streams_fire_on_mutation() { + let factory = CoinageDatabaseDependencyFactory::new( + Arc::new(InMemoryCoinRepository::default()), + Arc::new(InMemoryVoucherRepository::default()), + ); + let coins = factory.coin_repository(); + let mut coin_changes = coins.subscribe_changes(); + coins + .upsert(&coin(1, None, CoinState::Available)) + .await + .unwrap(); + coin_changes.changed().await.unwrap(); + + let same = factory.coin_repository(); + assert_eq!( + same.list().await.unwrap().len(), + 1, + "same underlying instance" + ); + + let vouchers = factory.voucher_repository(); + let mut voucher_changes = vouchers.subscribe_changes(); + vouchers + .upsert(&Voucher { + exponent: 0, + derivation_index: 1, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: crate::model::VoucherRemoteState::Unlocated, + local_state: VoucherLocalState::Available, + privacy: crate::model::VoucherPrivacyLevel::Degraded, + }) + .await + .unwrap(); + voucher_changes.changed().await.unwrap(); + vouchers + .set_local_state(1, VoucherLocalState::Spent) + .await + .unwrap(); + voucher_changes.changed().await.unwrap(); + } +} diff --git a/rust/crates/truapi-coinage/src/tasks.rs b/rust/crates/truapi-coinage/src/tasks.rs new file mode 100644 index 000000000..1ef42039f --- /dev/null +++ b/rust/crates/truapi-coinage/src/tasks.rs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use futures::future::{AbortHandle, Abortable}; +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +/// Launch a cancellable future on the Host's executor. +pub(crate) fn spawn_abortable(spawner: &crate::Spawner, work: F) -> AbortHandle +where + F: Future + Send + 'static, +{ + let (abort, registration) = AbortHandle::new_pair(); + spawner(Box::pin(async move { + let _ = Abortable::new(work, registration).await; + })); + abort +} + +/// Per-message task ownership with cancellation and duplicate suppression. +pub struct ActiveTaskRegistry { + spawner: crate::Spawner, + state: Mutex, +} + +#[derive(Default)] +struct RegistryState { + next_generation: u64, + tasks: HashMap, +} + +impl ActiveTaskRegistry { + /// Bind background work to the owning Host executor. + pub fn new(spawner: crate::Spawner) -> Self { + Self { + spawner, + state: Mutex::new(RegistryState::default()), + } + } + + /// Start only if this message does not already have a live task. + pub fn try_start(self: &Arc, message_id: &str, work: F) -> bool + where + F: Future + Send + 'static, + { + let mut state = self.state.lock(); + if state.tasks.contains_key(message_id) { + return false; + } + let generation = state.next_generation; + state.next_generation = generation.checked_add(1).expect("task generation overflow"); + let (abort, registration) = AbortHandle::new_pair(); + let id = message_id.to_owned(); + state.tasks.insert(id.clone(), (generation, abort)); + drop(state); + // Construct outside the future so cancellation before its first poll + // still releases the id. An older cancelled task cannot remove a restart. + let guard = RemoveOnDrop { + registry: Arc::clone(self), + id, + generation, + }; + (self.spawner)(Box::pin( + Abortable::new( + async move { + let _guard = guard; + work.await; + }, + registration, + ) + .map(|_| ()), + )); + true + } + + /// Whether work currently owns this message id. + pub fn is_tracked(&self, message_id: &str) -> bool { + self.state.lock().tasks.contains_key(message_id) + } + + /// Number of live message tasks. + pub fn len(&self) -> usize { + self.state.lock().tasks.len() + } + + /// Whether no message is currently owned. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Cancel all work and make the message ids available for restart. + pub fn cancel_all(&self) { + let handles: Vec<_> = self + .state + .lock() + .tasks + .drain() + .map(|(_, (_, handle))| handle) + .collect(); + for handle in handles { + handle.abort(); + } + } +} + +use futures::FutureExt; + +struct RemoveOnDrop { + registry: Arc, + id: String, + generation: u64, +} + +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let mut state = self.registry.state.lock(); + if state + .tasks + .get(&self.id) + .is_some_and(|(generation, _)| *generation == self.generation) + { + state.tasks.remove(&self.id); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::watch; + + use super::*; + + async fn settle(registry: &ActiveTaskRegistry, until: impl Fn(&ActiveTaskRegistry) -> bool) { + for _ in 0..1_000 { + if until(registry) { + return; + } + tokio::task::yield_now().await; + } + panic!("registry never settled"); + } + + #[tokio::test] + async fn duplicate_starts_are_rejected_until_completion() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let (release_tx, release_rx) = watch::channel(false); + let runs = Arc::new(AtomicUsize::new(0)); + + let work = { + let runs = Arc::clone(&runs); + let mut release = release_rx.clone(); + async move { + runs.fetch_add(1, Ordering::SeqCst); + while !*release.borrow() { + if release.changed().await.is_err() { + return; + } + } + } + }; + assert!(registry.try_start("m1", work)); + assert!(registry.is_tracked("m1")); + assert!( + !registry.try_start("m1", async {}), + "in-flight id rejects a second task" + ); + + release_tx.send(true).unwrap(); + settle(®istry, |r| !r.is_tracked("m1")).await; + assert_eq!(runs.load(Ordering::SeqCst), 1, "the duplicate never ran"); + assert!(registry.try_start("m1", async {}), "restart after defer"); + } + + #[tokio::test] + async fn distinct_ids_run_concurrently() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let (_release_tx, release_rx) = watch::channel(false); + for id in ["a", "b", "c"] { + let mut release = release_rx.clone(); + assert!(registry.try_start(id, async move { + let _ = release.changed().await; + })); + } + assert_eq!(registry.len(), 3); + } + + #[tokio::test] + async fn cancel_all_aborts_and_clears() { + let registry = Arc::new(ActiveTaskRegistry::new(crate::test_spawner())); + let completed = Arc::new(AtomicUsize::new(0)); + for id in ["a", "b"] { + let completed = Arc::clone(&completed); + registry.try_start(id, async move { + std::future::pending::<()>().await; + completed.fetch_add(1, Ordering::SeqCst); + }); + } + registry.cancel_all(); + settle(®istry, |r| r.is_empty()).await; + assert_eq!(completed.load(Ordering::SeqCst), 0, "aborted, not run"); + assert!(registry.try_start("a", async {}), "ids are free again"); + } + + #[tokio::test] + async fn cancelled_unpolled_task_cannot_remove_its_replacement() { + let queue = Arc::new(Mutex::new( + Vec::>::new(), + )); + let pending = Arc::clone(&queue); + let registry = Arc::new(ActiveTaskRegistry::new(Arc::new(move |future| { + pending.lock().push(future); + }))); + assert!(registry.try_start("message", std::future::pending())); + registry.cancel_all(); + assert!(registry.try_start("message", std::future::pending())); + let old = queue.lock().remove(0); + old.await; + assert!(registry.is_tracked("message")); + assert!(!registry.try_start("message", async {})); + registry.cancel_all(); + let replacement = queue.lock().remove(0); + replacement.await; + assert!(registry.is_empty()); + } +} diff --git a/rust/crates/truapi-coinage/src/timer.rs b/rust/crates/truapi-coinage/src/timer.rs new file mode 100644 index 000000000..339762505 --- /dev/null +++ b/rust/crates/truapi-coinage/src/timer.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Runtime-independent delays, including browser hosts. +pub(crate) async fn sleep(duration: std::time::Duration) { + futures_timer::Delay::new(duration).await; +} diff --git a/rust/crates/truapi-coinage/src/transfer_sender.rs b/rust/crates/truapi-coinage/src/transfer_sender.rs new file mode 100644 index 000000000..642982db9 --- /dev/null +++ b/rust/crates/truapi-coinage/src/transfer_sender.rs @@ -0,0 +1,1992 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::HashMap; +use std::future::Future; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::join_all; +use rand::RngCore; +use tokio::sync::{Mutex as AsyncMutex, Notify}; +use tracing::{error, warn}; + +use crate::allocator::CoinAllocator; +use crate::clock::Clock; +use crate::denomination::DenominationBreakdownContext; +use crate::keys::CoinKeypairFactory; +use crate::memo::{MemoEntry, TransferMemo}; +use crate::model::{Coin, CoinState, EffectivePrivacy, Voucher, VoucherRemoteState}; +use crate::repo::{CoinRepository, TransferContext, TransferStateCommitter, VoucherRepository}; +use crate::ring_proof::{PersonOriginKind, ResolvedUnloadToken, RingProofParams}; +use crate::selection::{ + CoinSelectionError, CoinSelectionResult, CoinSelector, PrivacyLevel, RecyclerKey, + TransferStrategy, +}; +use crate::wal::{ + CheckpointBlock, TransferWalEntry, WalCoinRef, WalOperation, WalPayload, WalStore, + operation_entry_id, +}; + +const PREVIEW_LIFETIME_MS: i64 = 2 * 60 * 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferPreviewChoice { + Full, + NonDegraded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferPreviewStrategy { + ExactMatch, + Split, + UnloadIntoCoins, +} + +/// Secret-free preview fields suitable for an FFI record. `preview_id` is +/// opaque and meaningful only to the session-scoped service that created it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpaqueTransferPreview { + pub preview_id: String, + pub session_generation: u64, + pub full_amount: u128, + /// Maximum source value that may leave the purse, in raw planks. + /// Unload bounds conservatively include all selected voucher value. + pub max_debit_amount: u128, + pub non_degraded_amount: u128, + pub is_degraded: bool, + pub strategy: TransferPreviewStrategy, + pub expires_at_ms: i64, +} + +/// Public-only voucher snapshot for selection diagnostics: exposes selection +/// state without exposing any voucher secret key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoucherSelectionDiagnostic { + pub derivation_index: u32, + pub exponent: i16, + pub local_state: crate::model::VoucherLocalState, + pub remote_state: crate::model::VoucherRemoteState, + pub stored_privacy: crate::model::VoucherPrivacyLevel, + pub effective_privacy: EffectivePrivacy, + pub ready_at_ms: i64, +} + +pub fn voucher_selection_diagnostics( + vouchers: &[Voucher], + now_ms: i64, +) -> Vec { + vouchers + .iter() + .map(|voucher| VoucherSelectionDiagnostic { + derivation_index: voucher.derivation_index, + exponent: voucher.exponent, + local_state: voucher.local_state, + remote_state: voucher.remote_state, + stored_privacy: voucher.privacy, + effective_privacy: voucher.effective_privacy(now_ms), + ready_at_ms: voucher.ready_at_ms, + }) + .collect() +} + +/// Redacted aggregate emitted when authoritative preview selection fails. +/// Values are represented only by decimal digit counts, so production logs +/// reveal neither exact balances nor any coin/voucher key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectorSnapshotDiagnostic { + pub selectable_coins: usize, + pub expiring_coins: usize, + pub locked_coins: usize, + pub unloadable_vouchers: usize, + pub locked_vouchers: usize, + pub full_vouchers: usize, + pub degraded_vouchers: usize, + pub selectable_coin_value_digits: usize, + pub unloadable_voucher_value_digits: usize, +} + +pub fn selector_snapshot_diagnostic( + coins: &[Coin], + vouchers: &[Voucher], + context: &DenominationBreakdownContext, + now_ms: i64, +) -> SelectorSnapshotDiagnostic { + let selectable_coin_value = coins + .iter() + .filter(|coin| coin.is_selectable()) + .fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }); + let unloadable_voucher_value = vouchers + .iter() + .filter(|voucher| voucher.is_unloadable()) + .fold(0u128, |sum, voucher| { + sum.saturating_add(context.value_in_planks(voucher.exponent)) + }); + SelectorSnapshotDiagnostic { + selectable_coins: coins.iter().filter(|coin| coin.is_selectable()).count(), + expiring_coins: coins + .iter() + .filter(|coin| { + coin.state == crate::model::CoinState::Available && coin.is_expiring_soon() + }) + .count(), + locked_coins: coins.iter().filter(|coin| !coin.is_selectable()).count(), + unloadable_vouchers: vouchers + .iter() + .filter(|voucher| voucher.is_unloadable()) + .count(), + locked_vouchers: vouchers + .iter() + .filter(|voucher| !voucher.is_unloadable()) + .count(), + full_vouchers: vouchers + .iter() + .filter(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Full) + .count(), + degraded_vouchers: vouchers + .iter() + .filter(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Degraded) + .count(), + selectable_coin_value_digits: decimal_digits(selectable_coin_value), + unloadable_voucher_value_digits: decimal_digits(unloadable_voucher_value), + } +} + +fn decimal_digits(value: u128) -> usize { + value.to_string().len() +} + +fn selection_error_kind(error: &CoinSelectionError) -> &'static str { + match error { + CoinSelectionError::ZeroAmount => "zero_amount", + CoinSelectionError::EmptyWallet => "empty_wallet", + CoinSelectionError::AmountNotRepresentable { .. } => "amount_not_representable", + CoinSelectionError::NoReadyVouchers => "vouchers_not_ready", + CoinSelectionError::TooManyVouchersInGroup { .. } => "too_many_vouchers", + CoinSelectionError::InsufficientFunds => "insufficient_funds", + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegularTransferError { + Selection(CoinSelectionError), + PreviewNotFound, + PreviewExpired, + BalanceChanged, + NonDegradedAmountUnavailable, + OperationNotFound, + InvalidOperationId, + Planning(String), + Reservation(String), + Journal(String), + HandoffRejected, +} + +impl std::fmt::Display for RegularTransferError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Selection(error) => error.fmt(formatter), + Self::PreviewNotFound => formatter.write_str("transfer preview was not found"), + Self::PreviewExpired => formatter.write_str("transfer preview expired"), + Self::BalanceChanged => { + formatter.write_str("CASH balance changed; preview the payment again") + } + Self::NonDegradedAmountUnavailable => { + formatter.write_str("no non-degraded CASH amount is available") + } + Self::OperationNotFound => formatter.write_str("transfer operation was not found"), + Self::InvalidOperationId => { + formatter.write_str("transfer operation identifier is empty") + } + Self::Planning(error) => write!(formatter, "transfer planning failed: {error}"), + Self::Reservation(error) => write!(formatter, "transfer reservation failed: {error}"), + Self::Journal(error) => write!(formatter, "transfer journal failed: {error}"), + Self::HandoffRejected => { + formatter.write_str("CASH message was rejected before durable acceptance") + } + } + } +} + +impl std::error::Error for RegularTransferError {} + +impl From for RegularTransferError { + fn from(value: CoinSelectionError) -> Self { + Self::Selection(value) + } +} + +/// One split submission after recipient/change indices have been allocated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitTransferSubmission { + pub overflow_coin: Coin, + pub recipient_coins: Vec, + pub change_coins: Vec, + pub wal_entry_id: String, +} + +/// One unload group before the host resolves the shared finalized state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadGroupDraft { + pub recycler: RecyclerKey, + pub vouchers: Vec, + pub recipient_coins: Vec, + pub change_coins: Vec, + pub wal_entry_id: String, +} + +/// Chain-origin inputs resolved at one finalized snapshot. Proof bytes are +/// deliberately absent: they are created only after the final transaction +/// implication exists inside the submitter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnloadOriginPreparation { + pub recycler_ring: RingProofParams, + pub person_origin: PersonOriginKind, + pub people_ring: RingProofParams, + pub token: ResolvedUnloadToken, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedUnloadGroup { + pub draft: UnloadGroupDraft, + pub readiness_block_hash: [u8; 32], + pub recycler_revision: u32, + pub origin: UnloadOriginPreparation, +} + +/// Production implementations must update the named WAL checkpoint after +/// transaction creation and before broadcast. A successful return certifies +/// that the submitted input consumption and exact requested recipient/change +/// allocation finalized successfully. This evidence permits retiring the +/// child WAL even if the recipient consumes an output before the next query. +#[async_trait] +pub trait RegularTransferSubmitter: Send + Sync { + async fn prepare_unload_groups( + &self, + groups: &[UnloadGroupDraft], + ) -> Result, String>; + + async fn submit_split(&self, submission: &SplitTransferSubmission) -> Result<(), String>; + + async fn submit_unload_group(&self, submission: &PreparedUnloadGroup) -> Result<(), String>; +} + +pub struct RegularCoinTransferParts { + /// Executor owned by the active signing authority. + pub spawner: crate::Spawner, + pub coins: Arc, + pub vouchers: Arc, + pub wal: Arc, + pub allocator: Arc, + pub submitter: Arc, + pub denominations: DenominationBreakdownContext, + pub max_consolidation: usize, + pub clock: Arc, + pub session_generation: u64, + pub committer: Option>, +} + +#[derive(Clone)] +struct CachedPreview { + descriptor: OpaqueTransferPreview, + full: CoinSelectionResult, + non_degraded: Option, + coins: Vec, + vouchers: Vec, +} + +#[derive(Default)] +struct Confirmation { + result: Mutex>>, + notify: Notify, +} + +impl Confirmation { + async fn wait(&self) -> Result<(), RegularTransferError> { + loop { + let notified = self.notify.notified(); + if let Some(result) = self.result.lock().clone() { + return result; + } + notified.await; + } + } + + fn finish(&self, result: Result<(), RegularTransferError>) { + *self.result.lock() = Some(result); + self.notify.notify_waiters(); + } +} + +pub struct RegularCoinTransferService { + spawner: crate::Spawner, + key_factory: Arc, + coins: Arc, + vouchers: Arc, + wal: Arc, + allocator: Arc, + submitter: Arc, + denominations: DenominationBreakdownContext, + max_consolidation: usize, + clock: Arc, + session_generation: u64, + committer: Option>, + previews: AsyncMutex>, + confirmations: AsyncMutex>>, + execution_lock: AsyncMutex<()>, +} + +impl RegularCoinTransferService { + pub fn new(root_entropy: &[u8], parts: RegularCoinTransferParts) -> Self { + Self { + key_factory: Arc::new(CoinKeypairFactory::new(root_entropy)), + coins: parts.coins, + vouchers: parts.vouchers, + wal: parts.wal, + allocator: parts.allocator, + submitter: parts.submitter, + denominations: parts.denominations, + max_consolidation: parts.max_consolidation.max(1), + clock: parts.clock, + session_generation: parts.session_generation, + committer: parts.committer, + previews: AsyncMutex::new(HashMap::new()), + confirmations: AsyncMutex::new(HashMap::new()), + execution_lock: AsyncMutex::new(()), + spawner: parts.spawner, + } + } + + /// The native send UI speaks whole CASH cents; selection and memo values + /// speak raw chain planks. Keep their only conversion at this service + /// boundary so callers cannot accidentally preview cents as planks. + pub fn cash_cents_to_planks(&self, cents: u128) -> Option { + self.denominations.cash_cents_to_planks(cents) + } + + pub fn cash_cents_from_planks(&self, planks: u128) -> Option { + self.denominations.cash_cents_from_planks(planks) + } + + pub fn planks_per_cash_cent(&self) -> u128 { + self.denominations.asset_unit + } + + pub async fn preview( + &self, + amount: u128, + ) -> Result { + let coins = self + .coins + .list() + .await + .map_err(RegularTransferError::Planning)?; + let vouchers = self + .vouchers + .list() + .await + .map_err(RegularTransferError::Planning)?; + let now_ms = self.clock.now_ms(); + let full = match CoinSelector::new(self.denominations.clone(), self.max_consolidation) + .select(amount, &coins, &vouchers, now_ms) + { + Ok(selection) => selection, + Err(selection_error) => { + let snapshot = + selector_snapshot_diagnostic(&coins, &vouchers, &self.denominations, now_ms); + warn!( + error = selection_error_kind(&selection_error), + strategy = "none", + requested_value_digits = decimal_digits(amount), + selectable_coins = snapshot.selectable_coins, + expiring_coins = snapshot.expiring_coins, + locked_coins = snapshot.locked_coins, + unloadable_vouchers = snapshot.unloadable_vouchers, + locked_vouchers = snapshot.locked_vouchers, + full_vouchers = snapshot.full_vouchers, + degraded_vouchers = snapshot.degraded_vouchers, + selectable_coin_value_digits = snapshot.selectable_coin_value_digits, + unloadable_voucher_value_digits = snapshot.unloadable_voucher_value_digits, + "coinage selector snapshot" + ); + return Err(selection_error.into()); + } + }; + let (non_degraded, non_degraded_amount) = + non_degraded_selection(&full, &self.denominations, now_ms); + let strategy = match full.strategy { + TransferStrategy::ExactMatch { .. } => TransferPreviewStrategy::ExactMatch, + TransferStrategy::Split { .. } => TransferPreviewStrategy::Split, + TransferStrategy::UnloadIntoCoins { .. } => TransferPreviewStrategy::UnloadIntoCoins, + }; + let preview_id = random_id("cash-preview"); + let descriptor = OpaqueTransferPreview { + preview_id: preview_id.clone(), + session_generation: self.session_generation, + full_amount: amount, + max_debit_amount: selection_max_debit(&full, &self.denominations), + non_degraded_amount, + is_degraded: full.privacy_level == PrivacyLevel::Degraded, + strategy, + expires_at_ms: now_ms.saturating_add(PREVIEW_LIFETIME_MS), + }; + let mut previews = self.previews.lock().await; + previews.retain(|_, preview| preview.descriptor.expires_at_ms >= now_ms); + previews.insert( + preview_id, + CachedPreview { + descriptor: descriptor.clone(), + full, + non_degraded, + coins, + vouchers, + }, + ); + Ok(descriptor) + } + + pub async fn preview_descriptor( + &self, + preview_id: &str, + ) -> Result { + let descriptor = match self + .previews + .lock() + .await + .get(preview_id) + .map(|preview| preview.descriptor.clone()) + { + Some(descriptor) => descriptor, + None => { + return Err(RegularTransferError::PreviewNotFound); + } + }; + if self.clock.now_ms() > descriptor.expires_at_ms { + return Err(RegularTransferError::PreviewExpired); + } + Ok(descriptor) + } + + /// Confirms exactly the cached selector result. Calls racing for the same + /// preview join one result; only the first closure can receive the memo. + /// `handoff` may return `Err(())` only if it certifies that no recipient or + /// transport accepted the secret. Ambiguous delivery must retain the WAL + /// by returning `Ok(())` and reporting transport uncertainty separately. + pub async fn confirm( + self: &Arc, + preview_id: &str, + choice: TransferPreviewChoice, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let (confirmation, leader) = { + let mut confirmations = self.confirmations.lock().await; + match confirmations.get(preview_id) { + Some(existing) => (Arc::clone(existing), false), + None => { + let confirmation = Arc::new(Confirmation::default()); + confirmations.insert(preview_id.to_owned(), Arc::clone(&confirmation)); + (confirmation, true) + } + } + }; + if leader { + let service = Arc::clone(self); + let preview_id = preview_id.to_owned(); + let running = Arc::clone(&confirmation); + crate::tasks::spawn_abortable(&self.spawner, async move { + let result = service + .confirm_operation(&preview_id, &preview_id, choice, handoff) + .await; + running.finish(result); + }); + } + confirmation.wait().await + } + + /// Confirms a host-owned durable operation. The host must serialize wallet + /// recovery with this call and run it on its cancellation-safe executor. + /// The callback may await durable transport persistence before returning. + /// `Err` from the callback certifies no acceptance; uncertain acceptance + /// must return `Ok` and retain the transport's idempotent operation key. + /// + /// All chain work finishes (or leaves recoverable WAL) before returning. + /// Once transport accepts, chain/persistence failures never become `Err`. + pub async fn confirm_operation( + self: &Arc, + operation_id: &str, + preview_id: &str, + choice: TransferPreviewChoice, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + if operation_id.is_empty() { + return Err(RegularTransferError::InvalidOperationId); + } + let _execution = self.execution_lock.lock().await; + let existing = self.operation_wal(operation_id).await?; + if !existing.is_empty() { + return self.resume_entries(existing, handoff).await; + } + let cached = self + .previews + .lock() + .await + .get(preview_id) + .cloned() + .ok_or(RegularTransferError::PreviewNotFound)?; + if self.clock.now_ms() > cached.descriptor.expires_at_ms { + return Err(RegularTransferError::PreviewExpired); + } + let current_coins = self + .coins + .list() + .await + .map_err(RegularTransferError::Planning)?; + let current_vouchers = self + .vouchers + .list() + .await + .map_err(RegularTransferError::Planning)?; + if current_coins != cached.coins || current_vouchers != cached.vouchers { + return Err(RegularTransferError::BalanceChanged); + } + let expected_amount = match choice { + TransferPreviewChoice::Full => cached.descriptor.full_amount, + TransferPreviewChoice::NonDegraded => cached.descriptor.non_degraded_amount, + }; + let result = match choice { + TransferPreviewChoice::Full => cached.full, + TransferPreviewChoice::NonDegraded => cached + .non_degraded + .ok_or(RegularTransferError::NonDegradedAmountUnavailable)?, + }; + let amount = selection_value(&result, &self.denominations); + if amount == 0 + || amount != expected_amount + || selection_max_debit(&result, &self.denominations) + > cached.descriptor.max_debit_amount + { + return Err(RegularTransferError::BalanceChanged); + } + let mut plan = self.create_plan(operation_id, result, amount).await?; + let context = self.transfer_context(); + context + .reserve(&plan.reserved_coins, &plan.reserved_vouchers) + .await + .map_err(RegularTransferError::Reservation)?; + if let Err(error) = self.wal.save_all(&plan.journals).await { + // A failed acknowledgement can still have committed the atomic + // batch. Never partially delete it: restart would lose the plan. + // Recovery restores orphan reservations if no batch committed, + // otherwise the complete Prepared operation remains resumable. + return Err(RegularTransferError::Journal(error)); + } + let mut parent = plan + .journals + .iter() + .find(|entry| entry.operation.is_transfer_receipt()) + .cloned() + .expect("operation plan includes its durable receipt"); + let memo = plan.memo.take().expect("plan memo consumed once"); + if handoff(memo).await.is_err() { + self.reject_operation(&mut parent, &plan.journals).await?; + return Err(RegularTransferError::HandoffRejected); + } + if let Err(error) = self + .finish_accepted(&mut parent, &plan.journals, plan.chain) + .await + { + error!(%error, "accepted CASH operation remains journaled for recovery"); + } + Ok(()) + } + + pub async fn operation_wal( + &self, + operation_id: &str, + ) -> Result, RegularTransferError> { + self.wal + .load_operation(operation_id) + .await + .map_err(RegularTransferError::Journal) + } + + /// Resumes only existing allocations, never selects or debits new inputs. + /// Invoke after recovery and renewed user approval, never from passive + /// reconciliation. For Prepared receipts, `handoff` must resolve/replay the + /// SAME host transport operation, since acceptance may precede a crash. + /// Accepted/Completed receipts never invoke it. Known checkpoints are not + /// broadcast again; recovery must first prove their mortality or fork. + pub async fn resume_operation( + self: &Arc, + operation_id: &str, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + let _execution = self.execution_lock.lock().await; + self.resume_entries(self.operation_wal(operation_id).await?, handoff) + .await + } + + async fn resume_entries( + &self, + entries: Vec, + handoff: F, + ) -> Result<(), RegularTransferError> + where + F: FnOnce(TransferMemo) -> Fut + Send, + Fut: Future> + Send, + { + let mut parent = entries + .iter() + .find(|entry| entry.operation.is_transfer_receipt()) + .cloned() + .ok_or(RegularTransferError::OperationNotFound)?; + match parent.operation { + WalOperation::TransferCompleted => return Ok(()), + WalOperation::TransferRejected => { + self.reject_operation(&mut parent, &entries).await?; + return Err(RegularTransferError::HandoffRejected); + } + WalOperation::TransferPrepared => { + let coins = parent + .payload + .output_coins + .iter() + .map(referenced_coin) + .collect::>(); + let amount = coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(self.denominations.value_in_planks(coin.exponent)) + }); + let memo = self.build_memo(&coins, amount)?; + if handoff(memo).await.is_err() { + self.reject_operation(&mut parent, &entries).await?; + return Err(RegularTransferError::HandoffRejected); + } + } + WalOperation::TransferAccepted => {} + _ => return Err(RegularTransferError::OperationNotFound), + } + // From here the memo may already be owned by the recipient. + let completion = async { + parent.operation = WalOperation::TransferAccepted; + self.wal.save(&parent).await?; + let chain = self.restore_chain_plan(&entries).await?; + self.finish_accepted(&mut parent, &entries, chain).await + } + .await; + if let Err(error) = completion { + error!(%error, "accepted CASH operation remains journaled for recovery"); + } + Ok(()) + } + + fn transfer_context(&self) -> Arc { + let mut context = TransferContext::new(Arc::clone(&self.coins), Arc::clone(&self.vouchers)); + if let Some(committer) = &self.committer { + context = context.with_committer(Arc::clone(committer)); + } + Arc::new(context) + } + + async fn reject_operation( + &self, + parent: &mut TransferWalEntry, + entries: &[TransferWalEntry], + ) -> Result<(), RegularTransferError> { + parent.operation = WalOperation::TransferRejected; + self.wal + .save(parent) + .await + .map_err(RegularTransferError::Journal)?; + for entry in entries + .iter() + .filter(|entry| !entry.operation.is_transfer_receipt()) + { + for input in &entry.payload.input_coins { + self.coins + .set_state(input.derivation_index, CoinState::Available) + .await + .map_err(RegularTransferError::Reservation)?; + } + for input in &entry.payload.input_vouchers { + self.vouchers + .set_local_state( + input.derivation_index, + crate::model::VoucherLocalState::Available, + ) + .await + .map_err(RegularTransferError::Reservation)?; + } + self.wal + .delete(&entry.entry_id) + .await + .map_err(RegularTransferError::Journal)?; + } + Ok(()) + } + + async fn finish_accepted( + &self, + parent: &mut TransferWalEntry, + entries: &[TransferWalEntry], + chain: ChainPlan, + ) -> Result<(), String> { + parent.operation = WalOperation::TransferAccepted; + self.wal.save(parent).await?; + let context = self.transfer_context(); + for entry in entries + .iter() + .filter(|entry| entry.operation == WalOperation::SecretHandoff) + { + let indices = entry + .payload + .input_coins + .iter() + .map(|coin| coin.derivation_index) + .collect::>(); + context.process_outputs(&indices, &[], &[], &[]).await?; + self.wal.delete(&entry.entry_id).await?; + } + self.run_chain_plan(chain, context).await?; + let children_remain = self.wal.load_all().await?.iter().any(|entry| { + entry.entry_id != parent.entry_id + && entry.operation_parent_id().as_deref() == Some(parent.entry_id.as_str()) + }); + if !children_remain { + parent.operation = WalOperation::TransferCompleted; + self.wal.save(parent).await?; + } + Ok(()) + } + + async fn restore_chain_plan(&self, entries: &[TransferWalEntry]) -> Result { + let mut drafts = Vec::new(); + let vouchers = if entries.iter().any(|entry| { + entry.operation == WalOperation::IntoCoins + && entry.checkpoint == CheckpointBlock::Pending + }) { + self.vouchers.list().await? + } else { + Vec::new() + }; + for entry in entries.iter().filter(|entry| { + entry.checkpoint == CheckpointBlock::Pending + && matches!( + entry.operation, + WalOperation::Split | WalOperation::IntoCoins + ) + }) { + let recipient_coins = entry + .payload + .destination_coins + .iter() + .map(referenced_coin) + .collect::>(); + let change_coins = entry + .payload + .output_coins + .iter() + .filter(|coin| !entry.payload.destination_coins.contains(coin)) + .map(referenced_coin) + .collect::>(); + match entry.operation { + WalOperation::Split => { + if entry.payload.input_coins.len() != 1 { + return Err("split journal does not have exactly one input".into()); + } + return Ok(ChainPlan::Split(SplitTransferSubmission { + overflow_coin: referenced_coin(&entry.payload.input_coins[0]), + recipient_coins, + change_coins, + wal_entry_id: entry.entry_id.clone(), + })); + } + WalOperation::IntoCoins => { + let members = entry + .payload + .input_vouchers + .iter() + .map(|reference| { + vouchers + .iter() + .find(|voucher| { + voucher.derivation_index == reference.derivation_index + && voucher.exponent == reference.exponent + }) + .cloned() + .ok_or_else(|| { + "unload journal input is not available locally".to_string() + }) + }) + .collect::, _>>()?; + let first = members.first().ok_or("unload journal has no vouchers")?; + let VoucherRemoteState::InRecycler { recycler_index } = first.remote_state + else { + return Err("unload journal awaits voucher location reconciliation".into()); + }; + let recycler = RecyclerKey { + exponent: first.exponent, + index: recycler_index, + }; + if members.iter().any(|voucher| { + voucher.exponent != recycler.exponent + || voucher.remote_state != first.remote_state + }) { + return Err("unload journal spans multiple recyclers".into()); + } + drafts.push(UnloadGroupDraft { + recycler, + vouchers: members, + recipient_coins, + change_coins, + wal_entry_id: entry.entry_id.clone(), + }); + } + _ => {} + } + } + if drafts.is_empty() { + return Ok(ChainPlan::None); + } + let prepared = self.submitter.prepare_unload_groups(&drafts).await?; + if prepared.len() != drafts.len() + || prepared + .iter() + .zip(&drafts) + .any(|(prepared, draft)| &prepared.draft != draft) + { + return Err("unload recovery changed the durable allocation".into()); + } + Ok(ChainPlan::Unload(prepared)) + } + + async fn create_plan( + &self, + operation_id: &str, + result: CoinSelectionResult, + amount: u128, + ) -> Result { + let mut journals = Vec::new(); + let mut reserved_coins = Vec::new(); + let mut reserved_vouchers = Vec::new(); + let (pass_through, recipient, chain) = match result.strategy { + TransferStrategy::ExactMatch { coins } => { + reserved_coins.extend(coins.iter().map(|coin| coin.derivation_index)); + (coins.clone(), coins, ChainPlan::None) + } + TransferStrategy::Split { + partial_coins, + split_coin, + target_denominations, + change_denominations, + } => { + let recipient = self.allocate_coins(&target_denominations).await?; + let change = self.allocate_coins(&change_denominations).await?; + reserved_coins.extend(partial_coins.iter().map(|coin| coin.derivation_index)); + reserved_coins.push(split_coin.derivation_index); + let wal_entry_id = operation_entry_id(operation_id, "split"); + let submission = SplitTransferSubmission { + overflow_coin: split_coin.clone(), + recipient_coins: recipient.clone(), + change_coins: change.clone(), + wal_entry_id: wal_entry_id.clone(), + }; + journals.push(chain_journal( + wal_entry_id, + WalOperation::Split, + &[split_coin], + &[], + &recipient, + &change, + self.clock.now_ms(), + )); + let mut memo_coins = partial_coins.clone(); + memo_coins.extend(recipient); + (partial_coins, memo_coins, ChainPlan::Split(submission)) + } + TransferStrategy::UnloadIntoCoins { coins, groups } => { + reserved_coins.extend(coins.iter().map(|coin| coin.derivation_index)); + let mut drafts = Vec::with_capacity(groups.len()); + let mut memo_coins = coins.clone(); + for (group_index, group) in groups.into_iter().enumerate() { + let recipient = self.allocate_coins(&group.recipient_denominations).await?; + let change = self.allocate_coins(&group.change_denominations).await?; + reserved_vouchers.extend( + group + .vouchers + .iter() + .map(|voucher| voucher.derivation_index), + ); + memo_coins.extend(recipient.clone()); + drafts.push(UnloadGroupDraft { + recycler: group.recycler, + vouchers: group.vouchers, + recipient_coins: recipient, + change_coins: change, + wal_entry_id: operation_entry_id( + operation_id, + &format!("unload-{group_index}"), + ), + }); + } + let prepared = self + .submitter + .prepare_unload_groups(&drafts) + .await + .map_err(RegularTransferError::Planning)?; + if prepared.len() != drafts.len() + || prepared + .iter() + .zip(&drafts) + .any(|(prepared, draft)| &prepared.draft != draft) + { + return Err(RegularTransferError::Planning( + "unload preparation did not preserve every recycler group".into(), + )); + } + for group in &prepared { + journals.push(chain_journal( + group.draft.wal_entry_id.clone(), + WalOperation::IntoCoins, + &[], + &group.draft.vouchers, + &group.draft.recipient_coins, + &group.draft.change_coins, + self.clock.now_ms(), + )); + } + (coins, memo_coins, ChainPlan::Unload(prepared)) + } + }; + + let memo = self.build_memo(&recipient, amount)?; + if !pass_through.is_empty() { + let id = operation_entry_id(operation_id, "handoff"); + journals.push(TransferWalEntry { + entry_id: id.clone(), + operation: WalOperation::SecretHandoff, + payload: WalPayload { + input_coins: coin_refs(&pass_through), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }); + } + journals.push(TransferWalEntry { + entry_id: operation_entry_id(operation_id, "parent"), + operation: WalOperation::TransferPrepared, + payload: WalPayload { + output_coins: coin_refs(&recipient), + ..WalPayload::default() + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms: self.clock.now_ms(), + }); + Ok(TransferPlan { + memo: Some(memo), + reserved_coins, + reserved_vouchers, + journals, + chain, + }) + } + + async fn allocate_coins( + &self, + denominations: &[crate::denomination::Denomination], + ) -> Result, RegularTransferError> { + let mut coins = Vec::with_capacity(denominations.len()); + for denomination in denominations { + coins.push( + self.allocator + .allocate(denomination.exponent) + .await + .map_err(RegularTransferError::Planning)?, + ); + } + Ok(coins) + } + + fn build_memo( + &self, + coins: &[Coin], + expected: u128, + ) -> Result { + let total = coins.iter().fold(0u128, |total, coin| { + total.saturating_add(self.denominations.value_in_planks(coin.exponent)) + }); + if total != expected { + return Err(RegularTransferError::Planning(format!( + "memo value {total} does not equal selected amount {expected}" + ))); + } + let entries = coins + .iter() + .map(|coin| { + self.key_factory + .secret_bytes(coin.derivation_index) + .map(MemoEntry) + .map_err(RegularTransferError::Planning) + }) + .collect::, _>>()?; + Ok(TransferMemo { + entries, + total_value: total, + }) + } + + async fn run_chain_plan( + &self, + chain: ChainPlan, + context: Arc, + ) -> Result<(), String> { + match chain { + ChainPlan::None => Ok(()), + ChainPlan::Split(submission) => match self.submitter.submit_split(&submission).await { + Ok(()) => { + context + .process_outputs( + &[submission.overflow_coin.derivation_index], + &[], + &submission.change_coins, + &submission.recipient_coins, + ) + .await?; + self.wal.delete(&submission.wal_entry_id).await + } + // Even a pre-broadcast failure must retain the fixed outputs: + // their secrets already belong to the recipient. + Err(error) => Err(error), + }, + ChainPlan::Unload(groups) => { + let outcomes = join_all(groups.into_iter().map(|group| { + let submitter = Arc::clone(&self.submitter); + async move { + let result = submitter.submit_unload_group(&group).await; + (group, result) + } + })) + .await; + let mut errors = Vec::new(); + for (group, outcome) in outcomes { + let voucher_ids = group + .draft + .vouchers + .iter() + .map(|voucher| voucher.derivation_index) + .collect::>(); + match outcome { + Ok(()) => { + if let Err(error) = context + .process_outputs( + &[], + &voucher_ids, + &group.draft.change_coins, + &group.draft.recipient_coins, + ) + .await + { + errors.push(error); + continue; + } + if let Err(error) = self.wal.delete(&group.draft.wal_entry_id).await { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + } + } +} + +struct TransferPlan { + memo: Option, + reserved_coins: Vec, + reserved_vouchers: Vec, + journals: Vec, + chain: ChainPlan, +} + +enum ChainPlan { + None, + Split(SplitTransferSubmission), + Unload(Vec), +} + +fn random_id(prefix: &str) -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("{prefix}-{}", hex::encode(bytes)) +} + +fn coin_refs(coins: &[Coin]) -> Vec { + coins + .iter() + .map(|coin| WalCoinRef { + derivation_index: coin.derivation_index, + exponent: coin.exponent, + }) + .collect() +} + +fn voucher_refs(vouchers: &[Voucher]) -> Vec { + vouchers + .iter() + .map(|voucher| WalCoinRef { + derivation_index: voucher.derivation_index, + exponent: voucher.exponent, + }) + .collect() +} + +fn referenced_coin(reference: &WalCoinRef) -> Coin { + Coin { + derivation_index: reference.derivation_index, + exponent: reference.exponent, + age: None, + state: CoinState::PendingTransfer, + } +} + +fn chain_journal( + entry_id: String, + operation: WalOperation, + input_coins: &[Coin], + input_vouchers: &[Voucher], + destination: &[Coin], + change: &[Coin], + created_at_ms: i64, +) -> TransferWalEntry { + let mut output_coins = coin_refs(destination); + output_coins.extend(coin_refs(change)); + TransferWalEntry { + entry_id, + operation, + payload: WalPayload { + input_coins: coin_refs(input_coins), + input_vouchers: voucher_refs(input_vouchers), + output_coins, + output_vouchers: Vec::new(), + destination_coins: coin_refs(destination), + }, + checkpoint: CheckpointBlock::Pending, + created_at_ms, + } +} + +fn selection_value(result: &CoinSelectionResult, context: &DenominationBreakdownContext) -> u128 { + match &result.strategy { + TransferStrategy::ExactMatch { coins } => coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }), + TransferStrategy::Split { + partial_coins, + target_denominations, + .. + } => partial_coins + .iter() + .fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }) + .saturating_add(context.total_value(target_denominations)), + TransferStrategy::UnloadIntoCoins { coins, groups } => groups.iter().fold( + coins.iter().fold(0u128, |sum, coin| { + sum.saturating_add(context.value_in_planks(coin.exponent)) + }), + |sum, group| sum.saturating_add(context.total_value(&group.recipient_denominations)), + ), + } +} + +fn selection_max_debit( + result: &CoinSelectionResult, + context: &DenominationBreakdownContext, +) -> u128 { + match &result.strategy { + TransferStrategy::UnloadIntoCoins { coins, groups } => coins + .iter() + .map(|coin| coin.exponent) + .chain( + groups + .iter() + .flat_map(|group| group.vouchers.iter().map(|voucher| voucher.exponent)), + ) + .fold(0u128, |total, exponent| { + total.saturating_add(context.value_in_planks(exponent)) + }), + // Whole coins and splits retain their planned change exactly. + _ => selection_value(result, context), + } +} + +fn non_degraded_selection( + full: &CoinSelectionResult, + context: &DenominationBreakdownContext, + now_ms: i64, +) -> (Option, u128) { + if full.privacy_level == PrivacyLevel::Full { + return (Some(full.clone()), selection_value(full, context)); + } + let TransferStrategy::UnloadIntoCoins { coins, groups } = &full.strategy else { + return (Some(full.clone()), selection_value(full, context)); + }; + let groups = groups + .iter() + .filter(|group| { + group + .vouchers + .iter() + .all(|voucher| voucher.effective_privacy(now_ms) == EffectivePrivacy::Full) + }) + .cloned() + .collect::>(); + let result = CoinSelectionResult { + strategy: if groups.is_empty() { + TransferStrategy::ExactMatch { + coins: coins.clone(), + } + } else { + TransferStrategy::UnloadIntoCoins { + coins: coins.clone(), + groups, + } + }, + privacy_level: PrivacyLevel::Full, + }; + let amount = selection_value(&result, context); + ((amount > 0).then_some(result), amount) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + use crate::balance::compute_balance; + use crate::clock::FixedClock; + use crate::index_store::InMemoryCoinageIndexStore; + use crate::model::{CoinState, VoucherLocalState, VoucherPrivacyLevel, VoucherRemoteState}; + use crate::repo::{InMemoryCoinRepository, InMemoryVoucherRepository}; + + #[derive(Default)] + struct MemWal(Mutex>); + + #[async_trait] + impl WalStore for MemWal { + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String> { + let mut entries = self.0.lock(); + entries.retain(|existing| existing.entry_id != entry.entry_id); + entries.push(entry.clone()); + Ok(()) + } + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String> { + let mut stored = self.0.lock(); + stored.retain(|existing| { + !entries + .iter() + .any(|entry| entry.entry_id == existing.entry_id) + }); + stored.extend_from_slice(entries); + Ok(()) + } + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String> { + let mut entries = self.0.lock(); + entries + .iter_mut() + .find(|entry| entry.entry_id == entry_id) + .ok_or_else(|| "missing WAL".to_string())? + .checkpoint = checkpoint; + Ok(()) + } + async fn load_all(&self) -> Result, String> { + Ok(self.0.lock().clone()) + } + async fn delete(&self, entry_id: &str) -> Result<(), String> { + self.0.lock().retain(|entry| entry.entry_id != entry_id); + Ok(()) + } + } + + struct MockSubmitter { + wal: Arc, + fail_unload: HashSet, + } + + #[async_trait] + impl RegularTransferSubmitter for MockSubmitter { + async fn prepare_unload_groups( + &self, + groups: &[UnloadGroupDraft], + ) -> Result, String> { + Ok(groups + .iter() + .cloned() + .map(|draft| PreparedUnloadGroup { + recycler_revision: 1, + readiness_block_hash: [7; 32], + origin: UnloadOriginPreparation { + recycler_ring: RingProofParams { + ring_exponent: 9, + ring_index: draft.recycler.index, + ring_revision: 1, + ring_members: vec![[1; 32]], + }, + person_origin: PersonOriginKind::Lite, + people_ring: RingProofParams { + ring_exponent: 9, + ring_index: 2, + ring_revision: 1, + ring_members: vec![[2; 32]], + }, + token: ResolvedUnloadToken { + period: 3, + counter: draft.recycler.index, + }, + }, + draft, + }) + .collect()) + } + + async fn submit_split(&self, submission: &SplitTransferSubmission) -> Result<(), String> { + self.wal + .update_checkpoint( + &submission.wal_entry_id, + CheckpointBlock::Known { + number: 10, + hash: [9; 32], + }, + ) + .await + } + + async fn submit_unload_group( + &self, + submission: &PreparedUnloadGroup, + ) -> Result<(), String> { + self.wal + .update_checkpoint( + &submission.draft.wal_entry_id, + CheckpointBlock::Known { + number: 10, + hash: [9; 32], + }, + ) + .await?; + if self.fail_unload.contains(&submission.draft.recycler.index) { + Err("scripted ambiguous unload failure".into()) + } else { + Ok(()) + } + } + } + + fn context() -> DenominationBreakdownContext { + DenominationBreakdownContext { + asset_unit: 1, + max_exponent: 10, + min_exponent: 0, + precision: 2, + } + } + + fn voucher(index: u32, exponent: i16, recycler: u32) -> Voucher { + Voucher { + exponent, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + #[test] + fn selector_snapshot_is_aggregate_only_and_uses_value_digit_counts() { + let coins = vec![ + Coin { + exponent: 2, + derivation_index: 99, + age: Some(1), + state: CoinState::Available, + }, + Coin { + exponent: 1, + derivation_index: 100, + age: Some(14), + state: CoinState::Available, + }, + Coin { + exponent: 0, + derivation_index: 101, + age: Some(1), + state: CoinState::PendingTransfer, + }, + ]; + let mut ready = voucher(7, 3, 4); + ready.ready_at_ms = 0; + let mut locked = voucher(8, 1, 4); + locked.local_state = VoucherLocalState::PendingTransfer; + locked.privacy = VoucherPrivacyLevel::Degraded; + let diagnostic = selector_snapshot_diagnostic(&coins, &[ready, locked], &context(), 1_000); + assert_eq!(diagnostic.selectable_coins, 1); + assert_eq!(diagnostic.expiring_coins, 1); + assert_eq!(diagnostic.locked_coins, 2); + assert_eq!(diagnostic.unloadable_vouchers, 1); + assert_eq!(diagnostic.locked_vouchers, 1); + assert_eq!(diagnostic.full_vouchers, 1); + assert_eq!(diagnostic.degraded_vouchers, 1); + assert_eq!(diagnostic.selectable_coin_value_digits, 1); // value 4 + assert_eq!(diagnostic.unloadable_voucher_value_digits, 1); // value 8 + } + + fn service( + vouchers: Vec, + fail_unload: HashSet, + ) -> ( + Arc, + Arc, + Arc, + Arc, + ) { + service_with_context(vouchers, fail_unload, context()) + } + + fn service_with_context( + vouchers: Vec, + fail_unload: HashSet, + denominations: DenominationBreakdownContext, + ) -> ( + Arc, + Arc, + Arc, + Arc, + ) { + let coins = Arc::new(InMemoryCoinRepository::default()); + let vouchers = Arc::new(InMemoryVoucherRepository::with_vouchers(vouchers)); + let wal = Arc::new(MemWal::default()); + let submitter = Arc::new(MockSubmitter { + wal: Arc::clone(&wal), + fail_unload, + }); + let service = Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers: Arc::clone(&vouchers) as Arc<_>, + wal: Arc::clone(&wal) as Arc<_>, + allocator: Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + submitter, + denominations, + max_consolidation: 100, + clock: Arc::new(FixedClock(1_000)), + session_generation: 4, + committer: None, + }, + )); + (service, coins, vouchers, wal) + } + + /// Native amounts stay in CASH cents while the selector stays in raw + /// asset planks. This regression covers real-scale CASH amounts and + /// proves the 10,000x boundary is applied before all three selector + /// strategies run. + #[tokio::test] + async fn native_cash_amounts_preview_at_six_decimal_asset_precision() { + let denominations = DenominationBreakdownContext { + asset_unit: 10_000, + max_exponent: 14, + min_exponent: 0, + precision: 6, + }; + // 2^14 + 2^11 + 2^10 + 2^9 + 2^5 = 20,000 cents = 200 CASH. + let inventory = vec![ + voucher(1, 14, 1), + voucher(2, 11, 2), + voucher(3, 10, 3), + voucher(4, 9, 4), + voucher(5, 5, 5), + ]; + let (service, _, _, _) = service_with_context(inventory, HashSet::new(), denominations); + + for (cash, cents, expected_planks) in [ + (1, 100, 1_000_000), + (10, 1_000, 10_000_000), + (100, 10_000, 100_000_000), + (200, 20_000, 200_000_000), + ] { + let planks = service.cash_cents_to_planks(cents).unwrap(); + assert_eq!(planks, expected_planks, "{cash} CASH conversion"); + let preview = service.preview(planks).await.unwrap(); + assert_eq!(preview.full_amount, expected_planks, "{cash} CASH preview"); + assert_eq!( + service.cash_cents_from_planks(preview.full_amount), + Some(cents), + "{cash} CASH projection" + ); + } + } + + #[tokio::test] + async fn six_voucher_balance_previews_and_confirms_one_cash() { + let inventory = vec![ + voucher(1, 10, 1), // 1024 + voucher(2, 4, 2), // 16 + voucher(3, 4, 3), // 16 + voucher(4, 3, 4), // 8 + voucher(5, 1, 5), // 2 + voucher(6, 1, 6), // 2 + ]; + assert_eq!( + compute_balance(&[], &inventory, &context(), 1_000).total_planks(), + 1_068 + ); + let (service, _coins, vouchers, wal) = service(inventory, HashSet::new()); + let preview = service.preview(100).await.unwrap(); + assert_eq!(preview.strategy, TransferPreviewStrategy::UnloadIntoCoins); + assert_eq!(preview.full_amount, 100); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |memo| async move { + assert_eq!(memo.total_value, 100); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(vouchers.list().await.unwrap().len(), 5); + assert_eq!( + wal.load_operation(&preview.preview_id).await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } + + #[tokio::test] + async fn split_persists_change_retires_destination_and_preserves_receipt() { + let coins = Arc::new(InMemoryCoinRepository::with_coins([Coin { + exponent: 3, + derivation_index: 100, + age: Some(1), + state: crate::CoinState::Available, + }])); + let vouchers = Arc::new(InMemoryVoucherRepository::default()); + let wal = Arc::new(MemWal::default()); + let service = Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&coins) as Arc<_>, + vouchers, + wal: Arc::clone(&wal) as Arc<_>, + allocator: Arc::new(CoinAllocator::new(Arc::new( + InMemoryCoinageIndexStore::default(), + ))), + submitter: Arc::new(MockSubmitter { + wal: Arc::clone(&wal), + fail_unload: HashSet::new(), + }), + denominations: context(), + max_consolidation: 100, + clock: Arc::new(FixedClock(1_000)), + session_generation: 4, + committer: None, + }, + )); + let preview = service.preview(3).await.unwrap(); + assert_eq!(preview.strategy, TransferPreviewStrategy::Split); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |memo| async move { + assert_eq!(memo.total_value, 3); + Ok(()) + }, + ) + .await + .unwrap(); + let rows = coins.list().await.unwrap(); + assert_eq!( + rows.iter() + .find(|coin| coin.derivation_index == 100) + .unwrap() + .state, + crate::CoinState::Spent + ); + assert!( + rows.iter() + .any(|coin| coin.state == crate::CoinState::Spent && coin.derivation_index != 100) + ); + assert!( + rows.iter() + .any(|coin| coin.state == crate::CoinState::Available) + ); + assert_eq!( + wal.load_operation(&preview.preview_id).await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } + + #[tokio::test] + async fn multi_group_unload_commits_success_and_keeps_ambiguous_group_reserved() { + let (service, _, vouchers, wal) = + service(vec![voucher(1, 1, 1), voucher(2, 0, 2)], HashSet::from([2])); + let preview = service.preview(3).await.unwrap(); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Ok(()) }, + ) + .await + .unwrap(); + let remaining = vouchers.list().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].derivation_index, 2); + assert_eq!(remaining[0].local_state, VoucherLocalState::PendingTransfer); + let entries = wal.load_operation(&preview.preview_id).await.unwrap(); + assert!( + entries + .iter() + .any(|entry| entry.operation == WalOperation::TransferAccepted) + ); + let unresolved = entries + .iter() + .find(|entry| entry.operation == WalOperation::IntoCoins) + .unwrap(); + assert_eq!(unresolved.payload.input_vouchers[0].derivation_index, 2); + assert!(matches!( + unresolved.checkpoint, + CheckpointBlock::Known { .. } + )); + } + + #[test] + fn spendable_voucher_snapshot_never_returns_false_empty_wallet() { + let inventory = vec![ + voucher(1, 10, 1), + voucher(2, 4, 2), + voucher(3, 4, 3), + voucher(4, 3, 4), + voucher(5, 1, 5), + voucher(6, 1, 6), + ]; + let selector = CoinSelector::new(context(), 100); + for amount in 1..=1_068 { + assert_ne!( + selector.select(amount, &[], &inventory, 1_000), + Err(CoinSelectionError::EmptyWallet), + "false empty wallet at amount {amount}" + ); + } + } + + /// Property sweep for the opaque boundary: confirmation must execute the + /// exact amount retained by preview, across every representable amount in + /// the source voucher. It must never reselect a different total. + #[tokio::test] + async fn preview_to_confirm_preserves_every_representable_amount() { + for amount in 1..=64_u128 { + let (service, _, _, _) = service(vec![voucher(1, 6, 1)], HashSet::new()); + let preview = service.preview(amount).await.unwrap(); + assert_eq!(preview.full_amount, amount); + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + assert_eq!(memo.total_value, amount); + Ok(()) + }, + ) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn preview_rejects_a_changed_repository_snapshot() { + let inventory = vec![voucher(1, 10, 1)]; + let (service, _coins, vouchers, _) = service(inventory, HashSet::new()); + let preview = service.preview(100).await.unwrap(); + vouchers + .set_local_state(1, VoucherLocalState::PendingTransfer) + .await + .unwrap(); + assert_eq!( + service + .confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Ok(()) } + ) + .await, + Err(RegularTransferError::BalanceChanged) + ); + } + + #[tokio::test] + async fn same_preview_confirmations_are_coalesced() { + let (service, _, _, _) = service(vec![voucher(1, 10, 1)], HashSet::new()); + let preview = service.preview(100).await.unwrap(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first = { + let service = Arc::clone(&service); + let calls = Arc::clone(&calls); + let id = preview.preview_id.clone(); + tokio::spawn(async move { + service + .confirm(&id, TransferPreviewChoice::Full, move |_| async move { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + tokio::task::yield_now().await; + Ok(()) + }) + .await + }) + }; + let second = service.confirm( + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { panic!("coalesced confirmation must not receive the memo") }, + ); + assert!(first.await.unwrap().is_ok()); + assert!(second.await.is_ok()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[test] + fn diagnostics_contain_only_public_selection_state() { + let fixture = [ + voucher(7, 10, 9), + voucher(8, 4, 9), + voucher(9, 4, 9), + voucher(10, 3, 9), + voucher(11, 1, 9), + voucher(12, 1, 9), + ]; + let rows = voucher_selection_diagnostics(&fixture, 0); + assert_eq!(rows.len(), 6); + assert_eq!(rows[0].derivation_index, 7); + assert_eq!( + rows[0].remote_state, + VoucherRemoteState::InRecycler { recycler_index: 9 } + ); + assert_eq!(rows[0].effective_privacy, EffectivePrivacy::Full); + } + + fn restart(service: &RegularCoinTransferService) -> Arc { + Arc::new(RegularCoinTransferService::new( + &[0x33; 32], + RegularCoinTransferParts { + spawner: crate::test_spawner(), + coins: Arc::clone(&service.coins), + vouchers: Arc::clone(&service.vouchers), + wal: Arc::clone(&service.wal), + allocator: Arc::clone(&service.allocator), + submitter: Arc::clone(&service.submitter), + denominations: service.denominations.clone(), + max_consolidation: service.max_consolidation, + clock: Arc::clone(&service.clock), + session_generation: service.session_generation + 1, + committer: service.committer.clone(), + }, + )) + } + + #[tokio::test] + async fn restart_after_transport_acceptance_replays_same_allocations_once() { + let (service, coins, vouchers, wal) = service(vec![voucher(1, 3, 1)], HashSet::new()); + let preview = service.preview(3).await.unwrap(); + assert_eq!(preview.max_debit_amount, 8); + let (accepted, received) = tokio::sync::oneshot::channel(); + let transfer = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .confirm_operation( + "host:payment-1", + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + accepted.send(memo.identifier()).unwrap(); + // Transport durably accepted but its acknowledgement + // has not returned when the process disappears. + std::future::pending::>().await + }, + ) + .await + }) + }; + let accepted_identifier = received.await.unwrap(); + let before = wal.load_operation("host:payment-1").await.unwrap(); + assert!( + before + .iter() + .any(|entry| entry.operation == WalOperation::TransferPrepared) + ); + assert!( + before + .iter() + .any(|entry| entry.operation == WalOperation::IntoCoins) + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::PendingTransfer + ); + transfer.abort(); + let _ = transfer.await; + let restarted = restart(&service); + restarted + .resume_operation("host:payment-1", move |memo| async move { + assert_eq!(memo.identifier(), accepted_identifier); + assert_eq!(memo.total_value, 3); + Ok(()) + }) + .await + .unwrap(); + assert!(vouchers.list().await.unwrap().is_empty()); + let after = coins.list().await.unwrap(); + assert_eq!( + after + .iter() + .filter(|coin| coin.state == CoinState::Available) + .map(|coin| context().value_in_planks(coin.exponent)) + .sum::(), + 5 + ); + let receipt = wal.load_operation("host:payment-1").await.unwrap(); + assert_eq!(receipt.len(), 1); + assert_eq!(receipt[0].operation, WalOperation::TransferCompleted); + assert_eq!( + receipt[0].payload.output_coins, + before + .iter() + .find(|entry| entry.operation == WalOperation::TransferPrepared) + .unwrap() + .payload + .output_coins + ); + restarted + .confirm_operation( + "host:payment-1", + "no-preview-after-restart", + TransferPreviewChoice::Full, + |_| async { panic!("completed operation must not hand off or debit again") }, + ) + .await + .unwrap(); + assert_eq!(coins.list().await.unwrap(), after); + } + + #[tokio::test] + async fn rejected_operation_keeps_identity_but_releases_only_its_inputs() { + let (service, _, vouchers, wal) = service(vec![voucher(1, 3, 1)], HashSet::new()); + let preview = service.preview(3).await.unwrap(); + assert_eq!( + service + .confirm_operation( + "rejected", + &preview.preview_id, + TransferPreviewChoice::Full, + |_| async { Err(()) } + ) + .await, + Err(RegularTransferError::HandoffRejected) + ); + assert_eq!( + vouchers.list().await.unwrap()[0].local_state, + VoucherLocalState::Available + ); + let restarted = restart(&service); + assert_eq!( + restarted + .resume_operation("rejected", |_| async { + panic!("rejected operation identity must not start a new debit") + }) + .await, + Err(RegularTransferError::HandoffRejected) + ); + let entries = wal.load_operation("rejected").await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].operation, WalOperation::TransferRejected); + } + + #[tokio::test] + async fn exact_operation_preserves_receipt_without_reexporting_coin_secrets() { + let (service, coins, _, wal) = service(Vec::new(), HashSet::new()); + coins + .upsert(&Coin { + derivation_index: 90, + exponent: 2, + age: Some(1), + state: CoinState::Available, + }) + .await + .unwrap(); + let preview = service.preview(4).await.unwrap(); + let observed = Arc::clone(&wal); + service + .confirm_operation( + "exact", + &preview.preview_id, + TransferPreviewChoice::Full, + move |memo| async move { + let entries = observed.load_operation("exact").await.unwrap(); + let handoff = entries + .iter() + .find(|entry| entry.operation == WalOperation::SecretHandoff) + .unwrap(); + assert_eq!(handoff.payload.input_coins[0].derivation_index, 90); + assert_eq!(memo.total_value, 4); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(coins.list().await.unwrap()[0].state, CoinState::Spent); + restart(&service) + .resume_operation("exact", |_| async { + panic!("accepted exact-coin secret must not be handed out again") + }) + .await + .unwrap(); + assert_eq!( + wal.load_operation("exact").await.unwrap()[0].operation, + WalOperation::TransferCompleted + ); + } +} diff --git a/rust/crates/truapi-coinage/src/tx_extensions.rs b/rust/crates/truapi-coinage/src/tx_extensions.rs new file mode 100644 index 000000000..017518148 --- /dev/null +++ b/rust/crates/truapi-coinage/src/tx_extensions.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-chain/src/tx_extensions.rs. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +//! Exact SCALE encoding of Coinage transaction-origin extensions. +use parity_scale_codec::{Decode, Encode}; + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AsCoinage(pub Option); + +impl AsCoinage { + pub const IDENTIFIER: &'static str = "AsCoinage"; + + pub const fn as_coin() -> Self { + Self(Some(AsCoinageInfo::AsCoin)) + } + + pub const fn infallible_unpaid_signed(nonce: u32) -> Self { + Self(Some(AsCoinageInfo::InfallibleUnpaidSigned(nonce))) + } + + pub fn unload_token_people( + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + ) -> Self { + Self(Some(AsCoinageInfo::AsUnloadTokenPeople { + proof, + period, + counter, + alias_proofs, + })) + } + + pub fn unload_token_lite_people( + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + ) -> Self { + Self(Some(AsCoinageInfo::AsUnloadTokenLitePeople { + proof, + period, + counter, + alias_proofs, + })) + } +} + +/// The People-ring half of the Coinage unload-token proof +/// (`indiv_pallet_people::types::MembershipProof`). Live metadata declares +/// the proof as a bounded byte vector; `revision` (2026-08 wipe, spec +/// 1000032) is the ring revision the proof was built against. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CoinagePeopleProof { + pub proof: Vec, + pub ring: u32, + pub revision: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum AsCoinageInfo { + /// Dispatch as the sr25519 coin which signed the extrinsic. + #[codec(index = 0)] + AsCoin, + /// Full-person free unload token. + #[codec(index = 1)] + AsUnloadTokenPeople { + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Lite-person free unload token. + #[codec(index = 2)] + AsUnloadTokenLitePeople { + proof: CoinagePeopleProof, + period: u32, + counter: u32, + alias_proofs: Vec>, + }, + /// Paid unload-token ring proof. + #[codec(index = 3)] + AsUnloadTokenPaid { + proof: Vec, + period: u32, + paid_token_ring_index: u32, + paid_token_ring_revision: u32, + alias_proofs: Vec>, + }, + /// A fee-recycler output used as the unload token. `retry_counter` + /// joined in the 2026-08 runtime (spec 1000032). + #[codec(index = 4)] + AsUnloadTokenFromOutput { + fee_recycler_value: i8, + fee_recycler_index: u32, + fee_recycler_revision: u32, + retry_counter: u8, + alias_proofs: Vec>, + }, + #[codec(index = 5)] + InfallibleUnpaidSigned(u32), +} + +#[cfg(test)] +mod tests { + use super::*; + /// `AsCoinage(None)` is the metadata default. Coin-origin calls opt in + /// with `Some(AsCoin)`: option tag 1 followed by enum variant 0. + #[test] + fn as_coinage_as_coin_encoding_is_pinned() { + assert_eq!(AsCoinage(None).encode(), vec![0]); + assert_eq!(AsCoinage::as_coin().encode(), vec![1, 0]); + assert_eq!( + AsCoinage::decode(&mut AsCoinage::as_coin().encode().as_slice()).unwrap(), + AsCoinage::as_coin() + ); + } + + #[test] + fn as_coinage_infallible_unpaid_signed_encoding_is_pinned() { + assert_eq!( + AsCoinage::infallible_unpaid_signed(7).encode(), + vec![1, 5, 7, 0, 0, 0] + ); + assert_eq!( + AsCoinage::decode(&mut AsCoinage::infallible_unpaid_signed(7).encode().as_slice()) + .unwrap(), + AsCoinage::infallible_unpaid_signed(7) + ); + } + + #[test] + fn as_coinage_people_unload_encoding_is_pinned() { + let extension = AsCoinage::unload_token_people( + CoinagePeopleProof { + proof: vec![0xAA, 0xBB], + ring: 7, + revision: 3, + }, + 9, + 2, + vec![vec![0x11], vec![0x22, 0x33]], + ); + // Option::Some, variant 1, compact proof len, proof, ring, revision, + // period, counter, compact alias-proof count and one compact length + // each. + assert_eq!( + extension.encode(), + vec![ + 1, 1, 8, 0xAA, 0xBB, 7, 0, 0, 0, 3, 0, 0, 0, 9, 0, 0, 0, 2, 0, 0, 0, 8, 4, 0x11, 8, + 0x22, 0x33, + ] + ); + assert_eq!( + AsCoinage::decode(&mut extension.encode().as_slice()).unwrap(), + extension + ); + } + + /// `retry_counter` sits between the recycler revision and the alias + /// proofs (2026-08 runtime). + #[test] + fn as_coinage_from_output_encoding_is_pinned() { + let extension = AsCoinage(Some(AsCoinageInfo::AsUnloadTokenFromOutput { + fee_recycler_value: -2, + fee_recycler_index: 5, + fee_recycler_revision: 9, + retry_counter: 4, + alias_proofs: vec![vec![0x77]], + })); + assert_eq!( + extension.encode(), + vec![1, 4, 0xFE, 5, 0, 0, 0, 9, 0, 0, 0, 4, 4, 4, 0x77] + ); + assert_eq!( + AsCoinage::decode(&mut extension.encode().as_slice()).unwrap(), + extension + ); + } +} diff --git a/rust/crates/truapi-coinage/src/voucher_location.rs b/rust/crates/truapi-coinage/src/voucher_location.rs new file mode 100644 index 000000000..cbd6a9dad --- /dev/null +++ b/rust/crates/truapi-coinage/src/voucher_location.rs @@ -0,0 +1,847 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use std::collections::{HashMap, HashSet}; +use {parking_lot::Mutex, std::sync::Arc}; + +use async_trait::async_trait; +use futures::future::AbortHandle; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use crate::model::{Voucher, VoucherPrivacyLevel, VoucherRemoteState, ring_readiness_upgraded}; +use crate::repo::VoucherRepository; + +/// A member's on-chain ring position (`Members.Members` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RingPosition { + /// Membership submitted, no ring slot yet. + Onboarding, + /// Assigned to ring `ring_index` at slot `included_at`. + Included { ring_index: u32, included_at: u32 }, +} + +/// A ring's key status (`Members.RingKeysStatus` decode). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RingStatus { + /// How many member keys the ring has actually included. + pub included_members: u32, +} + +/// One partial emission from the batched subscription. Both vectors are +/// keyed by voucher derivation index; a `None` position means the member +/// entry disappeared. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct VoucherLocationUpdate { + pub ring_positions: Vec<(u32, Option)>, + pub ring_statuses: Vec<(u32, RingStatus)>, +} + +/// The chain-subscription effect: ONE batched storage subscription +/// covering the three request families. Calling it again replaces the +/// previous subscription (the returned receiver of the old call simply +/// stops emitting once dropped). +#[async_trait] +pub trait VoucherLocationSubscriber: Send + Sync { + /// `pending` — derivation indices still needing a location (not in a + /// recycler); `included` — accumulated included positions as + /// `(derivation_index, ring_index)`; `degraded` — degraded + /// in-recycler vouchers as `(derivation_index, recycler_index)`. + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String>; +} + +#[derive(Default)] +struct LocationState { + /// The baseline: what the LIVE subscription covers. + subscribed_indices: HashSet, + /// Included positions seen so far (non-included ones are removed). + positions: HashMap, + /// Ring statuses seen so far. + statuses: HashMap, +} + +/// The service. `sync` (re)builds the subscription from current local +/// vouchers; call it at startup and after voucher saves. `stop` tears +/// the subscription down. +pub struct VoucherLocationService { + spawner: crate::Spawner, + vouchers: Arc, + chain: Arc, + state: Mutex, + task: Mutex>, +} + +impl VoucherLocationService { + pub fn new( + vouchers: Arc, + chain: Arc, + spawner: crate::Spawner, + ) -> Self { + Self { + vouchers, + chain, + state: Mutex::new(LocationState::default()), + spawner, + task: Mutex::new(None), + } + } + + /// (Re)builds the batched subscription: prune accumulated state to + /// subscribe the three families. With nothing to watch, the live + /// subscription is torn down. + pub async fn sync(self: &Arc) -> Result<(), String> { + let vouchers = self.vouchers.list().await?; + let pending: Vec = vouchers + .iter() + .filter(|v| !v.remote_state.is_in_recycler()) + .map(|v| v.derivation_index) + .collect(); + let degraded: Vec<(u32, u32)> = vouchers + .iter() + .filter_map(|v| match v.remote_state { + VoucherRemoteState::InRecycler { recycler_index } + if v.privacy == VoucherPrivacyLevel::Degraded => + { + Some((v.derivation_index, recycler_index)) + } + _ => None, + }) + .collect(); + + let included: Vec<(u32, u32)> = { + let mut state = self.state.lock(); + let current: HashSet = pending.iter().copied().collect(); + state.positions.retain(|index, _| current.contains(index)); + state.statuses.retain(|index, _| current.contains(index)); + // The baseline reflects exactly what this batch subscribes. + state.subscribed_indices = state.positions.keys().copied().collect(); + state + .positions + .iter() + .filter_map(|(index, position)| match position { + RingPosition::Included { ring_index, .. } => Some((*index, *ring_index)), + RingPosition::Onboarding => None, + }) + .collect() + }; + + if pending.is_empty() && degraded.is_empty() { + self.stop(); + return Ok(()); + } + + let receiver = self.chain.subscribe(pending, included, degraded).await?; + self.replace_task(receiver); + Ok(()) + } + + /// Tears down the live subscription and resets accumulated state. + pub fn stop(&self) { + if let Some(handle) = self.task.lock().take() { + handle.abort(); + } + *self.state.lock() = LocationState::default(); + } + + fn replace_task(self: &Arc, mut receiver: mpsc::Receiver) { + let service = Arc::clone(self); + let handle = crate::tasks::spawn_abortable(&self.spawner, async move { + while let Some(update) = receiver.recv().await { + match service.handle_update(update).await { + Ok(true) => { + let service = Arc::clone(&service); + crate::tasks::spawn_abortable(&service.spawner.clone(), async move { + if let Err(error) = service.sync().await { + warn!(error, "voucher location resubscription failed"); + } + }); + return; + } + Ok(false) => {} + Err(error) => warn!(error, "voucher location update failed"), + } + } + }); + let previous = self.task.lock().replace(handle); + if let Some(previous) = previous { + previous.abort(); + } + } + + /// One emission. Resolves `true` when a resubscription is needed + /// (and the emission was deliberately not applied). + async fn handle_update(&self, update: VoucherLocationUpdate) -> Result { + let requires_resubscription = { + let mut state = self.state.lock(); + for (index, position) in &update.ring_positions { + match position { + Some(position @ RingPosition::Included { .. }) => { + state.positions.insert(*index, *position); + } + _ => { + state.positions.remove(index); + } + } + } + for (index, status) in &update.ring_statuses { + state.statuses.insert(*index, *status); + } + let accumulated: HashSet = state.positions.keys().copied().collect(); + accumulated != state.subscribed_indices + }; + + let voucher_map: HashMap = self + .vouchers + .list() + .await? + .into_iter() + .map(|v| (v.derivation_index, v)) + .collect(); + let mut updates: HashMap = HashMap::new(); + + let (positions, statuses) = { + let state = self.state.lock(); + (state.positions.clone(), state.statuses.clone()) + }; + let waiting_for_status = positions + .keys() + .filter(|index| !statuses.contains_key(index)) + .count(); + let waiting_for_coverage = positions + .iter() + .filter(|(index, position)| { + let RingPosition::Included { included_at, .. } = position else { + return false; + }; + statuses + .get(index) + .is_some_and(|status| status.included_members <= *included_at) + }) + .count(); + if requires_resubscription { + log_reconciliation_summary( + &voucher_map, + positions.len(), + waiting_for_status, + waiting_for_coverage, + 0, + 0, + true, + ); + return Ok(true); + } + + // Pass A — positions from THIS emission, statuses possibly + // accumulated earlier. + for (index, position) in &update.ring_positions { + let Some(voucher) = updates + .get(index) + .cloned() + .or_else(|| voucher_map.get(index).cloned()) + else { + continue; + }; + match position { + Some(RingPosition::Included { + ring_index, + included_at, + }) => { + // Deferred until the ring status covers the slot. + let Some(status) = statuses.get(index) else { + continue; + }; + if status.included_members > *included_at { + updates.insert( + *index, + committed(voucher, *ring_index, status.included_members), + ); + } + } + // Present but not included (or gone) → onboarding. + _ => { + let mut voucher = voucher; + voucher.remote_state = VoucherRemoteState::Onboarding; + updates.insert(*index, voucher); + } + } + } + + // Pass B — statuses from THIS emission, positions possibly + // accumulated earlier; also upgrades degraded in-recycler + for (index, status) in &update.ring_statuses { + let Some(voucher) = updates + .get(index) + .cloned() + .or_else(|| voucher_map.get(index).cloned()) + else { + continue; + }; + match (voucher.remote_state, positions.get(index)) { + // Pending voucher whose position accumulated earlier. + ( + _, + Some(RingPosition::Included { + ring_index, + included_at, + }), + ) if !voucher.remote_state.is_in_recycler() => { + if status.included_members > *included_at { + updates.insert( + *index, + committed(voucher, *ring_index, status.included_members), + ); + } + } + // Degraded in-recycler voucher: readiness upgrade only. + (VoucherRemoteState::InRecycler { .. }, _) + if voucher.privacy == VoucherPrivacyLevel::Degraded + && ring_readiness_upgraded(status.included_members) => + { + let mut voucher = voucher; + voucher.privacy = VoucherPrivacyLevel::Full; + updates.insert(*index, voucher); + } + _ => {} + } + } + + let mut location_updates = 0usize; + let mut privacy_updates = 0usize; + for (index, voucher) in &updates { + // Only remote_state/privacy changed above; skip no-ops so a + // repeated emission does not rewrite rows. + let Some(previous) = voucher_map.get(index) else { + continue; + }; + if previous == voucher { + continue; + } + if previous.privacy == voucher.privacy { + self.vouchers + .set_remote_state(*index, voucher.remote_state) + .await?; + location_updates += 1; + } else { + // Ring readiness changes privacy as well as location. + self.vouchers.upsert(voucher).await?; + if previous.remote_state != voucher.remote_state { + location_updates += 1; + } + if previous.privacy != voucher.privacy { + privacy_updates += 1; + } + } + } + log_reconciliation_summary( + &voucher_map, + positions.len(), + waiting_for_status, + waiting_for_coverage, + location_updates, + privacy_updates, + false, + ); + Ok(false) + } +} + +fn log_reconciliation_summary( + vouchers: &HashMap, + tracked_positions: usize, + waiting_for_status: usize, + waiting_for_coverage: usize, + location_changes: usize, + privacy_changes: usize, + resubscription_required: bool, +) { + let tracked_total = vouchers.len(); + let in_recycler_total = vouchers + .values() + .filter(|voucher| voucher.remote_state.is_in_recycler()) + .count(); + let pending_location_total = tracked_total.saturating_sub(in_recycler_total); + let degraded_total = vouchers + .values() + .filter(|voucher| voucher.privacy == VoucherPrivacyLevel::Degraded) + .count(); + debug!( + tracked_total, + in_recycler_total, + pending_location_total, + degraded_total, + waiting_for_status, + waiting_for_coverage, + location_changes, + privacy_changes, + tracked_positions, + resubscription_required, + changes_scope = "current_emission", + "coinage voucher location emission reconciled" + ); +} + +fn committed(mut voucher: Voucher, ring_index: u32, included_members: u32) -> Voucher { + voucher.remote_state = VoucherRemoteState::InRecycler { + recycler_index: ring_index, + }; + if voucher.privacy == VoucherPrivacyLevel::Degraded && ring_readiness_upgraded(included_members) + { + voucher.privacy = VoucherPrivacyLevel::Full; + } + voucher +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::balance::compute_balance; + use crate::constants::MINIMUM_RING_SIZE; + use crate::denomination::DenominationBreakdownContext; + use crate::model::VoucherLocalState; + use crate::repo::InMemoryVoucherRepository; + + /// One recorded `subscribe` call's arguments. + type SubscribeCall = (Vec, Vec<(u32, u32)>, Vec<(u32, u32)>); + + /// Scripted subscriber: records each subscribe call's arguments and + /// hands out a fresh channel per call. + #[derive(Default)] + struct MockSubscriber { + calls: Mutex>, + senders: Mutex>>, + } + + impl MockSubscriber { + fn latest_sender(&self) -> mpsc::Sender { + self.senders + .lock() + .last() + .expect("no subscription yet") + .clone() + } + } + + #[async_trait] + impl VoucherLocationSubscriber for MockSubscriber { + async fn subscribe( + &self, + pending: Vec, + included: Vec<(u32, u32)>, + degraded: Vec<(u32, u32)>, + ) -> Result, String> { + self.calls.lock().push((pending, included, degraded)); + let (tx, rx) = mpsc::channel(8); + self.senders.lock().push(tx); + Ok(rx) + } + } + + fn onboarding_voucher(index: u32) -> Voucher { + Voucher { + exponent: 1, + derivation_index: index, + allocated_at_ms: 0, + ready_at_ms: 0, + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + } + } + + fn degraded_in_recycler(index: u32, recycler: u32) -> Voucher { + Voucher { + remote_state: VoucherRemoteState::InRecycler { + recycler_index: recycler, + }, + privacy: VoucherPrivacyLevel::Degraded, + ..onboarding_voucher(index) + } + } + + fn service( + vouchers: Arc, + ) -> (Arc, Arc) { + let subscriber = Arc::new(MockSubscriber::default()); + ( + Arc::new(VoucherLocationService::new( + vouchers, + Arc::clone(&subscriber) as Arc, + crate::test_spawner(), + )), + subscriber, + ) + } + + async fn voucher_state(repo: &InMemoryVoucherRepository, index: u32) -> Voucher { + repo.list() + .await + .unwrap() + .into_iter() + .find(|v| v.derivation_index == index) + .unwrap() + } + + async fn settle(mut probe: impl AsyncFnMut() -> Option) -> T { + for _ in 0..1_000 { + if let Some(value) = probe().await { + return value; + } + tokio::task::yield_now().await; + } + panic!("never settled"); + } + + #[tokio::test] + async fn two_phase_accumulation_commits_only_when_both_halves_agree() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(1), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + assert_eq!(subscriber.calls.lock().len(), 1); + assert_eq!( + subscriber.calls.lock()[0].0, + vec![1], + "pending voucher subscribed" + ); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 1, + Some(RingPosition::Included { + ring_index: 4, + included_at: 2, + }), + )], + ring_statuses: vec![], + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + assert_eq!( + subscriber.calls.lock()[1].1, + vec![(1, 4)], + "resubscription carries the accumulated included position" + ); + assert_eq!( + voucher_state(&repo, 1).await.remote_state, + VoucherRemoteState::Onboarding, + "no commit before the ring status arrives" + ); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: 3, + }, + )], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 1).await; + voucher.remote_state.is_in_recycler().then_some(voucher) + }) + .await; + assert_eq!( + committed.remote_state, + VoucherRemoteState::InRecycler { recycler_index: 4 } + ); + } + + /// Position and status arriving in the same emission still forces a + /// resubscription, since the baseline only tracks previously-known + /// included indices; the commit lands once the status is re-delivered + /// on the new subscription. + #[tokio::test] + async fn position_and_status_in_one_emission_commit_after_resync() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(7), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 7, + Some(RingPosition::Included { + ring_index: 2, + included_at: 0, + }), + )], + ring_statuses: vec![( + 7, + RingStatus { + included_members: 1, + }, + )], + }) + .await + .unwrap(); + // The first emission triggers resubscription; re-deliver the + // status on the new batch (position stays accumulated). + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 7, + RingStatus { + included_members: 1, + }, + )], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 7).await; + voucher.remote_state.is_in_recycler().then_some(voucher) + }) + .await; + assert_eq!( + committed.remote_state, + VoucherRemoteState::InRecycler { recycler_index: 2 } + ); + } + + #[tokio::test] + async fn ring_size_upgrade_boundary() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + degraded_in_recycler(1, 9), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + assert_eq!( + subscriber.calls.lock()[0].2, + vec![(1, 9)], + "degraded in-recycler voucher gets a ring-status request" + ); + + // One below the threshold: NOT upgraded. + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: MINIMUM_RING_SIZE - 1, + }, + )], + }) + .await + .unwrap(); + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert_eq!( + voucher_state(&repo, 1).await.privacy, + VoucherPrivacyLevel::Degraded + ); + + // Exactly the threshold: upgraded. + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![], + ring_statuses: vec![( + 1, + RingStatus { + included_members: MINIMUM_RING_SIZE, + }, + )], + }) + .await + .unwrap(); + let upgraded = settle(async || { + let voucher = voucher_state(&repo, 1).await; + (voucher.privacy == VoucherPrivacyLevel::Full).then_some(voucher) + }) + .await; + assert!(upgraded.remote_state.is_in_recycler(), "location untouched"); + } + + #[test] + fn minimum_ring_size_is_ten() { + assert_eq!(MINIMUM_RING_SIZE, 10); + } + + #[tokio::test] + async fn stale_accumulated_state_is_pruned_on_sync() { + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([ + onboarding_voucher(1), + onboarding_voucher(2), + ])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![( + 2, + Some(RingPosition::Included { + ring_index: 1, + included_at: 0, + }), + )], + ring_statuses: vec![], + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + + // Voucher 2 disappears locally; the next sync prunes it. + repo.remove(2).await.unwrap(); + service.sync().await.unwrap(); + let calls = subscriber.calls.lock(); + let last = calls.last().unwrap(); + assert_eq!(last.0, vec![1], "only the live voucher is pending"); + assert!(last.1.is_empty(), "stale accumulated position pruned"); + } + + /// A not-included member entry maps the voucher to `Onboarding`, and + /// an all-quiet wallet tears the subscription down. + #[tokio::test] + async fn not_included_position_marks_onboarding() { + let mut unlocated = onboarding_voucher(3); + unlocated.remote_state = VoucherRemoteState::Unlocated; + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers([unlocated])); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: vec![(3, Some(RingPosition::Onboarding))], + ring_statuses: vec![], + }) + .await + .unwrap(); + let committed = settle(async || { + let voucher = voucher_state(&repo, 3).await; + (voucher.remote_state == VoucherRemoteState::Onboarding).then_some(()) + }) + .await; + let () = committed; + + // Nothing left to watch once the voucher graduates fully. + repo.remove(3).await.unwrap(); + service.sync().await.unwrap(); + assert!(service.task.lock().is_none(), "subscription torn down"); + } + + #[tokio::test] + async fn six_vouchers_converge_across_reordered_partial_emissions() { + let recyclers = [2, 2, 3, 4, 4, 5]; + let original = (1u32..=6) + .map(|index| Voucher { + exponent: i16::try_from(index - 1).unwrap(), + derivation_index: index, + allocated_at_ms: 1_000 + i64::from(index), + ready_at_ms: 2_000 + i64::from(index), + remote_state: VoucherRemoteState::Onboarding, + local_state: VoucherLocalState::Available, + privacy: VoucherPrivacyLevel::Full, + }) + .collect::>(); + let repo = Arc::new(InMemoryVoucherRepository::with_vouchers(original.clone())); + let (service, subscriber) = service(Arc::clone(&repo)); + service.sync().await.unwrap(); + + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: (1u32..=6) + .map(|index| { + ( + index, + Some(RingPosition::Included { + ring_index: recyclers[usize::try_from(index - 1).unwrap()], + included_at: index - 1, + }), + ) + }) + .collect(), + ring_statuses: Vec::new(), + }) + .await + .unwrap(); + settle(async || (subscriber.calls.lock().len() == 2).then_some(())).await; + let mut included = subscriber.calls.lock()[1].1.clone(); + included.sort_unstable(); + assert_eq!( + included, + (1u32..=6) + .map(|index| (index, recyclers[usize::try_from(index - 1).unwrap()])) + .collect::>(), + "the replacement batch covers every newly included voucher" + ); + + for index in [6u32, 2, 5, 1, 4, 3] { + subscriber + .latest_sender() + .send(VoucherLocationUpdate { + ring_positions: Vec::new(), + ring_statuses: vec![( + index, + RingStatus { + included_members: MINIMUM_RING_SIZE, + }, + )], + }) + .await + .unwrap(); + } + + let converged = settle(async || { + let rows = repo.list().await.unwrap(); + rows.iter() + .all(|voucher| voucher.remote_state.is_in_recycler()) + .then_some(rows) + }) + .await; + for (before, after) in original.iter().zip(&converged) { + assert_eq!(after.derivation_index, before.derivation_index); + assert_eq!(after.exponent, before.exponent); + assert_eq!(after.allocated_at_ms, before.allocated_at_ms); + assert_eq!(after.ready_at_ms, before.ready_at_ms); + assert_eq!(after.local_state, before.local_state); + assert_eq!(after.privacy, before.privacy); + assert_eq!( + after.remote_state, + VoucherRemoteState::InRecycler { + recycler_index: recyclers + [usize::try_from(before.derivation_index - 1).unwrap()] + } + ); + } + + let balance = compute_balance( + &[], + &converged, + &DenominationBreakdownContext { + asset_unit: 1, + max_exponent: 10, + min_exponent: 0, + precision: 2, + }, + 10_000, + ); + assert_eq!(balance.full_privacy_planks, 63); + assert_eq!(balance.degraded_planks, 0); + } +} diff --git a/rust/crates/truapi-coinage/src/wal.rs b/rust/crates/truapi-coinage/src/wal.rs new file mode 100644 index 000000000..3e9844f96 --- /dev/null +++ b/rust/crates/truapi-coinage/src/wal.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Derived from paritytech/brevity-dozer, core/crates/brevity-coinage. +// Copyright the Brevity contributors. See NOTICE and LICENSE in this crate. + +use async_trait::async_trait; +use parity_scale_codec::{Decode, Encode, Error as CodecError, Input}; + +use crate::constants::WAL_MORTALITY_BLOCKS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalOperation { + /// Voucher unload into coins — recovery probes the expected output + /// coins on-chain. + IntoCoins, + /// Voucher offboard into an external asset — recovery checks the + /// input vouchers were consumed. + IntoExternalAsset, + /// Coin recycling — recovery checks the input coin was consumed and + /// the surplus voucher appeared. + RecycleIntoVoucher, + /// Whole coin secrets were (or may have been) handed off out of band. + /// Unlike an extrinsic, a memo has no mortal era. Recovery therefore + /// retains its input reservation while any input is still on-chain and + /// retires it only once every input is absent. The live sender deletes + /// this entry only when a handoff reports an explicit pre-acceptance + /// rejection. + SecretHandoff, + /// A regular Coinage split. It has coin inputs and expected coin + /// outputs like `IntoCoins`, but remains distinct so recovery and + /// diagnostics never infer that vouchers were involved. + Split, + /// Durable operation receipt. Prepared does not prove whether the + /// idempotent transport accepted the memo before a process stopped. + TransferPrepared, + TransferAccepted, + /// All outgoing allocations finalized successfully or were observed with + /// exact outputs and consumed inputs during recovery. Pass-through inputs + /// were retired locally. This does not itself prove recipient claim. + TransferCompleted, + /// Transport definitively rejected the memo before accepting it. + TransferRejected, +} + +impl WalOperation { + pub fn as_raw(self) -> i64 { + match self { + WalOperation::IntoCoins => 0, + WalOperation::IntoExternalAsset => 1, + WalOperation::RecycleIntoVoucher => 2, + WalOperation::SecretHandoff => 3, + WalOperation::Split => 4, + WalOperation::TransferPrepared => 5, + WalOperation::TransferAccepted => 6, + WalOperation::TransferCompleted => 7, + WalOperation::TransferRejected => 8, + } + } + + pub fn from_raw(raw: i64) -> Option { + Some(match raw { + 0 => WalOperation::IntoCoins, + 1 => WalOperation::IntoExternalAsset, + 2 => WalOperation::RecycleIntoVoucher, + 3 => WalOperation::SecretHandoff, + 4 => WalOperation::Split, + 5 => WalOperation::TransferPrepared, + 6 => WalOperation::TransferAccepted, + 7 => WalOperation::TransferCompleted, + 8 => WalOperation::TransferRejected, + _ => return None, + }) + } + + pub fn is_transfer_receipt(self) -> bool { + matches!( + self, + Self::TransferPrepared + | Self::TransferAccepted + | Self::TransferCompleted + | Self::TransferRejected + ) + } +} + +/// One derived asset referenced from a WAL payload: enough to re-derive +/// its key (index) and materialize it locally (exponent) at recovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct WalCoinRef { + pub derivation_index: u32, + pub exponent: i16, +} + +/// The SCALE payload of a WAL entry (`transfer_wal_entries.payload`): +/// inputs consumed and outputs expected by the journaled extrinsic. +#[derive(Debug, Clone, PartialEq, Eq, Default, Encode)] +pub struct WalPayload { + pub input_coins: Vec, + pub input_vouchers: Vec, + pub output_coins: Vec, + pub output_vouchers: Vec, + /// Subset of `output_coins` delivered in the recipient memo. Recovery + /// materializes these as locally `Spent`, while change remains + /// `Available`. Appending the field preserves the v1 prefix layout. + pub destination_coins: Vec, +} + +impl Decode for WalPayload { + fn decode(input: &mut I) -> Result { + let input_coins = Vec::::decode(input)?; + let input_vouchers = Vec::::decode(input)?; + let output_coins = Vec::::decode(input)?; + let output_vouchers = Vec::::decode(input)?; + // Rows written before destination/change distinction end after the + // fourth vector. Treat that exact legacy shape as all-change. + let destination_coins = match input.remaining_len()? { + Some(0) => Vec::new(), + _ => Vec::::decode(input)?, + }; + Ok(Self { + input_coins, + input_vouchers, + output_coins, + output_vouchers, + destination_coins, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckpointBlock { + Pending, + Known { number: u64, hash: [u8; 32] }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferWalEntry { + pub entry_id: String, + pub operation: WalOperation, + pub payload: WalPayload, + pub checkpoint: CheckpointBlock, + pub created_at_ms: i64, +} + +/// Hex encoding makes the operation/child boundary unambiguous even when a +/// host identifier contains punctuation. All children share this namespace. +pub fn operation_entry_id(operation_id: &str, child: &str) -> String { + format!( + "cash-operation:{}:{child}", + hex::encode(operation_id.as_bytes()) + ) +} + +impl TransferWalEntry { + pub fn belongs_to_operation(&self, operation_id: &str) -> bool { + self.entry_id + .starts_with(&operation_entry_id(operation_id, "")) + } + + /// The durable receipt identifier for any correlated child or receipt. + pub fn operation_parent_id(&self) -> Option { + let suffix = self.entry_id.strip_prefix("cash-operation:")?; + let (operation, child) = suffix.split_once(':')?; + if operation.is_empty() || child.is_empty() { + return None; + } + Some(format!("cash-operation:{operation}:parent")) + } + + pub fn is_expired(&self, finalized_block: u64) -> bool { + match self.checkpoint { + CheckpointBlock::Pending => true, + CheckpointBlock::Known { number, .. } => { + finalized_block > number.saturating_add(WAL_MORTALITY_BLOCKS) + } + } + } + + pub fn is_forked(&self, canonical_hash_at_checkpoint: Option<&[u8; 32]>) -> bool { + match (&self.checkpoint, canonical_hash_at_checkpoint) { + (CheckpointBlock::Known { hash, .. }, Some(canonical)) => hash != canonical, + _ => false, + } + } +} + +/// Durable WAL persistence. `save_all` must commit the whole batch atomically; +/// checkpoint writes must be durable before an adapter broadcasts. +#[async_trait] +pub trait WalStore: Send + Sync { + /// Inserts or durably replaces the entry with the same identifier. + async fn save(&self, entry: &TransferWalEntry) -> Result<(), String>; + + async fn save_all(&self, entries: &[TransferWalEntry]) -> Result<(), String>; + + async fn update_checkpoint( + &self, + entry_id: &str, + checkpoint: CheckpointBlock, + ) -> Result<(), String>; + + async fn load_all(&self) -> Result, String>; + + /// Includes the permanent parent receipt even after every child settles. + async fn load_operation(&self, operation_id: &str) -> Result, String> { + let prefix = operation_entry_id(operation_id, ""); + Ok(self + .load_all() + .await? + .into_iter() + .filter(|entry| entry.entry_id.starts_with(&prefix)) + .collect()) + } + + async fn delete(&self, entry_id: &str) -> Result<(), String>; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(checkpoint: CheckpointBlock) -> TransferWalEntry { + TransferWalEntry { + entry_id: "e1".into(), + operation: WalOperation::IntoCoins, + payload: WalPayload::default(), + checkpoint, + created_at_ms: 0, + } + } + + #[test] + fn mortality_boundary_is_checkpoint_plus_300() { + let known = entry(CheckpointBlock::Known { + number: 1_000, + hash: [1; 32], + }); + assert!(!known.is_expired(1_300), "at the boundary: still alive"); + assert!(known.is_expired(1_301), "one past the boundary: dead"); + let near_max = entry(CheckpointBlock::Known { + number: u64::MAX - 10, + hash: [1; 32], + }); + assert!(!near_max.is_expired(u64::MAX), "saturating add, no wrap"); + } + + #[test] + fn pending_checkpoint_is_immediately_expired() { + assert!(entry(CheckpointBlock::Pending).is_expired(0)); + } + + #[test] + fn fork_detection_compares_canonical_hash() { + let known = entry(CheckpointBlock::Known { + number: 5, + hash: [1; 32], + }); + assert!(known.is_forked(Some(&[2; 32]))); + assert!(!known.is_forked(Some(&[1; 32]))); + assert!(!known.is_forked(None), "unavailable hash is not a fork"); + assert!(!entry(CheckpointBlock::Pending).is_forked(Some(&[2; 32]))); + } + + #[test] + fn operation_raw_round_trips() { + for op in [ + WalOperation::IntoCoins, + WalOperation::IntoExternalAsset, + WalOperation::RecycleIntoVoucher, + WalOperation::SecretHandoff, + WalOperation::Split, + WalOperation::TransferPrepared, + WalOperation::TransferAccepted, + WalOperation::TransferCompleted, + WalOperation::TransferRejected, + ] { + assert_eq!(WalOperation::from_raw(op.as_raw()), Some(op)); + } + assert_eq!(WalOperation::from_raw(9), None); + } + + #[test] + fn payload_scale_round_trips() { + let payload = WalPayload { + input_coins: vec![WalCoinRef { + derivation_index: 1, + exponent: 4, + }], + input_vouchers: vec![], + output_coins: vec![ + WalCoinRef { + derivation_index: 9, + exponent: 2, + }, + WalCoinRef { + derivation_index: 10, + exponent: -1, + }, + ], + output_vouchers: vec![], + destination_coins: vec![WalCoinRef { + derivation_index: 9, + exponent: 2, + }], + }; + let encoded = payload.encode(); + assert_eq!(WalPayload::decode(&mut &encoded[..]).unwrap(), payload); + } + + #[test] + fn legacy_payload_without_destination_suffix_still_decodes() { + let legacy = ( + vec![WalCoinRef { + derivation_index: 1, + exponent: 0, + }], + Vec::::new(), + vec![WalCoinRef { + derivation_index: 2, + exponent: 1, + }], + Vec::::new(), + ) + .encode(); + let decoded = WalPayload::decode(&mut &legacy[..]).unwrap(); + assert!(decoded.destination_coins.is_empty()); + assert_eq!(decoded.output_coins[0].derivation_index, 2); + } + #[test] + fn operation_namespace_cannot_match_a_different_host_identifier() { + let mut child = entry(CheckpointBlock::Pending); + child.entry_id = operation_entry_id("wallet:payment", "unload-0"); + assert!(child.belongs_to_operation("wallet:payment")); + assert!(!child.belongs_to_operation("wallet")); + assert!(!child.belongs_to_operation("wallet:payment:unload")); + assert_eq!( + child.operation_parent_id(), + Some(operation_entry_id("wallet:payment", "parent")) + ); + child.entry_id = "legacy-split".into(); + assert_eq!(child.operation_parent_id(), None); + } +} diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index ee44dcef4..aa7e26c28 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -3,7 +3,7 @@ name = "truapi-host-cli" version = "0.23.0" edition.workspace = true description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" -license = "MIT" +license = "MIT AND AGPL-3.0-only" include = ["src/**", "js/**", "README.md", "SPEC.md"] [[bin]] @@ -11,7 +11,7 @@ name = "truapi-host" path = "src/main.rs" [target.'cfg(unix)'.dependencies] -rustix = { workspace = true, features = ["process"] } +rustix = { workspace = true, features = ["process", "fs"] } [lints.rust] unsafe_code = "forbid" @@ -31,6 +31,7 @@ flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } fs2 = { workspace = true } +getrandom = { workspace = true } hex = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } parity-scale-codec = { workspace = true, features = ["derive"] } diff --git a/rust/crates/truapi-host-cli/LICENSE b/rust/crates/truapi-host-cli/LICENSE new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/rust/crates/truapi-host-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 b/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 new file mode 100644 index 000000000..a028880c7 --- /dev/null +++ b/rust/crates/truapi-host-cli/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/rust/crates/truapi-host-cli/NOTICE b/rust/crates/truapi-host-cli/NOTICE new file mode 100644 index 000000000..553bc8d9a --- /dev/null +++ b/rust/crates/truapi-host-cli/NOTICE @@ -0,0 +1,18 @@ +Original Host CLI code retains its MIT license; see LICENSE. +The CLI links the combined signing runtime, including AGPL-3.0-only code, +and is not an MIT-only distribution. See LICENSE-AGPL-3.0. + +Coinage is a modified extraction from paritytech/brevity-dozer, revision +d504259b60b88ca42f70a8378186a714887ef19f, copyright its contributors. +Detailed provenance accompanies the truapi-coinage crate in its NOTICE. +Native HOP protocol/crypto is adapted from brevity-chat/src/hop.rs in that +same Brevity revision, under AGPL-3.0-only, with Host-private durable custody. +Native Chat wire/crypto is included in rust/crates/truapi-chat-v2, derived from +paritytech/polkavm-app-kit useragent-chat-v2 at revision +57b236fe9e740c83d0ead3d22cc7ca5a85e4ad17, under AGPL-3.0-only, with native +attachment codec and secret-zeroization modifications included in this source. + +Corresponding Source must include the exact Host source revision, all local +modifications, dependency provenance/license notices and build instructions. +Source repository: https://github.com/paritytech/host-rust-core +A repository URL alone does not provide unpublished local modifications. diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index c451f1a68..62af571a4 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -6,33 +6,30 @@ host-spec §B roles and pair over the **real People-chain statement store** (the same node an iOS/web client uses), so tests run against a real signer with no Novasama-operated dependency. -See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering -contract. +See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering contract. -Either host can be driven by a **product script** you write: a JS/TS file that -receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) -and calls it like any product would. With `--script`, the CLI runs the script -and exits with its status. Without `--script`, both roles open a full-screen -terminal UI when stdin and stdout are TTYs. +Either host can be driven by a **product script** you write: a JS/TS file that receives a global `truapi` (the +`@parity/truapi` client, scoped to a product id) and calls it like any product would. With `--script`, the CLI runs the +script and exits with its status. Without `--script`, both roles open a full-screen terminal UI when stdin and stdout +are TTYs. The headless host reports its English interface language but has no native locale/time-zone formatting engine. Its locale subscription therefore reports no time zone, and `locale.localizeTimestamps` returns a domain error explaining that local time conversion is unavailable. Products must not interpret that absence as UTC or use a fixed offset as local time. One binary, `truapi-host`: -| Command | Role | -| --- | --- | -| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | -| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, decodes copied pairing QR images or accepts deeplinks, registers statement allowance on-chain, signs. | -| `dev` | Run a local development product with the shared container loaded by a script tag. | -| `identity-check` | Probe the root and the network's `uid.` identity account for a registered username (read from the dotNS contracts on Asset Hub). | -| `register-name` | Register a full-person username via `DotnsGateway.register_name` on Asset Hub, linked to a lite username or standalone with a chat key. | -| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. On a full period it prints each occupied slot's age and which one would be replaced. | -| `pgas-check` | Diagnose (or `--submit`) an Asset Hub PGAS allowance claim: ring membership on People, whether Asset Hub has imported that ring revision, the day's first unclaimed slot, and the `Pgas.claim_pgas` extrinsic. | - -The repository's `make e2e-dotli` target builds this binary and runs the -dotli/playground Diagnosis suite with a non-interactive signing-host responder. -It verifies the initial pairing, remote signing, host sign-out, and -same-account reconnect without the external signer-bot service. +| Command | Role | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, decodes copied pairing QR images or accepts deeplinks, registers statement allowance on-chain, signs. | +| `dev` | Run a local development product with the shared container loaded by a script tag. | +| `identity-check` | Probe the root and the network's `uid.` identity account for a registered username (read from the dotNS contracts on Asset Hub). | +| `register-name` | Register a full-person username via `DotnsGateway.register_name` on Asset Hub, linked to a lite username or standalone with a chat key. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. On a full period it prints each occupied slot's age and which one would be replaced. | +| `pgas-check` | Diagnose (or `--submit`) an Asset Hub PGAS allowance claim: ring membership on People, whether Asset Hub has imported that ring revision, the day's first unclaimed slot, and the `Pgas.claim_pgas` extrinsic. | + +The repository's `make e2e-dotli` target builds this binary and runs the dotli/playground Diagnosis suite with a +non-interactive signing-host responder. It verifies the initial pairing, remote signing, host sign-out, and same-account +reconnect without the external signer-bot service. ## Install @@ -41,39 +38,32 @@ curl -fsSL https://raw.githubusercontent.com/paritytech/trinity-user-agents/main truapi-host signing-host ``` -Prebuilt binaries exist for `aarch64-apple-darwin`, -`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`. The Linux -binaries are statically linked, so they run on any distribution. The installer -puts each version in `$XDG_DATA_HOME/truapi-host/versions//` and -symlinks `~/.local/bin/truapi-host` through a `current` link, so an update only -moves that one link. - -| Variable | Effect | -| --- | --- | -| `TRUAPI_HOST_VERSION` | Install this version instead of the current stable one. | -| `TRUAPI_HOST_INSTALL_DIR` | Version store, default `$XDG_DATA_HOME/truapi-host`. | -| `TRUAPI_HOST_BIN_DIR` | Directory the `PATH` symlink goes in, default `~/.local/bin`. | - -Product scripts (`--script`, `/script`) work from an installed binary: the -archive ships a self-contained `runner.js` with the `@parity/truapi` client and -shared web API permission checks bundled in, plus `script-types.d.ts` for the -globals it injects. You still need `bun` on `PATH`, since it executes the runner -and your script. The development container is shipped separately as -`sandbox-assets/container.js`. - -Product frames use a private, per-process WebSocket-over-Unix-domain-socket by -default, so starting either host does not reserve a TCP port. Pass -`--frame-listen 127.0.0.1:0` to expose an ordinary loopback WebSocket instead; -this is required for browser clients, which cannot open filesystem sockets. +Prebuilt binaries exist for `aarch64-apple-darwin`, `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`. The +Linux binaries are statically linked, so they run on any distribution. The installer puts each version in +`$XDG_DATA_HOME/truapi-host/versions//` and symlinks `~/.local/bin/truapi-host` through a `current` link, so an +update only moves that one link. + +| Variable | Effect | +| ------------------------- | ------------------------------------------------------------- | +| `TRUAPI_HOST_VERSION` | Install this version instead of the current stable one. | +| `TRUAPI_HOST_INSTALL_DIR` | Version store, default `$XDG_DATA_HOME/truapi-host`. | +| `TRUAPI_HOST_BIN_DIR` | Directory the `PATH` symlink goes in, default `~/.local/bin`. | + +Product scripts (`--script`, `/script`) work from an installed binary: the archive ships a self-contained `runner.js` +with the `@parity/truapi` client and shared web API permission checks bundled in, plus `script-types.d.ts` for the +globals it injects. You still need `bun` on `PATH`, since it executes the runner and your script. The development +container is shipped separately as `sandbox-assets/container.js`. + +Product frames use a private, per-process WebSocket-over-Unix-domain-socket by default, so starting either host does not +reserve a TCP port. Pass `--frame-listen 127.0.0.1:0` to expose an ordinary loopback WebSocket instead; this is required +for browser clients, which cannot open filesystem sockets. ### Staying current -A managed install checks for a new release at most once every four hours, -alongside whatever command you ran rather than delaying it, and installs it into -the version store. A command that finishes first waits for the download, so even -a one-shot run lands the update; it prints a line while downloading. The running -process is never replaced underneath itself: the new version takes effect the -next time you start `truapi-host`, and the CLI says so when one is waiting. +A managed install checks for a new release at most once every four hours, alongside whatever command you ran rather than +delaying it, and installs it into the version store. A command that finishes first waits for the download, so even a +one-shot run lands the update; it prints a line while downloading. The running process is never replaced underneath +itself: the new version takes effect the next time you start `truapi-host`, and the CLI says so when one is waiting. Every archive is checked against its published SHA-256 before it is unpacked. ```bash @@ -82,46 +72,37 @@ truapi-host --version # what is running TRUAPI_HOST_NO_UPDATE=1 ... # never check ``` -Binaries that the installer did not put in place — a `cargo install` copy, a -source build, a distro package — are detected and never modified. A local build -says so on every run and prints the install command, since it otherwise looks +Binaries that the installer did not put in place — a `cargo install` copy, a source build, a distro package — are +detected and never modified. A local build says so on every run and prints the install command, since it otherwise looks identical to a managed install that is quietly up to date. -The two install routes shadow each other depending on `PATH` order, so each one -clears the other: installing removes a `cargo install` copy, and -`make headless install` removes a prebuilt install first. To remove a prebuilt -install without replacing it: +The two install routes shadow each other depending on `PATH` order, so each one clears the other: installing removes a +`cargo install` copy, and `make headless install` removes a prebuilt install first. To remove a prebuilt install without +replacing it: ```bash curl -fsSL https://raw.githubusercontent.com/paritytech/trinity-user-agents/main/scripts/truapi-host-installer.sh | bash -s -- --uninstall ``` -`make e2e-cli-update` exercises the whole chain locally: it packages the binary, -serves a fake release over loopback, installs it with the real installer, and -updates it. Nothing contacts GitHub. +`make e2e-cli-update` exercises the whole chain locally: it packages the binary, serves a fake release over loopback, +installs it with the real installer, and updates it. Nothing contacts GitHub. ### State directory -Reserved identities derive under `uid.paseo` / `peopl.paseo` on -`paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on `previewnet`. -Managed host state lives under `/v2`, including accounts, -sessions, pairings, core and product storage, and log -preferences. The CLI appends `v2` to both the default base path and a path set -through `--base-path` or `TRUAPI_HOST_BASE_PATH`. For example, -`--base-path ./truapi-host-paseo` uses `./truapi-host-paseo/v2`. -New script projects live separately under `/scripts` so clearing -host sessions does not delete them. - -The CLI leaves previous host state outside `v2` untouched and unused, and starts -normal onboarding automatically. There is no state migration. Pair devices -again; sign out first on any paired host that still uses an old identity. -Existing `.dot` personhood membership does not transfer to the new keys. +Reserved identities derive under `uid.paseo` / `peopl.paseo` on `paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on +`previewnet`. Managed host state lives under `/v2`, including accounts, sessions, pairings, core and product +storage, and log preferences. The CLI appends `v2` to both the default base path and a path set through `--base-path` or +`TRUAPI_HOST_BASE_PATH`. For example, `--base-path ./truapi-host-paseo` uses `./truapi-host-paseo/v2`. New script +projects live separately under `/scripts` so clearing host sessions does not delete them. + +The CLI leaves previous host state outside `v2` untouched and unused, and starts normal onboarding automatically. There +is no state migration. Pair devices again; sign out first on any paired host that still uses an old identity. Existing +`.dot` personhood membership does not transfer to the new keys. ### Building from source -A source build resolves the product-script runner from the checkout, so it also -needs the generated `@parity/truapi` sources. (An installed release ships its -own bundled runner and does not.) Dev also needs the container bundle generated +A source build resolves the product-script runner from the checkout, so it also needs the generated `@parity/truapi` +sources. (An installed release ships its own bundled runner and does not.) Dev also needs the container bundle generated by `make headless` or `make cli-runner`. To build and install the CLI yourself: ```bash @@ -134,62 +115,52 @@ build tools and regenerates Rust and TypeScript sources on every run, including ### Raw proof contexts (development only) -A product can bind a ring-VRF proof to 32 bytes of its choosing instead of a -product-namespaced context by calling `development_createAccountProof` from -`@parity/truapi`; the signing host honours it as is. Yet to be removed before a +A product can bind a ring-VRF proof to 32 bytes of its choosing instead of a product-namespaced context by calling +`development_createAccountProof` from `@parity/truapi`; the signing host honours it as is. Yet to be removed before a production release. ### Browser products -`truapi-host dev` is one command for "run this product as if it were inside a -host". It starts a signing host on loopback, waits for the signer, then runs the -wrapped development command with the host already live: +`truapi-host dev` is one command for "run this product as if it were inside a host". It starts a signing host on +loopback, waits for the signer, then runs the wrapped development command with the host already live: ```bash truapi-host dev -- yarn dev ``` -The product reaches it through a development-only tag, which the host serves -itself: +The product reaches it through a development-only tag, which the host serves itself: ```jsx -{process.env.NODE_ENV === "development" && ( -