diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f37c2514..7ce8f9da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,47 @@ jobs: working-directory: explorer run: npm run build + dart: + name: Dart client (analyze + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable + with: + toolchain: stable + + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + with: + toolchain: nightly + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # TODO: pin to a commit SHA to match the repo's action-pinning convention. + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Generate the Dart client from the Rust trait surface + run: | + cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json + cargo run -p truapi-codegen -- \ + --input target/doc/truapi.json \ + --output "$RUNNER_TEMP/truapi-ts-throwaway" \ + --dart-output dart/truapi/lib/src/generated \ + --dart-host-output dart/truapi/lib/src/generated + cargo run -p truapi --example wire_vectors -- dart/truapi/test/wire_vectors.json + + - name: Analyze + test + working-directory: dart/truapi + run: | + dart pub get + dart format --output=none --set-exit-if-changed lib test + dart analyze --fatal-infos + dart test + e2e: name: E2E (playground inside dotli) # Temporarily disabled while the dotli playground smoke test is stabilized. @@ -279,7 +320,7 @@ jobs: name: CI Status if: always() runs-on: ubuntu-latest - needs: [rust, licenses, codegen, ts-client, playground, explorer, e2e] + needs: [rust, licenses, codegen, ts-client, playground, explorer, dart, e2e] steps: - name: Check all jobs run: | @@ -290,6 +331,7 @@ jobs: "${{ needs.ts-client.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" + "${{ needs.dart.result }}" "${{ needs.e2e.result }}" ) for r in "${results[@]}"; do diff --git a/CLAUDE.md b/CLAUDE.md index f7624763..b140d975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,16 +9,28 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot ``` rust/crates/ truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 - truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher + truapi-codegen/ rustdoc JSON → TypeScript client + Dart host + Rust dispatcher + (ts.rs and dart.rs are sibling emitters over the same IR) truapi-macros/ #[wire(id = N)] proc-macro js/packages/ truapi/ @parity/truapi TS package; generated TS lives under ignored paths +dart/ + truapi/ Dart/Flutter host; generated Dart under lib/src/generated (ignored) playground/ Next.js interactive playground; deploys to truapi-playground.dot hosts/dotli/ dotli submodule docs/ design docs, RFCs, feature proposals -scripts/codegen.sh regenerate the TS client from the Rust crate +scripts/codegen.sh regenerate the TS client + Dart host from the Rust crate ``` +The Dart package is **host-only** (products are web apps that use the TS/JS +client). `truapi-codegen` emits the shared types/wire-table (`--dart-output`) and +the host dispatcher (`--dart-host-output`); no Dart client facade is generated (see +`rust/crates/truapi-codegen/src/dart.rs`). The entry point is +`package:truapi/truapi.dart` (implement `TruapiHostHandlers`, wire with +`createTruapiServer`). Golden cross-language wire vectors are produced by `cargo run +-p truapi --example wire_vectors`. Run `make dart` to regenerate, refresh vectors, +analyze, and test. Design notes: `docs/design/dart-flutter-support.md`. + ## Code style - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). diff --git a/Makefile b/Makefile index c8bc371c..e006174f 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,12 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground dev matrix explorer +.PHONY: help setup build codegen test check playground dev matrix explorer dart dart-scaffold TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground EXPLORER := explorer +DART := dart/truapi DOTLI := hosts/dotli # `make dev DEBUG=1` runs dotli with VITE_APP_DEBUG=true to log every wire frame. @@ -61,3 +62,15 @@ matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-repo explorer: ## Run the explorer dev server standalone at http://localhost:5181. cd $(EXPLORER) && npx vite --base / --port 5181 + +dart: ## Regenerate + analyze + test the Dart client/host (regen golden vectors too). + ./scripts/codegen.sh + cargo run -p truapi --example wire_vectors -- $(DART)/test/wire_vectors.json + cd $(DART) && dart pub get && dart analyze && dart test + +dart-scaffold: ## (Re)generate the one-shot host scaffold (overwrites example/host_scaffold.dart). + cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json + cargo run -p truapi-codegen -- --input target/doc/truapi.json \ + --output /tmp/truapi-ts-throwaway \ + --dart-host-scaffold-output $(DART)/example/host_scaffold.dart + cd $(DART) && dart format example/host_scaffold.dart diff --git a/README.md b/README.md index a08d9483..f632e39a 100644 --- a/README.md +++ b/README.md @@ -55,16 +55,22 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full ``` rust/crates/ truapi/ Rust trait and type definitions (v01, v02) - truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher + truapi-codegen/ rustdoc JSON to TypeScript client + Dart host + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro js/packages/ truapi/ @parity/truapi TypeScript client +dart/ + truapi/ Dart/Flutter host (generated surface under lib/src/generated) playground/ Interactive Next.js playground (truapi-playground.dot) hosts/dotli/ dotli host, vendored as a submodule docs/ Design docs, RFCs, feature proposals -scripts/codegen.sh Regenerate the TS client from the Rust source +scripts/codegen.sh Regenerate the TS client + Dart host from the Rust source ``` +A Dart/Flutter host is generated from the same Rust trait surface; see +[`dart/truapi/README.md`](dart/truapi/README.md) and the design doc +[`docs/design/dart-flutter-support.md`](docs/design/dart-flutter-support.md). + ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. diff --git a/dart/truapi/.gitignore b/dart/truapi/.gitignore new file mode 100644 index 00000000..d245679c --- /dev/null +++ b/dart/truapi/.gitignore @@ -0,0 +1,12 @@ +# Dart/pub +.dart_tool/ +.packages +pubspec.lock +build/ + +# Generated by truapi-codegen (regenerate with scripts/codegen.sh). +lib/src/generated/ + +# Golden wire vectors generated from Rust (regenerate with `make dart` / +# `cargo run -p truapi --example wire_vectors`). +test/wire_vectors.json diff --git a/dart/truapi/README.md b/dart/truapi/README.md new file mode 100644 index 00000000..e7baeb61 --- /dev/null +++ b/dart/truapi/README.md @@ -0,0 +1,140 @@ +# truapi (Dart) + +The Dart/Flutter **host** for the **TrUAPI** protocol — the typed, SCALE-encoded +API surface a Polkadot host exposes to the products running inside it. + +This package is **host-only**: products are web apps and use the TS/JS +`@parity/truapi` client over the normal web route. The Dart side implements the +host that those products call. + +The host surface is **generated from the Rust trait definitions** in this repo +(`rust/crates/truapi`) by `truapi-codegen`, so the Rust crate stays the single +source of truth. This package contributes the hand-written runtime (SCALE codec, +host dispatcher, providers) and the generated surface under `lib/src/generated/`. + +> Prototype / reference implementation. Not audited. Use at your own risk. + +## Layout + +``` +lib/ + truapi.dart # host barrel: runtime + generated dispatcher + types + src/ + scale.dart # SCALE codec primitives & combinators + result.dart # sealed Result + transport.dart # Provider, frame codec, wire-id types, SubscriptionInterrupted + providers/ + loopback_provider.dart # in-memory channel for tests/local harnesses + host/ + host_server.dart # host dispatcher runtime (createHostServer, CallContext, …) + generated/ # git-ignored — produced by truapi-codegen + types.dart # data classes, enums, sealed unions + codecs + wire_table.dart # per-method frame-id constants + host.dart # per-service handler interfaces, build*Entries, createTruapiServer + index.dart # types + wire table barrel +test/ + scale_test.dart # SCALE vectors + round-trips + wire_vectors_test.dart # cross-language parity vs Rust golden vectors + host_server_test.dart # host dispatch over loopback, driven by raw wire frames +``` + +## Usage + +Implement one typed handler group per service and wire them to a `Provider`. +Handlers receive and return the inner protocol types; the generated dispatch +entries handle versioned wire wrapping, SCALE encode/decode, and the +request/subscription frame lifecycle. + +```dart +import 'package:truapi/truapi.dart'; + +class MyAccountHandlers implements AccountHostHandlers { + @override + Future> getAccount( + CallContext ctx, + HostAccountGetRequest request, + ) async { + final account = myStore.lookup(request.productAccountId); + return Ok(HostAccountGetResponse(account: account)); + } + + @override + Stream connectionStatusSubscribe( + CallContext ctx, + ) => + myConnectionStatusStream; // each event is forwarded as a receive frame + // … the remaining AccountHostHandlers methods +} + +// Implement TruapiHostHandlers (one getter per service) and start the server: +final server = createTruapiServer(provider, MyHostHandlers()); +// … later: server.dispose(); +``` + +A subscription handler returns a `Stream`. For a fallible +(`Result`) method, end the stream with a typed interrupt by adding +a `SubscriptionInterrupted(reason)` error; otherwise the stream's normal +completion ends the subscription. To compose a server from a subset of services +(or add custom entries), use the public per-service `buildEntries(...)` +builders with `createHostServer(provider, [...entries])`. + +`Result` is a sealed type — pattern-match with `switch`, or use +`result.isOk` / `result.okOrNull` / `result.match(...)`. + +### Scaffold + +[`example/host_scaffold.dart`](example/host_scaffold.dart) is a ready-to-edit +`ScaffoldHostHandlers` implementing **every** service method with +`throw UnimplementedError(...)`, plus heuristic notes on which services are +likely backed by a light client (smoldot), the wallet, or host-local state. +Copy it into your host app and fill in each method. Regenerate it any time the +trait surface changes: + +```bash +make dart-scaffold # overwrites example/host_scaffold.dart +``` + +## Type mapping + +| Rust | Dart | +|---|---| +| `bool`, `u8`/`u16`/`u32`, `i8`/`i16`/`i32` | `bool`, `int` | +| `u64`/`u128`/`i64`/`i128`, `Compact<_>` | `BigInt` | +| `String` | `String` | +| `Vec`, `[u8; N]` | `Uint8List` | +| `Vec` | `List` | +| `Option` | `T?` | +| `OptionBool` | `bool?` | +| tuple `(A, B)` | record `(A, B)` | +| struct | immutable class | +| unit-only enum | Dart `enum` | +| enum with payloads | `sealed class` + variant classes | +| `Result` | `Result` (`Ok` / `Err`) | +| `Subscription` | `Stream` | + +## Transport + +Everything above the `Provider` interface is transport-agnostic; only the +provider differs per host. This package ships `LoopbackChannel` (in-memory, for +tests). The real host implements `Provider` over the channel it exposes to its +products (e.g. a Flutter `BasicMessageChannel`, a local socket/WebSocket, +or stdio) — see the design doc's §6.1. + +## Regenerate + +From the repo root: + +```bash +make dart # codegen + golden vectors + analyze + test +# or just the codegen step: +./scripts/codegen.sh +``` + +## Develop + +```bash +dart pub get +dart analyze +dart test +dart format . +``` diff --git a/dart/truapi/analysis_options.yaml b/dart/truapi/analysis_options.yaml new file mode 100644 index 00000000..42ccb84f --- /dev/null +++ b/dart/truapi/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true + errors: + # Generated code is held to the same bar; real issues still surface. + todo: ignore + exclude: + - lib/src/generated/** + +linter: + rules: + - prefer_final_locals + - prefer_const_constructors + - avoid_print diff --git a/dart/truapi/example/host_scaffold.dart b/dart/truapi/example/host_scaffold.dart new file mode 100644 index 00000000..3796eab3 --- /dev/null +++ b/dart/truapi/example/host_scaffold.dart @@ -0,0 +1,411 @@ +// TrUAPI host scaffold — a starting point for a Flutter/Dart host. +// +// Copy this into your host app and replace each `throw UnimplementedError(...)` +// with your host logic, then start the server with your transport Provider: +// +// final server = createTruapiServer(provider, ScaffoldHostHandlers()); +// // ... later: server.dispose(); +// +// Backing notes (heuristic — adjust to your host): +// smoldart (light client, when ready): Chain, Preimage, Payment, CoinPayment, +// StatementStore +// host-local: System, Theme, LocalStorage, +// Notifications, Permissions, Entropy, +// ResourceAllocation +// wallet / keystore: Account, Signing +// messaging: Chat +// +// This file is a one-shot scaffold (not regenerated by the build): edit freely. + +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; + +/// Account lookup, aliasing, and proof generation. +class AccountHandlers implements AccountHostHandlers { + @override + Stream connectionStatusSubscribe( + CallContext ctx) => + throw UnimplementedError( + 'Account.connectionStatusSubscribe: not implemented'); + @override + Future> getAccount( + CallContext ctx, HostAccountGetRequest request) => + throw UnimplementedError('Account.getAccount: not implemented'); + @override + Future> + getAccountAlias(CallContext ctx, HostAccountGetAliasRequest request) => + throw UnimplementedError('Account.getAccountAlias: not implemented'); + @override + Future> + createAccountProof( + CallContext ctx, HostAccountCreateProofRequest request) => + throw UnimplementedError( + 'Account.createAccountProof: not implemented'); + @override + Future> + getLegacyAccounts(CallContext ctx) => throw UnimplementedError( + 'Account.getLegacyAccounts: not implemented'); + @override + Future> getUserId( + CallContext ctx) => + throw UnimplementedError('Account.getUserId: not implemented'); + @override + Future> requestLogin( + CallContext ctx, HostRequestLoginRequest request) => + throw UnimplementedError('Account.requestLogin: not implemented'); +} + +/// Chain interaction methods. +class ChainHandlers implements ChainHostHandlers { + @override + Stream followHeadSubscribe( + CallContext ctx, RemoteChainHeadFollowRequest request) => + throw UnimplementedError('Chain.followHeadSubscribe: not implemented'); + @override + Future> getHeadHeader( + CallContext ctx, RemoteChainHeadHeaderRequest request) => + throw UnimplementedError('Chain.getHeadHeader: not implemented'); + @override + Future> getHeadBody( + CallContext ctx, RemoteChainHeadBodyRequest request) => + throw UnimplementedError('Chain.getHeadBody: not implemented'); + @override + Future> getHeadStorage( + CallContext ctx, RemoteChainHeadStorageRequest request) => + throw UnimplementedError('Chain.getHeadStorage: not implemented'); + @override + Future> callHead( + CallContext ctx, RemoteChainHeadCallRequest request) => + throw UnimplementedError('Chain.callHead: not implemented'); + @override + Future> unpinHead( + CallContext ctx, RemoteChainHeadUnpinRequest request) => + throw UnimplementedError('Chain.unpinHead: not implemented'); + @override + Future> continueHead( + CallContext ctx, RemoteChainHeadContinueRequest request) => + throw UnimplementedError('Chain.continueHead: not implemented'); + @override + Future> stopHeadOperation( + CallContext ctx, RemoteChainHeadStopOperationRequest request) => + throw UnimplementedError('Chain.stopHeadOperation: not implemented'); + @override + Future> + getSpecGenesisHash( + CallContext ctx, RemoteChainSpecGenesisHashRequest request) => + throw UnimplementedError('Chain.getSpecGenesisHash: not implemented'); + @override + Future> + getSpecChainName( + CallContext ctx, RemoteChainSpecChainNameRequest request) => + throw UnimplementedError('Chain.getSpecChainName: not implemented'); + @override + Future> + getSpecProperties( + CallContext ctx, RemoteChainSpecPropertiesRequest request) => + throw UnimplementedError('Chain.getSpecProperties: not implemented'); + @override + Future> + broadcastTransaction(CallContext ctx, + RemoteChainTransactionBroadcastRequest request) => + throw UnimplementedError( + 'Chain.broadcastTransaction: not implemented'); + @override + Future> stopTransaction( + CallContext ctx, RemoteChainTransactionStopRequest request) => + throw UnimplementedError('Chain.stopTransaction: not implemented'); +} + +/// Chat room, bot, and message APIs. +class ChatHandlers implements ChatHostHandlers { + @override + Future> + createRoom(CallContext ctx, HostChatCreateRoomRequest request) => + throw UnimplementedError('Chat.createRoom: not implemented'); + @override + Future> + registerBot(CallContext ctx, HostChatRegisterBotRequest request) => + throw UnimplementedError('Chat.registerBot: not implemented'); + @override + Stream listSubscribe(CallContext ctx) => + throw UnimplementedError('Chat.listSubscribe: not implemented'); + @override + Future> + postMessage(CallContext ctx, HostChatPostMessageRequest request) => + throw UnimplementedError('Chat.postMessage: not implemented'); + @override + Stream actionSubscribe(CallContext ctx) => + throw UnimplementedError('Chat.actionSubscribe: not implemented'); + @override + Stream customMessageRenderSubscribe(CallContext ctx, + ProductChatCustomMessageRenderSubscribeRequest request) => + throw UnimplementedError( + 'Chat.customMessageRenderSubscribe: not implemented'); +} + +/// CoinPayment operations. +/// +/// RFC 0017 describes `Resolvable` values for long-running operations. +/// TrUAPI represents those as subscriptions whose items are the RFC status +/// updates. +class CoinPaymentHandlers implements CoinPaymentHostHandlers { + @override + Future> + createPurse(CallContext ctx, HostCoinPaymentCreatePurseRequest request) => + throw UnimplementedError('CoinPayment.createPurse: not implemented'); + @override + Future> + queryPurse(CallContext ctx, HostCoinPaymentQueryPurseRequest request) => + throw UnimplementedError('CoinPayment.queryPurse: not implemented'); + @override + Stream rebalancePurse( + CallContext ctx, HostCoinPaymentRebalancePurseRequest request) => + throw UnimplementedError('CoinPayment.rebalancePurse: not implemented'); + @override + Stream deletePurse( + CallContext ctx, HostCoinPaymentDeletePurseRequest request) => + throw UnimplementedError('CoinPayment.deletePurse: not implemented'); + @override + Future> + createReceivable(CallContext ctx, + HostCoinPaymentCreateReceivableRequest request) => + throw UnimplementedError( + 'CoinPayment.createReceivable: not implemented'); + @override + Future> + createCheque( + CallContext ctx, HostCoinPaymentCreateChequeRequest request) => + throw UnimplementedError('CoinPayment.createCheque: not implemented'); + @override + Stream deposit( + CallContext ctx, HostCoinPaymentDepositRequest request) => + throw UnimplementedError('CoinPayment.deposit: not implemented'); + @override + Stream refund( + CallContext ctx, HostCoinPaymentRefundRequest request) => + throw UnimplementedError('CoinPayment.refund: not implemented'); + @override + Stream listenForPayment( + CallContext ctx, HostCoinPaymentListenForRequest request) => + throw UnimplementedError('CoinPayment.listenForPayment: not implemented'); +} + +/// Deterministic entropy derivation. +class EntropyHandlers implements EntropyHostHandlers { + @override + Future> derive( + CallContext ctx, HostDeriveEntropyRequest request) => + throw UnimplementedError('Entropy.derive: not implemented'); +} + +/// Local key/value storage scoped to the calling product. +class LocalStorageHandlers implements LocalStorageHostHandlers { + @override + Future> read( + CallContext ctx, HostLocalStorageReadRequest request) => + throw UnimplementedError('LocalStorage.read: not implemented'); + @override + Future> write( + CallContext ctx, HostLocalStorageWriteRequest request) => + throw UnimplementedError('LocalStorage.write: not implemented'); + @override + Future> clear( + CallContext ctx, HostLocalStorageClearRequest request) => + throw UnimplementedError('LocalStorage.clear: not implemented'); +} + +/// Notification methods for locally-rendered push notifications. +class NotificationsHandlers implements NotificationsHostHandlers { + @override + Future> + sendPushNotification( + CallContext ctx, HostPushNotificationRequest request) => + throw UnimplementedError( + 'Notifications.sendPushNotification: not implemented'); + @override + Future> cancelPushNotification( + CallContext ctx, HostPushNotificationCancelRequest request) => + throw UnimplementedError( + 'Notifications.cancelPushNotification: not implemented'); +} + +/// Payment request and balance/status subscription methods. +class PaymentHandlers implements PaymentHostHandlers { + @override + Stream balanceSubscribe( + CallContext ctx, HostPaymentBalanceSubscribeRequest request) => + throw UnimplementedError('Payment.balanceSubscribe: not implemented'); + @override + Future> request( + CallContext ctx, HostPaymentRequest request) => + throw UnimplementedError('Payment.request: not implemented'); + @override + Stream statusSubscribe( + CallContext ctx, HostPaymentStatusSubscribeRequest request) => + throw UnimplementedError('Payment.statusSubscribe: not implemented'); + @override + Future> topUp( + CallContext ctx, HostPaymentTopUpRequest request) => + throw UnimplementedError('Payment.topUp: not implemented'); +} + +/// Permission request methods. +class PermissionsHandlers implements PermissionsHostHandlers { + @override + Future> + requestDevicePermission( + CallContext ctx, HostDevicePermissionRequest request) => + throw UnimplementedError( + 'Permissions.requestDevicePermission: not implemented'); + @override + Future> + requestRemotePermission( + CallContext ctx, RemotePermissionRequest request) => + throw UnimplementedError( + 'Permissions.requestRemotePermission: not implemented'); +} + +/// Preimage lookup and submission methods. +class PreimageHandlers implements PreimageHostHandlers { + @override + Stream lookupSubscribe( + CallContext ctx, RemotePreimageLookupSubscribeRequest request) => + throw UnimplementedError('Preimage.lookupSubscribe: not implemented'); + @override + Future> submit( + CallContext ctx, Uint8List request) => + throw UnimplementedError('Preimage.submit: not implemented'); +} + +/// Resource pre-allocation (allowance management). +class ResourceAllocationHandlers implements ResourceAllocationHostHandlers { + @override + Future> + request(CallContext ctx, HostRequestResourceAllocationRequest request) => + throw UnimplementedError( + 'ResourceAllocation.request: not implemented'); +} + +/// Signing operations. +class SigningHandlers implements SigningHostHandlers { + @override + Future> + createTransaction(CallContext ctx, ProductAccountTxPayload request) => + throw UnimplementedError( + 'Signing.createTransaction: not implemented'); + @override + Future< + Result> + createTransactionWithLegacyAccount( + CallContext ctx, LegacyAccountTxPayload request) => + throw UnimplementedError( + 'Signing.createTransactionWithLegacyAccount: not implemented'); + @override + Future> + signRawWithLegacyAccount( + CallContext ctx, HostSignRawWithLegacyAccountRequest request) => + throw UnimplementedError( + 'Signing.signRawWithLegacyAccount: not implemented'); + @override + Future> + signPayloadWithLegacyAccount(CallContext ctx, + HostSignPayloadWithLegacyAccountRequest request) => + throw UnimplementedError( + 'Signing.signPayloadWithLegacyAccount: not implemented'); + @override + Future> signRaw( + CallContext ctx, HostSignRawRequest request) => + throw UnimplementedError('Signing.signRaw: not implemented'); + @override + Future> signPayload( + CallContext ctx, HostSignPayloadRequest request) => + throw UnimplementedError('Signing.signPayload: not implemented'); +} + +/// Statement store methods. +class StatementStoreHandlers implements StatementStoreHostHandlers { + @override + Stream subscribe( + CallContext ctx, RemoteStatementStoreSubscribeRequest request) => + throw UnimplementedError('StatementStore.subscribe: not implemented'); + @override + Future< + Result> + createProof(CallContext ctx, + RemoteStatementStoreCreateProofRequest request) => + throw UnimplementedError( + 'StatementStore.createProof: not implemented'); + @override + Future< + Result> + createProofAuthorized(CallContext ctx, Statement request) => + throw UnimplementedError( + 'StatementStore.createProofAuthorized: not implemented'); + @override + Future> submit( + CallContext ctx, SignedStatement request) => + throw UnimplementedError('StatementStore.submit: not implemented'); +} + +/// General-purpose TrUAPI methods for handshake, feature detection, +/// and navigation. +class SystemHandlers implements SystemHostHandlers { + @override + Future> handshake( + CallContext ctx, HostHandshakeRequest request) => + throw UnimplementedError('System.handshake: not implemented'); + @override + Future> featureSupported( + CallContext ctx, HostFeatureSupportedRequest request) => + throw UnimplementedError('System.featureSupported: not implemented'); + @override + Future> navigateTo( + CallContext ctx, HostNavigateToRequest request) => + throw UnimplementedError('System.navigateTo: not implemented'); +} + +/// Host theme subscription. +class ThemeHandlers implements ThemeHostHandlers { + @override + Stream subscribe(CallContext ctx) => + throw UnimplementedError('Theme.subscribe: not implemented'); +} + +/// Aggregates every service handler. Pass to `createTruapiServer`. +class ScaffoldHostHandlers implements TruapiHostHandlers { + @override + final AccountHostHandlers account = AccountHandlers(); + @override + final ChainHostHandlers chain = ChainHandlers(); + @override + final ChatHostHandlers chat = ChatHandlers(); + @override + final CoinPaymentHostHandlers coinPayment = CoinPaymentHandlers(); + @override + final EntropyHostHandlers entropy = EntropyHandlers(); + @override + final LocalStorageHostHandlers localStorage = LocalStorageHandlers(); + @override + final NotificationsHostHandlers notifications = NotificationsHandlers(); + @override + final PaymentHostHandlers payment = PaymentHandlers(); + @override + final PermissionsHostHandlers permissions = PermissionsHandlers(); + @override + final PreimageHostHandlers preimage = PreimageHandlers(); + @override + final ResourceAllocationHostHandlers resourceAllocation = + ResourceAllocationHandlers(); + @override + final SigningHostHandlers signing = SigningHandlers(); + @override + final StatementStoreHostHandlers statementStore = StatementStoreHandlers(); + @override + final SystemHostHandlers system = SystemHandlers(); + @override + final ThemeHostHandlers theme = ThemeHandlers(); +} diff --git a/dart/truapi/lib/src/host/host_server.dart b/dart/truapi/lib/src/host/host_server.dart new file mode 100644 index 00000000..90b9b8cc --- /dev/null +++ b/dart/truapi/lib/src/host/host_server.dart @@ -0,0 +1,329 @@ +/// Host-side dispatcher for the TrUAPI wire protocol — the Dart port of +/// `js/packages/truapi-host/src/index.ts`. +/// +/// A host implements the typed handler interfaces in the generated +/// `host.dart` and wires them to a [Provider] with `createTruapiServer`. This +/// file is the transport-agnostic core: it decodes inbound frames, routes them +/// by wire id to request/subscription handlers, and emits response, receive, +/// and interrupt frames back through the same provider. +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import '../transport.dart'; + +/// Per-call context handed to every host handler. Carries the wire +/// `requestId` so handlers can correlate audit logs or per-call state. +class CallContext { + const CallContext(this.requestId); + + /// Transport-assigned request id for the originating client frame. + final String requestId; +} + +/// Cleanup invoked when a subscription stops (client stop frame, host +/// interrupt, or provider close). +typedef SubscriptionCleanup = void Function(); + +/// Raw byte port a subscription handler uses to push receive/interrupt frames +/// back to the client. +abstract class SubscriptionFramePort { + /// Emit a receive frame carrying the supplied encoded item bytes. + void sendReceive(Uint8List payload); + + /// Emit an interrupt frame carrying the supplied encoded reason bytes and + /// close the subscription locally. + void sendInterrupt(Uint8List payload); + + /// `true` once the subscription has ended. + bool get isClosed; +} + +/// A dispatch entry: either a one-shot [RequestEntry] or a [SubscriptionEntry]. +sealed class HostDispatchEntry { + const HostDispatchEntry(); +} + +/// Handler entry for a one-shot request method. The dispatcher decodes inbound +/// bytes, runs [handle], and forwards the returned bytes as the response frame. +class RequestEntry extends HostDispatchEntry { + const RequestEntry({required this.ids, required this.handle}); + + /// Wire discriminants for this request method. + final RequestFrameIds ids; + + /// Decode the request bytes, run the handler, and produce the response bytes. + final Future Function(CallContext ctx, Uint8List payload) handle; +} + +/// Handler entry for a subscription method. [start] decodes the start payload, +/// subscribes the handler's stream, and returns a cleanup function. +class SubscriptionEntry extends HostDispatchEntry { + const SubscriptionEntry({required this.ids, required this.start}); + + /// Wire discriminants for this subscription method. + final SubscriptionFrameIds ids; + + /// Decode the start bytes, begin streaming through [port], and return the + /// cleanup that tears the stream down. + final FutureOr Function( + CallContext ctx, + Uint8List payload, + SubscriptionFramePort port, + ) start; +} + +/// Optional hooks for visibility into protocol drift or handler errors. +class HostServerHooks { + const HostServerHooks({ + this.onUnknownFrame, + this.onRequestHandlerError, + this.onSubscriptionStartError, + }); + + /// Called when an inbound frame's wire id is not in the dispatch table. + final void Function(int id, Uint8List value)? onUnknownFrame; + + /// Called when a request handler throws or rejects. No response frame is + /// sent; the client request times out per its own policy. + final void Function(RequestFrameIds ids, Object error, CallContext ctx)? + onRequestHandlerError; + + /// Called when a subscription handler throws during `start`. + final void Function(SubscriptionFrameIds ids, Object error, CallContext ctx)? + onSubscriptionStartError; +} + +/// Handle returned by [createHostServer]. +abstract class TruapiHostServer { + /// Detach provider listeners, drop pending subscription state, and release + /// resources. Does not dispose the underlying [Provider]. Idempotent. + void dispose(); +} + +/// Wire a host server to [provider], routing inbound frames to [entries]. +TruapiHostServer createHostServer( + Provider provider, + List entries, [ + HostServerHooks hooks = const HostServerHooks(), +]) => + _HostServer(provider, entries, hooks); + +class _Pending implements _Slot { + _Pending(this.port); + @override + final SubscriptionFramePort port; + bool stopped = false; +} + +class _Active implements _Slot { + _Active(this.cleanup, this.port); + final SubscriptionCleanup cleanup; + @override + final SubscriptionFramePort port; +} + +abstract class _Slot { + SubscriptionFramePort get port; +} + +class _HostServer implements TruapiHostServer { + _HostServer(this._provider, List entries, this._hooks) { + for (final entry in entries) { + switch (entry) { + case RequestEntry(:final ids): + if (_byRequest.containsKey(ids.request)) { + throw StateError('duplicate request wire id ${ids.request}'); + } + _byRequest[ids.request] = entry; + case SubscriptionEntry(:final ids): + if (_byStart.containsKey(ids.start)) { + throw StateError( + 'duplicate subscription start wire id ${ids.start}'); + } + _byStart[ids.start] = entry; + _stopIds.add(ids.stop); + } + } + _unsubscribeMessage = _provider.subscribe(_handleInbound); + _unsubscribeClose = _provider.subscribeClose((_) => dispose()); + } + + final Provider _provider; + final HostServerHooks _hooks; + final Map _byRequest = {}; + final Map _byStart = {}; + final Set _stopIds = {}; + final Map _subscriptions = {}; + bool _disposed = false; + CancelFn? _unsubscribeMessage; + CancelFn? _unsubscribeClose; + + void _send(String requestId, int id, Uint8List value) { + if (_disposed) return; + try { + _provider.postMessage( + encodeWireMessage(ProtocolMessage(requestId, id, value)), + ); + } catch (_) { + // Provider closed mid-send; disposal handles teardown. + } + } + + void _tearDown(String requestId) { + final slot = _subscriptions[requestId]; + if (slot == null) return; + if (slot is _Pending) { + slot.stopped = true; + return; + } + _subscriptions.remove(requestId); + if (slot is _Active) { + try { + slot.cleanup(); + } catch (_) { + // handler cleanup errors are isolated from the dispatcher + } + } + } + + SubscriptionFramePort _makeFramePort( + String requestId, SubscriptionFrameIds ids) => + _FramePort(this, requestId, ids); + + void _handleInbound(Uint8List message) { + if (_disposed) return; + final ProtocolMessage decoded; + try { + decoded = decodeWireMessage(message); + } catch (_) { + return; + } + final ctx = CallContext(decoded.requestId); + + final requestEntry = _byRequest[decoded.id]; + if (requestEntry != null) { + final Future pending; + try { + pending = requestEntry.handle(ctx, decoded.value); + } catch (error) { + _hooks.onRequestHandlerError?.call(requestEntry.ids, error, ctx); + return; + } + pending.then( + (bytes) => _send(decoded.requestId, requestEntry.ids.response, bytes), + onError: (Object error) => + _hooks.onRequestHandlerError?.call(requestEntry.ids, error, ctx), + ); + return; + } + + final subEntry = _byStart[decoded.id]; + if (subEntry != null) { + if (_subscriptions.containsKey(decoded.requestId)) { + return; // duplicate start for the same requestId + } + final port = _makeFramePort(decoded.requestId, subEntry.ids); + final pending = _Pending(port); + _subscriptions[decoded.requestId] = pending; + + void finish(SubscriptionCleanup cleanup) { + final current = _subscriptions[decoded.requestId]; + if (!identical(current, pending) || + _disposed || + pending.stopped || + port.isClosed) { + if (identical(current, pending)) { + _subscriptions.remove(decoded.requestId); + } + try { + cleanup(); + } catch (_) {/* ignore */} + return; + } + _subscriptions[decoded.requestId] = _Active(cleanup, port); + } + + void fail(Object error) { + if (identical(_subscriptions[decoded.requestId], pending)) { + _subscriptions.remove(decoded.requestId); + } + _hooks.onSubscriptionStartError?.call(subEntry.ids, error, ctx); + } + + final FutureOr startResult; + try { + startResult = subEntry.start(ctx, decoded.value, port); + } catch (error) { + fail(error); + return; + } + if (startResult is Future) { + startResult.then(finish, onError: fail); + } else { + finish(startResult); + } + return; + } + + if (_stopIds.contains(decoded.id)) { + _tearDown(decoded.requestId); + return; + } + + _hooks.onUnknownFrame?.call(decoded.id, decoded.value); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + for (final entry in _subscriptions.entries.toList()) { + _subscriptions.remove(entry.key); + final slot = entry.value; + if (slot is _Pending) { + slot.stopped = true; + continue; + } + if (slot is _Active) { + try { + slot.cleanup(); + } catch (_) {/* ignore */} + } + } + try { + _unsubscribeMessage?.call(); + } catch (_) {/* ignore */} + try { + _unsubscribeClose?.call(); + } catch (_) {/* ignore */} + } +} + +class _FramePort implements SubscriptionFramePort { + _FramePort(this._server, this._requestId, this._ids); + + final _HostServer _server; + final String _requestId; + final SubscriptionFrameIds _ids; + bool _closed = false; + + @override + void sendReceive(Uint8List payload) { + if (_closed || _server._disposed) return; + _server._send(_requestId, _ids.receive, payload); + } + + @override + void sendInterrupt(Uint8List payload) { + if (_closed || _server._disposed) return; + _closed = true; + _server._send(_requestId, _ids.interrupt, payload); + // Host interrupted locally; drop the slot so later stop frames are no-ops. + _server._subscriptions.remove(_requestId); + } + + @override + bool get isClosed => _closed || _server._disposed; +} diff --git a/dart/truapi/lib/src/providers/loopback_provider.dart b/dart/truapi/lib/src/providers/loopback_provider.dart new file mode 100644 index 00000000..4a048a80 --- /dev/null +++ b/dart/truapi/lib/src/providers/loopback_provider.dart @@ -0,0 +1,80 @@ +/// In-memory loopback transport: two linked [Provider]s that deliver each +/// other's frames. Used by tests and local harnesses; no host process needed. +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import '../transport.dart'; + +/// A pair of [Provider]s wired back-to-back. A frame posted on [client] is +/// delivered to [host]'s subscribers, and vice versa, on a microtask to mimic +/// the asynchrony of a real channel. +class LoopbackChannel { + LoopbackChannel() { + _client._peer = _host; + _host._peer = _client; + } + + final _LoopbackEndpoint _client = _LoopbackEndpoint(); + final _LoopbackEndpoint _host = _LoopbackEndpoint(); + + /// The product/client side of the channel. + Provider get client => _client; + + /// The host side of the channel. + Provider get host => _host; +} + +class _LoopbackEndpoint extends Provider { + _LoopbackEndpoint? _peer; + final Set _listeners = {}; + final Set _closeListeners = {}; + Object? _closed; + + void _deliver(Uint8List message) { + if (_closed != null) return; + for (final listener in _listeners.toList()) { + listener(message); + } + } + + @override + void postMessage(Uint8List message) { + final closed = _closed; + if (closed != null) throw closed; + final peer = _peer; + if (peer == null) return; + final copy = Uint8List.fromList(message); + scheduleMicrotask(() => peer._deliver(copy)); + } + + @override + CancelFn subscribe(MessageHandler onMessage) { + if (_closed != null) return () {}; + _listeners.add(onMessage); + return () => _listeners.remove(onMessage); + } + + @override + CancelFn? subscribeClose(CloseHandler onClose) { + final closed = _closed; + if (closed != null) { + onClose(closed); + return () {}; + } + _closeListeners.add(onClose); + return () => _closeListeners.remove(onClose); + } + + @override + void dispose() { + if (_closed != null) return; + _closed = StateError('loopback endpoint disposed'); + for (final listener in _closeListeners.toList()) { + listener(_closed!); + } + _listeners.clear(); + _closeListeners.clear(); + } +} diff --git a/dart/truapi/lib/src/result.dart b/dart/truapi/lib/src/result.dart new file mode 100644 index 00000000..122b42da --- /dev/null +++ b/dart/truapi/lib/src/result.dart @@ -0,0 +1,75 @@ +/// Typed success/failure result, the Dart analogue of the TypeScript client's +/// `neverthrow` `Result`. +/// +/// Requests return `Future>`: protocol-level outcomes (the +/// host answered, with either a success value or a typed domain/framework +/// error) are values, not exceptions. Thrown errors are reserved for transport +/// and decode faults. +library; + +/// A success ([Ok]) or failure ([Err]) outcome carrying typed payloads. +sealed class Result { + const Result(); + + /// Whether this is an [Ok]. + bool get isOk => this is Ok; + + /// Whether this is an [Err]. + bool get isErr => this is Err; + + /// The success value, or `null` for an [Err]. + T? get okOrNull => switch (this) { + Ok(value: final v) => v, + Err() => null, + }; + + /// The error value, or `null` for an [Ok]. + E? get errOrNull => switch (this) { + Ok() => null, + Err(error: final e) => e, + }; + + /// Fold both branches into a single value. + R match({ + required R Function(T value) ok, + required R Function(E error) err, + }) => + switch (this) { + Ok(value: final v) => ok(v), + Err(error: final e) => err(e), + }; +} + +/// Successful [Result] carrying a value of type `T`. +final class Ok extends Result { + const Ok(this.value); + + /// The success payload. + final T value; + + @override + bool operator ==(Object other) => other is Ok && other.value == value; + + @override + int get hashCode => Object.hash(Ok, value); + + @override + String toString() => 'Ok($value)'; +} + +/// Failed [Result] carrying an error of type `E`. +final class Err extends Result { + const Err(this.error); + + /// The failure payload. + final E error; + + @override + bool operator ==(Object other) => other is Err && other.error == error; + + @override + int get hashCode => Object.hash(Err, error); + + @override + String toString() => 'Err($error)'; +} diff --git a/dart/truapi/lib/src/scale.dart b/dart/truapi/lib/src/scale.dart new file mode 100644 index 00000000..1a8b8ab7 --- /dev/null +++ b/dart/truapi/lib/src/scale.dart @@ -0,0 +1,488 @@ +/// SCALE codec primitives used by the generated TrUAPI client. +/// +/// A hand-written, dependency-light port of the combinator codecs the +/// TypeScript client gets from `scale-ts` (see `js/packages/truapi/src/scale.ts`), +/// plus the Polkadot-flavour helpers it adds (fixed/var byte arrays, lazy +/// recursive codecs, `OptionBool`, and `V`-indexed versioned wrappers). +/// +/// The wire format is the contract: every codec here must produce bytes +/// identical to `parity_scale_codec` on the Rust side and `scale-ts` on the +/// TypeScript side. The `wire_vectors` test asserts that byte-for-byte. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'result.dart'; + +/// Cursor over an immutable byte buffer that combinator decoders advance as +/// they consume fields. +class Input { + Input(this.bytes); + + /// Backing buffer being decoded. + final Uint8List bytes; + + /// Offset of the next unread byte. + int offset = 0; + + /// Read a single byte, advancing the cursor. + int takeByte() { + if (offset >= bytes.length) { + throw const FormatException('SCALE decode: unexpected end of input'); + } + return bytes[offset++]; + } + + /// Read exactly [n] bytes as a fresh, independent slice. + Uint8List takeBytes(int n) { + if (offset + n > bytes.length) { + throw FormatException( + 'SCALE decode: need $n bytes, ${bytes.length - offset} remain', + ); + } + final out = Uint8List.sublistView(bytes, offset, offset + n); + offset += n; + // Copy so callers may retain the slice independently of `bytes`. + return Uint8List.fromList(out); + } + + /// Whether the cursor has consumed the whole buffer. + bool get atEnd => offset >= bytes.length; +} + +/// A SCALE codec: encodes `T` into bytes and decodes `T` from an [Input]. +/// +/// Codecs compose through [encInto]/[decFrom], which thread a shared sink and +/// cursor so nested structures encode and decode in a single pass. +class Codec { + const Codec(this._enc, this._dec); + + final void Function(BytesBuilder out, T value) _enc; + final T Function(Input input) _dec; + + /// Encode [value] into a freshly allocated byte buffer. + Uint8List enc(T value) { + final out = BytesBuilder(copy: false); + _enc(out, value); + return out.toBytes(); + } + + /// Encode [value] into a shared sink (used by composite codecs). + void encInto(BytesBuilder out, T value) => _enc(out, value); + + /// Decode a `T` from a complete byte buffer. + T dec(Uint8List bytes) => _dec(Input(bytes)); + + /// Decode a `T` from a shared cursor (used by composite codecs). + T decFrom(Input input) => _dec(input); + + /// Adapt this codec to a different value type via a pair of pure mappings. + Codec map(T Function(U) toBase, U Function(T) fromBase) => Codec( + (out, value) => _enc(out, toBase(value)), + (input) => fromBase(_dec(input)), + ); +} + +// --- Fixed-width integers ------------------------------------------------- + +/// `bool`: one byte, `0` = false, `1` = true. +const Codec boolCodec = Codec(_encBool, _decBool); +void _encBool(BytesBuilder out, bool v) => out.addByte(v ? 1 : 0); +bool _decBool(Input i) { + final b = i.takeByte(); + if (b > 1) throw FormatException('SCALE decode: invalid bool byte $b'); + return b == 1; +} + +/// `u8`: a single unsigned byte. +const Codec u8 = Codec(_encU8, _decU8); +void _encU8(BytesBuilder out, int v) => out.addByte(v & 0xff); +int _decU8(Input i) => i.takeByte(); + +/// `u16`: little-endian unsigned, 2 bytes. +final Codec u16 = _uintLe(2); + +/// `u32`: little-endian unsigned, 4 bytes. +final Codec u32 = _uintLe(4); + +/// `i8`: signed byte (two's complement). +const Codec i8 = Codec(_encU8, _decI8); +int _decI8(Input i) { + final b = i.takeByte(); + return b < 0x80 ? b : b - 0x100; +} + +/// `i16`: little-endian signed, 2 bytes. +final Codec i16 = _intLe(2); + +/// `i32`: little-endian signed, 4 bytes. +final Codec i32 = _intLe(4); + +/// `u64`: little-endian unsigned, 8 bytes. Uses [BigInt] to avoid truncation. +final Codec u64 = _bigUintLe(8); + +/// `u128`: little-endian unsigned, 16 bytes. +final Codec u128 = _bigUintLe(16); + +/// `i64`: little-endian signed, 8 bytes. +final Codec i64 = _bigIntLe(8); + +/// `i128`: little-endian signed, 16 bytes. +final Codec i128 = _bigIntLe(16); + +Codec _uintLe(int n) => Codec( + (out, v) { + var value = v; + for (var b = 0; b < n; b++) { + out.addByte(value & 0xff); + value >>= 8; + } + }, + (i) { + final bytes = i.takeBytes(n); + var value = 0; + for (var b = n - 1; b >= 0; b--) { + value = (value << 8) | bytes[b]; + } + return value; + }, + ); + +Codec _intLe(int n) { + final unsigned = _uintLe(n); + final signBit = 1 << (n * 8 - 1); + final wrap = 1 << (n * 8); + return Codec( + (out, v) => unsigned.encInto(out, v < 0 ? v + wrap : v), + (i) { + final raw = unsigned.decFrom(i); + return raw >= signBit ? raw - wrap : raw; + }, + ); +} + +Codec _bigUintLe(int n) => Codec( + (out, v) { + var value = v; + final mask = BigInt.from(0xff); + for (var b = 0; b < n; b++) { + out.addByte((value & mask).toInt()); + value >>= 8; + } + }, + (i) { + final bytes = i.takeBytes(n); + var value = BigInt.zero; + for (var b = n - 1; b >= 0; b--) { + value = (value << 8) | BigInt.from(bytes[b]); + } + return value; + }, + ); + +Codec _bigIntLe(int n) { + final unsigned = _bigUintLe(n); + final signBit = BigInt.one << (n * 8 - 1); + final wrap = BigInt.one << (n * 8); + return Codec( + (out, v) => unsigned.encInto(out, v.isNegative ? v + wrap : v), + (i) { + final raw = unsigned.decFrom(i); + return raw >= signBit ? raw - wrap : raw; + }, + ); +} + +// --- Compact -------------------------------------------------------------- + +/// SCALE compact integer (`Compact`). Accepts and yields [BigInt] so the +/// full unsigned range round-trips regardless of magnitude. +const Codec compact = Codec(_encCompact, _decCompact); + +void _encCompact(BytesBuilder out, BigInt value) { + if (value.isNegative) { + throw ArgumentError('SCALE compact cannot encode a negative value: $value'); + } + if (value < BigInt.from(64)) { + out.addByte((value.toInt() << 2)); + } else if (value < BigInt.from(1 << 14)) { + final v = (value.toInt() << 2) | 0x01; + out + ..addByte(v & 0xff) + ..addByte((v >> 8) & 0xff); + } else if (value < BigInt.from(1 << 30)) { + final v = (value.toInt() << 2) | 0x02; + out + ..addByte(v & 0xff) + ..addByte((v >> 8) & 0xff) + ..addByte((v >> 16) & 0xff) + ..addByte((v >> 24) & 0xff); + } else { + final bytes = []; + var v = value; + final mask = BigInt.from(0xff); + while (v > BigInt.zero) { + bytes.add((v & mask).toInt()); + v >>= 8; + } + out.addByte(((bytes.length - 4) << 2) | 0x03); + for (final b in bytes) { + out.addByte(b); + } + } +} + +BigInt _decCompact(Input i) { + final first = i.takeByte(); + switch (first & 0x03) { + case 0: + return BigInt.from(first >> 2); + case 1: + final second = i.takeByte(); + return BigInt.from(((first >> 2) | (second << 6)) & 0x3fff); + case 2: + final b1 = i.takeByte(); + final b2 = i.takeByte(); + final b3 = i.takeByte(); + final v = + ((first >> 2) | (b1 << 6) | (b2 << 14) | (b3 << 22)) & 0xffffffff; + return BigInt.from(v).toUnsigned(32); + default: + final len = (first >> 2) + 4; + final bytes = i.takeBytes(len); + var value = BigInt.zero; + for (var b = len - 1; b >= 0; b--) { + value = (value << 8) | BigInt.from(bytes[b]); + } + return value; + } +} + +// --- Strings & bytes ------------------------------------------------------ + +/// `String`: compact length prefix + UTF-8 bytes. +final Codec str = Codec( + (out, v) { + final encoded = utf8.encode(v); + _encCompact(out, BigInt.from(encoded.length)); + out.add(encoded); + }, + (i) { + final len = _decCompact(i).toInt(); + return utf8.decode(i.takeBytes(len)); + }, +); + +/// `Vec`: compact length prefix + raw bytes. +final Codec bytes = Codec( + (out, v) { + _encCompact(out, BigInt.from(v.length)); + out.add(v); + }, + (i) { + final len = _decCompact(i).toInt(); + return i.takeBytes(len); + }, +); + +/// `[u8; N]`: exactly [length] raw bytes, no length prefix. +Codec bytesFixed(int length) => Codec( + (out, v) { + if (v.length != length) { + throw ArgumentError( + 'fixed byte array expected $length bytes, got ${v.length}', + ); + } + out.add(v); + }, + (i) => i.takeBytes(length), + ); + +// --- Containers ----------------------------------------------------------- + +/// The Rust unit type `()`. A real type (not `void`) so it can instantiate +/// generics such as `Component<()>` → `Component`. +class Unit { + const Unit(); + + @override + bool operator ==(Object other) => other is Unit; + + @override + int get hashCode => 0; + + @override + String toString() => '()'; +} + +/// The single inhabitant of [Unit]. +const Unit unitValue = Unit(); + +/// The unit type `()`: zero bytes on the wire. +const Codec unit = Codec(_encUnit, _decUnit); +void _encUnit(BytesBuilder out, Unit v) {} +Unit _decUnit(Input i) => unitValue; + +/// `Option`: one tag byte (`0` = none, `1` = some) then the inner value. +/// Encoded/decoded as a nullable Dart value. +Codec option(Codec inner) => Codec( + (out, v) { + if (v == null) { + out.addByte(0); + } else { + out.addByte(1); + inner.encInto(out, v); + } + }, + (i) { + final tag = i.takeByte(); + if (tag == 0) return null; + if (tag != 1) { + throw FormatException('SCALE decode: invalid Option tag $tag'); + } + return inner.decFrom(i); + }, + ); + +/// Substrate `OptionBool`: a one-byte tri-state. `null` → 0, `true` → 1, +/// `false` → 2. Matches `parity_scale_codec::OptionBool`. +final Codec optionBool = Codec( + (out, v) => out.addByte(v == null ? 0 : (v ? 1 : 2)), + (i) { + switch (i.takeByte()) { + case 0: + return null; + case 1: + return true; + case 2: + return false; + default: + throw const FormatException('SCALE decode: invalid OptionBool byte'); + } + }, +); + +/// `Vec`: compact length prefix then [count] encoded items. +Codec> vector(Codec inner) => Codec>( + (out, v) { + _encCompact(out, BigInt.from(v.length)); + for (final item in v) { + inner.encInto(out, item); + } + }, + (i) { + final len = _decCompact(i).toInt(); + return List.generate(len, (_) => inner.decFrom(i), growable: false); + }, + ); + +/// `[T; N]`: exactly [length] items with no length prefix (the fixed-array +/// analogue of [vector]). +Codec> vectorFixed(Codec inner, int length) => Codec>( + (out, v) { + if (v.length != length) { + throw ArgumentError( + 'fixed array expected $length items, got ${v.length}', + ); + } + for (final item in v) { + inner.encInto(out, item); + } + }, + (i) => List.generate(length, (_) => inner.decFrom(i), growable: false), + ); + +/// `Result`: one tag byte (`0` = Ok, `1` = Err) then the inner value. +Codec> result(Codec ok, Codec err) => + Codec>( + (out, v) { + switch (v) { + case Ok(value: final value): + out.addByte(0); + ok.encInto(out, value); + case Err(error: final error): + out.addByte(1); + err.encInto(out, error); + } + }, + (i) { + final tag = i.takeByte(); + switch (tag) { + case 0: + return Ok(ok.decFrom(i)); + case 1: + return Err(err.decFrom(i)); + default: + throw FormatException('SCALE decode: invalid Result tag $tag'); + } + }, + ); + +/// Defers codec construction until first use so mutually recursive generated +/// codecs can reference each other safely. +Codec lazy(Codec Function() factory) { + Codec? resolved; + Codec get() => resolved ??= factory(); + return Codec( + (out, v) => get().encInto(out, v), + (i) => get().decFrom(i), + ); +} + +/// A `V`-indexed versioned wrapper around a single inner codec. +/// +/// The TrUAPI client encodes exactly one selected wire version: it writes the +/// SCALE enum discriminant [index] (`N - 1`) then the inner payload, and on +/// decode consumes the discriminant and returns the inner value. This keeps +/// the public Dart surface the inner type, matching the TypeScript client's +/// `.value` stripping. +Codec versioned(int index, Codec inner) => Codec( + (out, v) { + out.addByte(index); + inner.encInto(out, v); + }, + (i) { + i.takeByte(); // consume the version discriminant + return inner.decFrom(i); + }, + ); + +// --- Tuples --------------------------------------------------------------- + +/// 2-tuple `(A, B)` encoded as its fields in order. +Codec<(A, B)> tuple2(Codec a, Codec b) => Codec<(A, B)>( + (out, v) { + a.encInto(out, v.$1); + b.encInto(out, v.$2); + }, + (i) => (a.decFrom(i), b.decFrom(i)), + ); + +/// 3-tuple `(A, B, C)` encoded as its fields in order. +Codec<(A, B, C)> tuple3(Codec a, Codec b, Codec c) => + Codec<(A, B, C)>( + (out, v) { + a.encInto(out, v.$1); + b.encInto(out, v.$2); + c.encInto(out, v.$3); + }, + (i) => (a.decFrom(i), b.decFrom(i), c.decFrom(i)), + ); + +/// 4-tuple `(A, B, C, D)` encoded as its fields in order. +Codec<(A, B, C, D)> tuple4( + Codec a, + Codec b, + Codec c, + Codec d, +) => + Codec<(A, B, C, D)>( + (out, v) { + a.encInto(out, v.$1); + b.encInto(out, v.$2); + c.encInto(out, v.$3); + d.encInto(out, v.$4); + }, + (i) => (a.decFrom(i), b.decFrom(i), c.decFrom(i), d.decFrom(i)), + ); diff --git a/dart/truapi/lib/src/transport.dart b/dart/truapi/lib/src/transport.dart new file mode 100644 index 00000000..97a1a8e5 --- /dev/null +++ b/dart/truapi/lib/src/transport.dart @@ -0,0 +1,128 @@ +/// Shared wire-protocol primitives for the TrUAPI host: the [Provider] byte +/// pipe, frame encode/decode, and the wire-discriminant types. +/// +/// The frame format is: +/// +/// ```text +/// [ requestId : SCALE str ][ discriminant : u8 ][ payload : SCALE bytes ] +/// ``` +/// +/// (Product/client transport lives in the TS/JS `@parity/truapi` package; this +/// Dart package is host-only.) +library; + +import 'dart:typed_data'; + +import 'scale.dart' as s; + +/// Cancels a previously registered listener. +typedef CancelFn = void Function(); + +/// Raw inbound frame handler. +typedef MessageHandler = void Function(Uint8List message); + +/// Provider-level close/failure handler. +typedef CloseHandler = void Function(Object error); + +/// Raw message pipe the host server rides on. Concrete providers (loopback for +/// tests, a native bridge for the host) implement this and nothing more. +abstract class Provider { + /// Send a complete SCALE-encoded wire frame to the peer. + void postMessage(Uint8List message); + + /// Register a callback for inbound SCALE-encoded wire frames. Returns a + /// function that removes the listener. + CancelFn subscribe(MessageHandler onMessage); + + /// Register a callback for provider-level close/failure events. Optional; + /// the default registers nothing. + CancelFn? subscribeClose(CloseHandler onClose) => null; + + /// Release provider resources and close the underlying pipe. + void dispose(); +} + +/// Wire discriminants for a one-shot request method. +class RequestFrameIds { + const RequestFrameIds({required this.request, required this.response}); + + /// Inbound request frame discriminant. + final int request; + + /// Outbound response frame discriminant. + final int response; +} + +/// Wire discriminants for a subscription method. +class SubscriptionFrameIds { + const SubscriptionFrameIds({ + required this.start, + required this.stop, + required this.interrupt, + required this.receive, + }); + + /// Inbound start frame discriminant. + final int start; + + /// Inbound stop frame discriminant. + final int stop; + + /// Outbound interrupt (stream-end) frame discriminant. + final int interrupt; + + /// Outbound item frame discriminant. + final int receive; +} + +/// Decoded TrUAPI wire frame. +class ProtocolMessage { + const ProtocolMessage(this.requestId, this.id, this.value); + + /// Correlation id shared by a request/response or subscription frame set. + final String requestId; + + /// Wire-table numeric discriminant. + final int id; + + /// SCALE-encoded payload body. + final Uint8List value; +} + +/// Encode a wire frame: `str(requestId) ++ u8(id) ++ payload`. +Uint8List encodeWireMessage(ProtocolMessage message) { + if (message.id < 0 || message.id > 255) { + throw ArgumentError('Invalid wire discriminant: ${message.id}'); + } + final out = BytesBuilder(copy: false); + s.str.encInto(out, message.requestId); + out.addByte(message.id); + out.add(message.value); + return out.toBytes(); +} + +/// Decode a wire frame produced by [encodeWireMessage]. +ProtocolMessage decodeWireMessage(Uint8List message) { + final input = s.Input(message); + final requestId = s.str.decFrom(input); + if (input.atEnd) { + throw const FormatException('Wire frame too short: missing discriminant'); + } + final id = input.takeByte(); + final value = input.takeBytes(message.length - input.offset); + return ProtocolMessage(requestId, id, value); +} + +/// Signal raised by a subscription handler to end a (fallible) subscription +/// with a typed interrupt reason. Add it as a stream error from a host +/// subscription handler; the dispatcher encodes [reason] into the interrupt +/// frame. A normal stream completion ends the subscription without a reason. +class SubscriptionInterrupted implements Exception { + const SubscriptionInterrupted(this.reason); + + /// The typed interrupt payload. + final Reason reason; + + @override + String toString() => 'SubscriptionInterrupted($reason)'; +} diff --git a/dart/truapi/lib/truapi.dart b/dart/truapi/lib/truapi.dart new file mode 100644 index 00000000..482f7cc2 --- /dev/null +++ b/dart/truapi/lib/truapi.dart @@ -0,0 +1,39 @@ +/// TrUAPI Dart host. +/// +/// The host side of the TrUAPI protocol, for host applications (e.g. a Flutter +/// desktop host) that implement the services the products inside them call. +/// Products are web apps and use the TS/JS `@parity/truapi` client, so this Dart +/// package ships **only** the host. +/// +/// Implement the generated `TruapiHostHandlers` (one typed handler group per +/// service) and wire them to a [Provider] with `createTruapiServer`: +/// +/// ```dart +/// import 'package:truapi/truapi.dart'; +/// +/// final server = createTruapiServer(provider, MyHostHandlers()); +/// // ... later: +/// server.dispose(); +/// ``` +/// +/// Handlers receive and return the inner (selected-version) protocol types; the +/// generated dispatch entries handle versioned wire wrapping, SCALE +/// encode/decode, and the request/subscription frame lifecycle. +library; + +export 'src/result.dart'; +export 'src/scale.dart' show Codec, Input, Unit, unitValue; +export 'src/transport.dart' + show + Provider, + ProtocolMessage, + RequestFrameIds, + SubscriptionFrameIds, + SubscriptionInterrupted, + decodeWireMessage, + encodeWireMessage; +export 'src/host/host_server.dart'; +export 'src/providers/loopback_provider.dart'; +// Generated by `truapi-codegen`: +export 'src/generated/index.dart'; // types + wire table +export 'src/generated/host.dart'; diff --git a/dart/truapi/pubspec.yaml b/dart/truapi/pubspec.yaml new file mode 100644 index 00000000..c217fc05 --- /dev/null +++ b/dart/truapi/pubspec.yaml @@ -0,0 +1,19 @@ +name: truapi +description: >- + TrUAPI Dart client — the typed, SCALE-encoded protocol client that lets + Flutter/Dart products talk to their Polkadot host. Generated from the Rust + trait definitions in paritytech/truapi. +version: 0.3.1 +repository: https://github.com/paritytech/truapi +publish_to: none + +environment: + sdk: ^3.0.0 + +# The core package is pure Dart (dart:typed_data + dart:async only) so it runs +# on Flutter (all platforms), Dart CLI, and server. Transport providers that +# need the Flutter SDK live behind an optional import, never in the core. + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.0 diff --git a/dart/truapi/test/host_server_test.dart b/dart/truapi/test/host_server_test.dart new file mode 100644 index 00000000..f0738477 --- /dev/null +++ b/dart/truapi/test/host_server_test.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi/src/scale.dart' as s; +import 'package:test/test.dart'; + +/// A minimal host implementation of the Account service. Only the two methods +/// exercised here have real bodies; the rest satisfy the interface. +class _AccountHandlers implements AccountHostHandlers { + @override + Stream connectionStatusSubscribe( + CallContext ctx, + ) async* { + yield HostAccountConnectionStatusSubscribeItem.connected; + yield HostAccountConnectionStatusSubscribeItem.disconnected; + } + + @override + Future> getAccount( + CallContext ctx, + HostAccountGetRequest request, + ) async { + // Echo the derivation index into the returned public key so the test can + // confirm the decoded request reached the handler intact. + return Ok( + HostAccountGetResponse( + account: ProductAccount( + publicKey: + Uint8List.fromList([request.productAccountId.derivationIndex]), + ), + ), + ); + } + + @override + Future> + getAccountAlias(CallContext ctx, HostAccountGetAliasRequest request) => + throw UnimplementedError(); + + @override + Future> + createAccountProof( + CallContext ctx, + HostAccountCreateProofRequest request, + ) => + throw UnimplementedError(); + + @override + Future> + getLegacyAccounts(CallContext ctx) => throw UnimplementedError(); + + @override + Future> getUserId( + CallContext ctx, + ) => + throw UnimplementedError(); + + @override + Future> requestLogin( + CallContext ctx, + HostRequestLoginRequest request, + ) => + throw UnimplementedError(); +} + +void main() { + test('host dispatches a request frame to the handler and replies', () async { + final channel = LoopbackChannel(); + final server = createHostServer( + channel.host, + buildAccountEntries(_AccountHandlers()), + ); + + // Product side: listen for the response frame. + final response = Completer(); + channel.client.subscribe((frame) { + final message = decodeWireMessage(frame); + if (message.id != accountGetAccount.response) return; + final result = s + .versioned(0, + s.result(hostAccountGetResponseCodec, hostAccountGetErrorCodec)) + .dec(message.value); + switch (result) { + case Ok(value: final value): + response.complete(value); + case Err(): + response.completeError(StateError('unexpected Err')); + } + }); + + // Product side: send a raw accountGetAccount request frame. + final payload = s.versioned(0, hostAccountGetRequestCodec).enc( + const HostAccountGetRequest( + productAccountId: + ProductAccountId(dotNsIdentifier: 'a.dot', derivationIndex: 5), + ), + ); + channel.client.postMessage( + encodeWireMessage( + ProtocolMessage('p:1', accountGetAccount.request, payload), + ), + ); + + final result = await response.future.timeout(const Duration(seconds: 2)); + expect(result.account.publicKey, Uint8List.fromList([5])); + server.dispose(); + }); + + test('host streams a subscription back as receive + interrupt frames', + () async { + final channel = LoopbackChannel(); + final server = createHostServer( + channel.host, + buildAccountEntries(_AccountHandlers()), + ); + + final items = []; + final done = Completer(); + channel.client.subscribe((frame) { + final message = decodeWireMessage(frame); + if (message.id == accountConnectionStatusSubscribe.receive) { + items.add( + s + .versioned(0, hostAccountConnectionStatusSubscribeItemCodec) + .dec(message.value), + ); + } else if (message.id == accountConnectionStatusSubscribe.interrupt) { + if (!done.isCompleted) done.complete(); + } + }); + + // Start the subscription (no payload → versioned unit). + channel.client.postMessage( + encodeWireMessage( + ProtocolMessage( + 'p:2', + accountConnectionStatusSubscribe.start, + s.versioned(0, s.unit).enc(s.unitValue), + ), + ), + ); + + await done.future.timeout(const Duration(seconds: 2)); + expect(items, [ + HostAccountConnectionStatusSubscribeItem.connected, + HostAccountConnectionStatusSubscribeItem.disconnected, + ]); + server.dispose(); + }); +} diff --git a/dart/truapi/test/scale_test.dart b/dart/truapi/test/scale_test.dart new file mode 100644 index 00000000..438c98f1 --- /dev/null +++ b/dart/truapi/test/scale_test.dart @@ -0,0 +1,130 @@ +import 'dart:typed_data'; + +import 'package:truapi/src/result.dart'; +import 'package:truapi/src/scale.dart' as s; +import 'package:test/test.dart'; + +String hex(Uint8List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List bytesOf(List v) => Uint8List.fromList(v); + +void main() { + group('integers', () { + test('u8', () { + expect(hex(s.u8.enc(1)), '01'); + expect(hex(s.u8.enc(255)), 'ff'); + expect(s.u8.dec(bytesOf([0x2a])), 42); + }); + + test('u16/u32 little-endian', () { + expect(hex(s.u16.enc(1)), '0100'); + expect(hex(s.u32.enc(1)), '01000000'); + expect(hex(s.u32.enc(0xdeadbeef)), 'efbeadde'); + expect(s.u32.dec(bytesOf([0xef, 0xbe, 0xad, 0xde])), 0xdeadbeef); + }); + + test('u64/u128 as BigInt', () { + expect(hex(s.u64.enc(BigInt.from(1))), '0100000000000000'); + final big = BigInt.parse('18446744073709551615'); // u64::MAX + expect(hex(s.u64.enc(big)), 'ffffffffffffffff'); + expect(s.u64.dec(s.u64.enc(big)), big); + final u128max = (BigInt.one << 128) - BigInt.one; + expect(s.u128.dec(s.u128.enc(u128max)), u128max); + }); + + test('signed round-trips', () { + for (final v in [0, 1, -1, 127, -128]) { + expect(s.i8.dec(s.i8.enc(v)), v); + } + for (final v in [0, 1, -1, 32767, -32768]) { + expect(s.i16.dec(s.i16.enc(v)), v); + } + expect(s.i64.dec(s.i64.enc(BigInt.from(-5))), BigInt.from(-5)); + }); + }); + + group('compact', () { + test('known vectors', () { + expect(hex(s.compact.enc(BigInt.zero)), '00'); + expect(hex(s.compact.enc(BigInt.from(1))), '04'); + expect(hex(s.compact.enc(BigInt.from(63))), 'fc'); + expect(hex(s.compact.enc(BigInt.from(64))), '0101'); + expect(hex(s.compact.enc(BigInt.from(16383))), 'fdff'); + expect(hex(s.compact.enc(BigInt.from(16384))), '02000100'); + expect(hex(s.compact.enc(BigInt.from(1073741823))), 'feffffff'); + expect(hex(s.compact.enc(BigInt.from(1073741824))), '0300000040'); + }); + + test('round-trips large', () { + final big = BigInt.parse('1000000000000000000000'); + expect(s.compact.dec(s.compact.enc(big)), big); + }); + }); + + group('bool / option', () { + test('bool', () { + expect(hex(s.boolCodec.enc(true)), '01'); + expect(hex(s.boolCodec.enc(false)), '00'); + }); + + test('option', () { + final c = s.option(s.u8); + expect(hex(c.enc(null)), '00'); + expect(hex(c.enc(7)), '0107'); + expect(c.dec(bytesOf([0x01, 0x07])), 7); + expect(c.dec(bytesOf([0x00])), null); + }); + + test('optionBool', () { + expect(hex(s.optionBool.enc(null)), '00'); + expect(hex(s.optionBool.enc(true)), '01'); + expect(hex(s.optionBool.enc(false)), '02'); + }); + }); + + group('strings / bytes / vectors', () { + test('str', () { + expect(hex(s.str.enc('hello')), '1468656c6c6f'); + expect(s.str.dec(s.str.enc('héllo ☃')), 'héllo ☃'); + }); + + test('Vec', () { + expect(hex(s.bytes.enc(bytesOf([1, 2, 3]))), '0c010203'); + expect(s.bytes.dec(bytesOf([0x0c, 1, 2, 3])), bytesOf([1, 2, 3])); + }); + + test('fixed bytes [u8; 4]', () { + final c = s.bytesFixed(4); + expect(hex(c.enc(bytesOf([1, 2, 3, 4]))), '01020304'); + expect(() => c.enc(bytesOf([1, 2, 3])), throwsArgumentError); + }); + + test('Vec', () { + final c = s.vector(s.u32); + expect(hex(c.enc([1, 2])), '080100000002000000'); + expect(c.dec(c.enc([5, 6, 7])), [5, 6, 7]); + }); + }); + + group('result / tuples / versioned', () { + test('Result', () { + final c = s.result(s.u8, s.str); + expect(hex(c.enc(const Ok(7))), '0007'); + expect(hex(c.enc(const Err('no'))), '01086e6f'); + expect(c.dec(c.enc(const Ok(9))), const Ok(9)); + }); + + test('tuple2', () { + final c = s.tuple2(s.u8, s.boolCodec); + expect(hex(c.enc((7, true))), '0701'); + expect(c.dec(c.enc((3, false))), (3, false)); + }); + + test('versioned wrapper writes the discriminant', () { + final c = s.versioned(0, s.u8); // V1 → index 0 + expect(hex(c.enc(42)), '002a'); + expect(c.dec(bytesOf([0x00, 0x2a])), 42); + }); + }); +} diff --git a/dart/truapi/test/wire_vectors_test.dart b/dart/truapi/test/wire_vectors_test.dart new file mode 100644 index 00000000..6169f9d1 --- /dev/null +++ b/dart/truapi/test/wire_vectors_test.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi/src/scale.dart' as s; +import 'package:test/test.dart'; + +/// Cross-language conformance: the Dart generated codecs must produce bytes +/// identical to `parity_scale_codec` on the Rust side. The golden vectors are +/// produced by `cargo run -p truapi --example wire_vectors`. +String hex(Uint8List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List u8(List v) => Uint8List.fromList(v); + +void main() { + final file = File('test/wire_vectors.json'); + if (!file.existsSync()) { + test( + 'wire vectors (regenerate with `make dart`)', + () {}, + skip: 'test/wire_vectors.json not generated; run ' + '`cargo run -p truapi --example wire_vectors -- dart/truapi/test/wire_vectors.json`', + ); + return; + } + final golden = + (jsonDecode(file.readAsStringSync()) as Map).cast(); + + void check(String name, Uint8List bytes) { + final expected = golden[name]; + expect(expected, isNotNull, reason: 'no golden vector named "$name"'); + expect(hex(bytes), expected, reason: 'codec mismatch for "$name"'); + } + + test('struct: ProductAccountId', () { + check( + 'product_account_id', + productAccountIdCodec.enc( + const ProductAccountId( + dotNsIdentifier: 'my-product.dot', + derivationIndex: 7, + ), + ), + ); + }); + + test('struct with Vec: ProductAccount', () { + check('product_account', + productAccountCodec.enc(ProductAccount(publicKey: u8([1, 2, 3, 4])))); + }); + + test('Option Some/None: LegacyAccount', () { + check( + 'legacy_account_some', + legacyAccountCodec + .enc(LegacyAccount(publicKey: u8([0xaa, 0xbb]), name: 'Wallet')), + ); + check( + 'legacy_account_none', + legacyAccountCodec.enc(LegacyAccount(publicKey: u8([]), name: null)), + ); + }); + + test('handshake request struct', () { + check( + 'handshake_request', + hostHandshakeRequestCodec + .enc(const HostHandshakeRequest(codecVersion: 1))); + }); + + test('sealed enum unit variant: HostHandshakeError', () { + check( + 'handshake_error_unsupported', + hostHandshakeErrorCodec + .enc(const HostHandshakeErrorUnsupportedProtocolVersion()), + ); + }); + + test('unit enum: TypographyStyle', () { + check('typography_body_large', + typographyStyleCodec.enc(TypographyStyle.bodyLargeRegular)); + }); + + test('compact + Option: Dimensions', () { + check( + 'dimensions', + dimensionsCodec.enc(Dimensions( + top: BigInt.from(10), + end: BigInt.from(20), + bottom: null, + start: BigInt.from(5), + )), + ); + }); + + test('OptionBool: ButtonProps', () { + check( + 'button_props', + buttonPropsCodec.enc(const ButtonProps( + text: 'Go', + variant: ButtonVariant.primary, + enabled: true, + loading: null, + clickAction: 'go', + )), + ); + }); + + test('fixed [u8; 32]: HostAccountGetAliasResponse', () { + check( + 'account_get_alias_response', + hostAccountGetAliasResponseCodec.enc(HostAccountGetAliasResponse( + context: u8(List.filled(32, 7)), + alias: u8([9, 9]), + )), + ); + }); + + test('versioned V1 envelope writes 0x00 discriminant', () { + check( + 'versioned_handshake_request_v1', + s + .versioned(0, hostHandshakeRequestCodec) + .enc(const HostHandshakeRequest(codecVersion: 1)), + ); + }); +} diff --git a/dart/truapi_smoldot/.gitignore b/dart/truapi_smoldot/.gitignore new file mode 100644 index 00000000..b1b34e83 --- /dev/null +++ b/dart/truapi_smoldot/.gitignore @@ -0,0 +1,5 @@ +# Dart/pub +.dart_tool/ +.packages +pubspec.lock +build/ diff --git a/dart/truapi_smoldot/analysis_options.yaml b/dart/truapi_smoldot/analysis_options.yaml new file mode 100644 index 00000000..7cf313f7 --- /dev/null +++ b/dart/truapi_smoldot/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml +analyzer: + language: + strict-casts: true diff --git a/dart/truapi_smoldot/dart_test.yaml b/dart/truapi_smoldot/dart_test.yaml new file mode 100644 index 00000000..da774341 --- /dev/null +++ b/dart/truapi_smoldot/dart_test.yaml @@ -0,0 +1,8 @@ +# Network + native-smoldot integration tests are opt-in. The default `dart test` +# run stays fast and offline. To run the live tests (needs the smoldot native +# library on LD_LIBRARY_PATH and internet access): +# +# dart test --run-skipped -t network +tags: + network: + skip: "live test: run with `dart test --run-skipped -t network` (needs network + libsmoldot)" diff --git a/dart/truapi_smoldot/lib/src/backend.dart b/dart/truapi_smoldot/lib/src/backend.dart new file mode 100644 index 00000000..722ec614 --- /dev/null +++ b/dart/truapi_smoldot/lib/src/backend.dart @@ -0,0 +1,100 @@ +import 'dart:typed_data'; + +import 'package:smoldot_provider/smoldot_provider.dart'; + +import 'hex.dart'; +import 'json_rpc_client.dart'; + +/// A chain the backend can serve, keyed (in [SmoldotChainBackend]) by its +/// genesis hash. +class ChainSource { + const ChainSource({ + required this.chainSpec, + this.relayChainSpec, + this.enableStatementStore = false, + }); + + /// Chain specification JSON for this chain. + final String chainSpec; + + /// Relay chain spec, for a parachain. Added first and passed as a potential + /// relay chain when adding [chainSpec]. + final String? relayChainSpec; + + /// Enable the statement-store protocol on this chain (needed by the + /// StatementStore service). + final bool enableStatementStore; +} + +/// Owns a smoldot light client and lazily adds one chain per requested genesis +/// hash, exposing a [JsonRpcClient] per chain. +/// +/// TrUAPI Chain/StatementStore requests carry a `genesisHash`; the handlers +/// resolve a client with [clientFor]. App-supplied [ChainSource]s provide the +/// chain specs (smoldot needs a spec to add a chain). +class SmoldotChainBackend { + SmoldotChainBackend._(this._client, this._sources); + + /// Initialize a smoldot client and register the chain sources, keyed by their + /// genesis hash (lower-case `0x`-prefixed hex). + static Future create({ + required Map chains, + SmoldotConfig? config, + }) async { + final client = SmoldotClient(config: config); + await client.initialize(); + return SmoldotChainBackend._(client, Map.of(chains)); + } + + final SmoldotClient _client; + final Map _sources; + final Map> _clients = {}; + + /// Resolve (adding the chain on first use) a [JsonRpcClient] for [genesisHash]. + /// + /// Throws [StateError] if no [ChainSource] was registered for that genesis + /// hash. Concurrent calls for the same chain share one add. + Future clientFor(Uint8List genesisHash) { + final key = bytesToHex(genesisHash); + return _clients[key] ??= _addChain(key); + } + + Future _addChain(String genesisHashHex) async { + final source = _sources[genesisHashHex]; + if (source == null) { + throw StateError( + 'no chain spec registered for genesis hash $genesisHashHex', + ); + } + + List? potentialRelayChains; + if (source.relayChainSpec != null) { + final relay = await _client + .addChain(AddChainConfig(chainSpec: source.relayChainSpec!)); + potentialRelayChains = [relay.chainId]; + } + + final chain = await _client.addChain( + AddChainConfig( + chainSpec: source.chainSpec, + potentialRelayChains: potentialRelayChains, + statementStore: + source.enableStatementStore ? const StatementStoreConfig() : null, + ), + ); + return JsonRpcClient(getSmProvider(chain)); + } + + /// Close every chain client and dispose the smoldot client. + Future dispose() async { + for (final pending in _clients.values) { + try { + await (await pending).close(); + } catch (_) { + // ignore teardown failures + } + } + _clients.clear(); + await _client.dispose(); + } +} diff --git a/dart/truapi_smoldot/lib/src/chain_handlers.dart b/dart/truapi_smoldot/lib/src/chain_handlers.dart new file mode 100644 index 00000000..0a9ad613 --- /dev/null +++ b/dart/truapi_smoldot/lib/src/chain_handlers.dart @@ -0,0 +1,475 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; + +import 'backend.dart'; +import 'hex.dart'; +import 'json_rpc_client.dart'; + +/// Resolves a [JsonRpcClient] for a chain identified by its genesis hash. +typedef ChainClientResolver = Future Function( + Uint8List genesisHash, +); + +/// One live `chainHead_v1_follow` subscription, keyed by the TrUAPI +/// `followSubscriptionId` (the wire request id of the follow start frame, which +/// the product echoes back on every follow-up operation). +class _FollowSession { + _FollowSession(this.client, this.smoldotSubscriptionId); + + /// JSON-RPC client the follow runs on; follow-up operations reuse it. + final JsonRpcClient client; + + /// Subscription id smoldot assigned to `chainHead_v1_follow`; passed as the + /// first argument of every `chainHead_v1_*` operation method. + final String smoldotSubscriptionId; +} + +/// Backs the TrUAPI **Chain** service with a smoldot light client, mapping the +/// typed TrUAPI requests onto Polkadot JSON-RPC over a [JsonRpcClient]. +/// +/// The chain-head surface is the JSON-RPC v2 `chainHead_v1_*` family: a single +/// [followHeadSubscribe] stream carries both the chain lifecycle events and the +/// results of the operations (`getHeadBody`/`getHeadStorage`/`callHead`) started +/// against it. Operations correlate to their follow via the +/// `followSubscriptionId`, which equals the [CallContext.requestId] of the +/// originating [followHeadSubscribe] call. +class SmoldotChainHandlers implements ChainHostHandlers { + SmoldotChainHandlers(this._clientFor); + + /// Resolve clients from a [SmoldotChainBackend]. + SmoldotChainHandlers.backend(SmoldotChainBackend backend) + : _clientFor = backend.clientFor; + + final ChainClientResolver _clientFor; + + /// Live follow subscriptions, keyed by TrUAPI `followSubscriptionId`. + final Map> _sessions = {}; + + /// Run [body] against the chain's client, mapping any throw to + /// `Err(GenericError)`. + Future> _guard( + Uint8List genesisHash, + Future Function(JsonRpcClient client) body, + ) async { + try { + final client = await _clientFor(genesisHash); + return Ok(await body(client)); + } catch (error) { + return Err(GenericError(reason: error.toString())); + } + } + + /// Run [body] against the follow session named by [followSubscriptionId], + /// mapping any throw (including an unknown subscription) to `Err`. + Future> _guardSession( + String followSubscriptionId, + Future Function(_FollowSession session) body, + ) async { + try { + final pending = _sessions[followSubscriptionId]; + if (pending == null) { + throw StateError( + 'no active follow subscription "$followSubscriptionId"', + ); + } + return Ok(await body(await pending)); + } catch (error) { + return Err(GenericError(reason: error.toString())); + } + } + + // --- chainHead_v1_follow (subscription) ------------------------------------ + + @override + Stream followHeadSubscribe( + CallContext ctx, + RemoteChainHeadFollowRequest request, + ) { + final ready = Completer<_FollowSession>(); + // Keep the stored future's errors handled even if no operation awaits it. + unawaited(ready.future.then((_) {}, onError: (_) {})); + StreamSubscription? inner; + late StreamController controller; + + void forget() { + if (identical(_sessions[ctx.requestId], ready.future)) { + _sessions.remove(ctx.requestId); + } + } + + Future start() async { + try { + final client = await _clientFor(request.genesisHash); + final (subscriptionId, events) = await client.subscribe( + 'chainHead_v1_follow', + [request.withRuntime], + 'chainHead_v1_unfollow', + ); + ready.complete(_FollowSession(client, subscriptionId)); + inner = events.listen( + (event) { + final item = _mapFollowEvent(event); + if (item != null && !controller.isClosed) controller.add(item); + }, + onError: (Object error) { + if (!controller.isClosed) controller.addError(error); + }, + onDone: () { + forget(); + if (!controller.isClosed) controller.close(); + }, + ); + } catch (error) { + if (!ready.isCompleted) ready.completeError(error); + forget(); + if (!controller.isClosed) { + controller.addError(error); + await controller.close(); + } + } + } + + controller = StreamController( + onListen: start, + onCancel: () async { + forget(); + await inner?.cancel(); + }, + ); + _sessions[ctx.requestId] = ready.future; + return controller.stream; + } + + // --- chainHead_v1_* operations --------------------------------------------- + + @override + Future> getHeadHeader( + CallContext ctx, + RemoteChainHeadHeaderRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + final header = await session.client.request( + 'chainHead_v1_header', + [session.smoldotSubscriptionId, bytesToHex(request.hash)], + ); + return RemoteChainHeadHeaderResponse( + header: header == null ? null : hexToBytes(header as String), + ); + }); + + @override + Future> getHeadBody( + CallContext ctx, + RemoteChainHeadBodyRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + final started = await session.client.request( + 'chainHead_v1_body', + [session.smoldotSubscriptionId, bytesToHex(request.hash)], + ); + return RemoteChainHeadBodyResponse( + operation: _operationStarted(started), + ); + }); + + @override + Future> getHeadStorage( + CallContext ctx, + RemoteChainHeadStorageRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + final items = [ + for (final item in request.items) + {'key': bytesToHex(item.key), 'type': _queryType(item.queryType)}, + ]; + final started = await session.client.request( + 'chainHead_v1_storage', + [ + session.smoldotSubscriptionId, + bytesToHex(request.hash), + items, + if (request.childTrie != null) + bytesToHex(request.childTrie!) + else + null, + ], + ); + return RemoteChainHeadStorageResponse( + operation: _operationStarted(started), + ); + }); + + @override + Future> callHead( + CallContext ctx, + RemoteChainHeadCallRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + final started = await session.client.request( + 'chainHead_v1_call', + [ + session.smoldotSubscriptionId, + bytesToHex(request.hash), + request.function, + bytesToHex(request.callParameters), + ], + ); + return RemoteChainHeadCallResponse( + operation: _operationStarted(started), + ); + }); + + @override + Future> unpinHead( + CallContext ctx, + RemoteChainHeadUnpinRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + await session.client.request( + 'chainHead_v1_unpin', + [ + session.smoldotSubscriptionId, + [for (final hash in request.hashes) bytesToHex(hash)], + ], + ); + return unitValue; + }); + + @override + Future> continueHead( + CallContext ctx, + RemoteChainHeadContinueRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + await session.client.request( + 'chainHead_v1_continue', + [session.smoldotSubscriptionId, request.operationId], + ); + return unitValue; + }); + + @override + Future> stopHeadOperation( + CallContext ctx, + RemoteChainHeadStopOperationRequest request, + ) => + _guardSession(request.followSubscriptionId, (session) async { + await session.client.request( + 'chainHead_v1_stopOperation', + [session.smoldotSubscriptionId, request.operationId], + ); + return unitValue; + }); + + // --- chainSpec_v1_* -------------------------------------------------------- + + @override + Future> + getSpecGenesisHash( + CallContext ctx, + RemoteChainSpecGenesisHashRequest request, + ) => + _guard(request.genesisHash, (client) async { + final hash = + await client.request('chainSpec_v1_genesisHash') as String; + return RemoteChainSpecGenesisHashResponse( + genesisHash: hexToBytes(hash), + ); + }); + + @override + Future> + getSpecChainName( + CallContext ctx, + RemoteChainSpecChainNameRequest request, + ) => + _guard(request.genesisHash, (client) async { + final name = + await client.request('chainSpec_v1_chainName') as String; + return RemoteChainSpecChainNameResponse(chainName: name); + }); + + @override + Future> + getSpecProperties( + CallContext ctx, + RemoteChainSpecPropertiesRequest request, + ) => + _guard(request.genesisHash, (client) async { + final properties = await client.request('chainSpec_v1_properties'); + return RemoteChainSpecPropertiesResponse( + properties: jsonEncode(properties), + ); + }); + + // --- transaction_v1_* ------------------------------------------------------ + + @override + Future> + broadcastTransaction( + CallContext ctx, + RemoteChainTransactionBroadcastRequest request, + ) => + _guard(request.genesisHash, (client) async { + final operationId = await client.request( + 'transaction_v1_broadcast', + [bytesToHex(request.transaction)], + ); + return RemoteChainTransactionBroadcastResponse( + operationId: operationId as String?, + ); + }); + + @override + Future> stopTransaction( + CallContext ctx, + RemoteChainTransactionStopRequest request, + ) => + _guard(request.genesisHash, (client) async { + await client.request('transaction_v1_stop', [request.operationId]); + return unitValue; + }); + + // --- JSON → typed event mapping -------------------------------------------- + + /// Map a `chainHead_v1_follow` event payload to a [RemoteChainHeadFollowItem], + /// or `null` for an event shape we don't model. + RemoteChainHeadFollowItem? _mapFollowEvent(Object? raw) { + if (raw is! Map) return null; + switch (raw['event']) { + case 'initialized': + final hashes = raw['finalizedBlockHashes'] ?? + (raw['finalizedBlockHash'] != null + ? [raw['finalizedBlockHash']] + : const []); + return RemoteChainHeadFollowItemInitialized( + finalizedBlockHashes: _hashList(hashes), + finalizedBlockRuntime: _runtime(raw['finalizedBlockRuntime']), + ); + case 'newBlock': + return RemoteChainHeadFollowItemNewBlock( + blockHash: hexToBytes(raw['blockHash'] as String), + parentBlockHash: hexToBytes(raw['parentBlockHash'] as String), + newRuntime: _runtime(raw['newRuntime']), + ); + case 'bestBlockChanged': + return RemoteChainHeadFollowItemBestBlockChanged( + bestBlockHash: hexToBytes(raw['bestBlockHash'] as String), + ); + case 'finalized': + return RemoteChainHeadFollowItemFinalized( + finalizedBlockHashes: _hashList(raw['finalizedBlockHashes']), + prunedBlockHashes: _hashList(raw['prunedBlockHashes']), + ); + case 'operationBodyDone': + return RemoteChainHeadFollowItemOperationBodyDone( + operationId: raw['operationId'] as String, + value: _hashList(raw['value']), + ); + case 'operationCallDone': + return RemoteChainHeadFollowItemOperationCallDone( + operationId: raw['operationId'] as String, + output: hexToBytes(raw['output'] as String), + ); + case 'operationStorageItems': + return RemoteChainHeadFollowItemOperationStorageItems( + operationId: raw['operationId'] as String, + items: _storageItems(raw['items']), + ); + case 'operationStorageDone': + return RemoteChainHeadFollowItemOperationStorageDone( + operationId: raw['operationId'] as String, + ); + case 'operationWaitingForContinue': + return RemoteChainHeadFollowItemOperationWaitingForContinue( + operationId: raw['operationId'] as String, + ); + case 'operationInaccessible': + return RemoteChainHeadFollowItemOperationInaccessible( + operationId: raw['operationId'] as String, + ); + case 'operationError': + return RemoteChainHeadFollowItemOperationError( + operationId: raw['operationId'] as String, + error: raw['error']?.toString() ?? 'operation error', + ); + case 'stop': + return const RemoteChainHeadFollowItemStop(); + default: + return null; + } + } + + OperationStartedResult _operationStarted(Object? raw) { + if (raw is Map && raw['result'] == 'started') { + return OperationStartedResultStarted( + operationId: raw['operationId'] as String, + ); + } + return const OperationStartedResultLimitReached(); + } + + RuntimeType? _runtime(Object? raw) { + if (raw is! Map) return null; + if (raw['type'] == 'valid') { + return RuntimeTypeValid(_runtimeSpec(raw['spec'] as Map)); + } + return RuntimeTypeInvalid( + error: raw['error']?.toString() ?? 'invalid runtime', + ); + } + + RuntimeSpec _runtimeSpec(Map spec) => RuntimeSpec( + specName: spec['specName'] as String, + implName: spec['implName'] as String, + specVersion: (spec['specVersion'] as num).toInt(), + implVersion: (spec['implVersion'] as num).toInt(), + transactionVersion: (spec['transactionVersion'] as num?)?.toInt(), + apis: _runtimeApis(spec['apis']), + ); + + List _runtimeApis(Object? raw) { + if (raw is! Map) return const []; + return [ + for (final entry in raw.entries) + RuntimeApi( + name: entry.key as String, + version: (entry.value as num).toInt(), + ), + ]; + } + + List _storageItems(Object? raw) { + if (raw is! List) return const []; + return [ + for (final item in raw.cast()) + StorageResultItem( + key: hexToBytes(item['key'] as String), + value: _optionalHex(item['value']), + hash: _optionalHex(item['hash']), + closestDescendantMerkleValue: + _optionalHex(item['closestDescendantMerkleValue']), + ), + ]; + } + + List _hashList(Object? raw) { + if (raw is! List) return const []; + return [for (final hash in raw) hexToBytes(hash as String)]; + } + + Uint8List? _optionalHex(Object? raw) => + raw == null ? null : hexToBytes(raw as String); + + String _queryType(StorageQueryType type) => switch (type) { + StorageQueryType.value => 'value', + StorageQueryType.hash => 'hash', + StorageQueryType.closestDescendantMerkleValue => + 'closestDescendantMerkleValue', + StorageQueryType.descendantsValues => 'descendantsValues', + StorageQueryType.descendantsHashes => 'descendantsHashes', + }; +} diff --git a/dart/truapi_smoldot/lib/src/hex.dart b/dart/truapi_smoldot/lib/src/hex.dart new file mode 100644 index 00000000..d3e6fa4a --- /dev/null +++ b/dart/truapi_smoldot/lib/src/hex.dart @@ -0,0 +1,22 @@ +import 'dart:typed_data'; + +/// Encode bytes as a lower-case `0x`-prefixed hex string (JSON-RPC convention). +String bytesToHex(Uint8List bytes) { + final out = StringBuffer('0x'); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); +} + +/// Decode a hex string (with or without a `0x` prefix) into bytes. +Uint8List hexToBytes(String hex) { + final start = hex.startsWith('0x') ? 2 : 0; + final length = (hex.length - start) ~/ 2; + final out = Uint8List(length); + for (var i = 0; i < length; i++) { + out[i] = + int.parse(hex.substring(start + i * 2, start + i * 2 + 2), radix: 16); + } + return out; +} diff --git a/dart/truapi_smoldot/lib/src/json_rpc_client.dart b/dart/truapi_smoldot/lib/src/json_rpc_client.dart new file mode 100644 index 00000000..1d96e225 --- /dev/null +++ b/dart/truapi_smoldot/lib/src/json_rpc_client.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:smoldot_provider/smoldot_provider.dart'; + +/// A JSON-RPC error returned by the peer. +class JsonRpcException implements Exception { + JsonRpcException(this.code, this.message, [this.data]); + + factory JsonRpcException.fromJson(Map json) => + JsonRpcException( + (json['code'] as num?)?.toInt() ?? 0, + json['message']?.toString() ?? 'JSON-RPC error', + json['data'], + ); + + /// JSON-RPC error code. + final int code; + + /// Human-readable message. + final String message; + + /// Optional structured error data. + final Object? data; + + @override + String toString() => 'JsonRpcException($code): $message'; +} + +/// A request/response + subscription JSON-RPC client over a [JsonRpcProvider]. +/// +/// Owns request ids and subscription correlation: a single read-loop over the +/// provider's `onMessage` routes responses by `id` and notifications by +/// `params.subscription`. This is the consumer the TrUAPI Chain/StatementStore +/// handlers run on top of a smoldot light client, mirroring substrate-connect / +/// polkadot-api. +class JsonRpcClient { + JsonRpcClient(JsonRpcProvider provider) { + _connection = provider(_onMessage); + } + + late final JsonRpcConnection _connection; + int _nextId = 1; + final Map>> _pending = {}; + final Map> _subscriptions = {}; + // Notifications that arrive in the microtask window between a subscribe + // response and the controller being registered are buffered here, then + // flushed on registration. + final Map> _orphanNotifications = {}; + bool _closed = false; + + /// Send a JSON-RPC request and resolve with its `result`. + /// + /// Throws [JsonRpcException] on a JSON-RPC error response, or [StateError] + /// once [close] has been called. + Future request( + String method, [ + List params = const [], + ]) async { + if (_closed) throw StateError('JsonRpcClient is closed'); + final id = _nextId++; + final completer = Completer>(); + _pending[id] = completer; + _connection.send( + jsonEncode( + {'jsonrpc': '2.0', 'id': id, 'method': method, 'params': params}), + ); + final message = await completer.future; + final error = message['error']; + if (error is Map) { + throw JsonRpcException.fromJson(error); + } + return message['result']; + } + + /// Start a subscription. Returns its id and a broadcast-free stream of + /// notification payloads (`params.result`). Cancelling the stream sends + /// [unsubscribeMethod] with the subscription id. + Future<(String id, Stream notifications)> subscribe( + String method, + List params, + String unsubscribeMethod, + ) async { + final subscriptionId = (await request(method, params)).toString(); + final controller = StreamController( + onCancel: () { + _subscriptions.remove(subscriptionId); + if (!_closed) { + // Fire-and-forget the unsubscribe. + request(unsubscribeMethod, [subscriptionId]).ignore(); + } + }, + ); + _subscriptions[subscriptionId] = controller; + final buffered = _orphanNotifications.remove(subscriptionId); + if (buffered != null) { + for (final item in buffered) { + controller.add(item); + } + } + return (subscriptionId, controller.stream); + } + + void _onMessage(String raw) { + if (_closed) return; + final Map message; + try { + message = jsonDecode(raw) as Map; + } catch (_) { + return; // ignore malformed frames + } + + final id = message['id']; + if (id != null) { + _pending.remove((id as num).toInt())?.complete(message); + return; + } + + // Notification: { method, params: { subscription, result } }. + final params = message['params']; + if (params is Map) { + final subscription = params['subscription']?.toString(); + if (subscription == null) return; + final result = params['result']; + final controller = _subscriptions[subscription]; + if (controller != null) { + controller.add(result); + } else { + (_orphanNotifications[subscription] ??= []).add(result); + } + } + } + + /// Stop the client: closes the provider connection and all subscription + /// streams, and fails any in-flight requests. + Future close() async { + if (_closed) return; + _closed = true; + for (final completer in _pending.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('JsonRpcClient closed')); + } + } + _pending.clear(); + for (final controller in _subscriptions.values) { + await controller.close(); + } + _subscriptions.clear(); + _orphanNotifications.clear(); + _connection.disconnect(); + } +} diff --git a/dart/truapi_smoldot/lib/src/statement_codec.dart b/dart/truapi_smoldot/lib/src/statement_codec.dart new file mode 100644 index 00000000..1d076032 --- /dev/null +++ b/dart/truapi_smoldot/lib/src/statement_codec.dart @@ -0,0 +1,293 @@ +/// SCALE codec for Substrate's `sp_statement_store::Statement`, which smoldot's +/// `statement_submit` / `statement_subscribeStatement` speak. +/// +/// A statement encodes as a SCALE `Vec`: a compact length followed by +/// each present field as `tag(u8) ++ content`, emitted in ascending tag order: +/// +/// | tag | field | content | +/// |-----|------------------|------------------------------------------| +/// | 0 | AuthenticityProof | the [StatementProof] (same bytes as TrUAPI) | +/// | 1 | DecryptionKey | `[u8; 32]` | +/// | 2 | Priority | `u32` | +/// | 3 | Channel | `[u8; 32]` | +/// | 4–7 | Topic1–Topic4 | `[u8; 32]` each (max four topics) | +/// | 8 | Data | `Vec` | +/// +/// TrUAPI's `SignedStatement` has no `priority` and an `expiry: u64` that the +/// product packs as `unixSeconds << 32`. We map that to Substrate's `Priority` +/// as `priority = (expiry >> 32)` (the high 32 bits), so a larger expiry sorts +/// as higher priority; decoding reverses it (`expiry = priority << 32`). This +/// expiry⇄priority mapping is the part the live statement-store test validates. +library; + +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; + +const int _tagProof = 0; +const int _tagDecryptionKey = 1; +const int _tagPriority = 2; +const int _tagChannel = 3; +const int _tagTopic1 = 4; +const int _tagTopic2 = 5; +const int _tagTopic3 = 6; +const int _tagTopic4 = 7; +const int _tagData = 8; + +/// Maximum number of topics a Substrate statement can carry (Topic1..Topic4). +const int maxStatementTopics = 4; + +/// Encode a [SignedStatement] into Substrate statement bytes. +Uint8List encodeStatement(SignedStatement statement) { + if (statement.topics.length > maxStatementTopics) { + throw ArgumentError( + 'a statement carries at most $maxStatementTopics topics, ' + 'got ${statement.topics.length}', + ); + } + + final fields = <(int, Uint8List)>[]; + fields.add((_tagProof, _encodeProof(statement.proof))); + if (statement.decryptionKey != null) { + fields.add((_tagDecryptionKey, _fixed(statement.decryptionKey!, 32))); + } + if (statement.expiry != null) { + final priority = (statement.expiry! >> 32).toUnsigned(32).toInt(); + fields.add((_tagPriority, _u32(priority))); + } + if (statement.channel != null) { + fields.add((_tagChannel, _fixed(statement.channel!, 32))); + } + for (var i = 0; i < statement.topics.length; i++) { + fields.add((_tagTopic1 + i, _fixed(statement.topics[i], 32))); + } + if (statement.data != null) { + fields.add((_tagData, _bytes(statement.data!))); + } + + final out = BytesBuilder(copy: false); + _writeCompact(out, fields.length); + for (final (tag, content) in fields) { + out.addByte(tag); + out.add(content); + } + return out.toBytes(); +} + +/// Decode Substrate statement bytes into a [SignedStatement]. +/// +/// Throws [FormatException] if the proof field (required by [SignedStatement]) +/// is absent or the bytes are malformed. +SignedStatement decodeStatement(Uint8List bytes) { + final reader = _Reader(bytes); + final fieldCount = reader.readCompact(); + + StatementProof? proof; + Uint8List? decryptionKey; + BigInt? expiry; + Uint8List? channel; + final topics = []; + Uint8List? data; + + for (var i = 0; i < fieldCount; i++) { + final tag = reader.readByte(); + switch (tag) { + case _tagProof: + proof = _decodeProof(reader); + case _tagDecryptionKey: + decryptionKey = reader.readFixed(32); + case _tagPriority: + expiry = BigInt.from(reader.readU32()) << 32; + case _tagChannel: + channel = reader.readFixed(32); + case _tagTopic1: + case _tagTopic2: + case _tagTopic3: + case _tagTopic4: + topics.add(reader.readFixed(32)); + case _tagData: + data = reader.readBytes(); + default: + throw FormatException('unknown statement field tag $tag'); + } + } + + if (proof == null) { + throw const FormatException('statement has no authenticity proof'); + } + return SignedStatement( + proof: proof, + decryptionKey: decryptionKey, + expiry: expiry, + channel: channel, + topics: topics, + data: data, + ); +} + +Uint8List _encodeProof(StatementProof proof) { + final out = BytesBuilder(copy: false); + switch (proof) { + case StatementProofSr25519(): + out.addByte(0); + out.add(_fixed(proof.signature, 64)); + out.add(_fixed(proof.signer, 32)); + case StatementProofEd25519(): + out.addByte(1); + out.add(_fixed(proof.signature, 64)); + out.add(_fixed(proof.signer, 32)); + case StatementProofEcdsa(): + out.addByte(2); + out.add(_fixed(proof.signature, 65)); + out.add(_fixed(proof.signer, 33)); + case StatementProofOnChain(): + out.addByte(3); + out.add(_fixed(proof.who, 32)); + out.add(_fixed(proof.blockHash, 32)); + out.add(_u64(proof.event)); + } + return out.toBytes(); +} + +StatementProof _decodeProof(_Reader reader) { + final variant = reader.readByte(); + switch (variant) { + case 0: + return StatementProofSr25519( + signature: reader.readFixed(64), + signer: reader.readFixed(32), + ); + case 1: + return StatementProofEd25519( + signature: reader.readFixed(64), + signer: reader.readFixed(32), + ); + case 2: + return StatementProofEcdsa( + signature: reader.readFixed(65), + signer: reader.readFixed(33), + ); + case 3: + return StatementProofOnChain( + who: reader.readFixed(32), + blockHash: reader.readFixed(32), + event: reader.readU64(), + ); + default: + throw FormatException('unknown statement proof variant $variant'); + } +} + +Uint8List _fixed(Uint8List value, int length) { + if (value.length != length) { + throw ArgumentError('expected $length bytes, got ${value.length}'); + } + return value; +} + +Uint8List _u32(int value) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, value, Endian.little); + return bytes; +} + +Uint8List _u64(BigInt value) { + final bytes = Uint8List(8); + final data = ByteData.view(bytes.buffer); + data.setUint64(0, value.toUnsigned(64).toInt(), Endian.little); + return bytes; +} + +/// `Vec`: a compact length prefix followed by the raw bytes. +Uint8List _bytes(Uint8List value) { + final out = BytesBuilder(copy: false); + _writeCompact(out, value.length); + out.add(value); + return out.toBytes(); +} + +/// Write a SCALE compact-encoded non-negative integer. +void _writeCompact(BytesBuilder out, int value) { + if (value < 0) throw ArgumentError('compact value must be non-negative'); + if (value < 1 << 6) { + out.addByte(value << 2); + } else if (value < 1 << 14) { + final v = (value << 2) | 0x01; + out.addByte(v & 0xff); + out.addByte((v >> 8) & 0xff); + } else if (value < 1 << 30) { + final v = (value << 2) | 0x02; + out.addByte(v & 0xff); + out.addByte((v >> 8) & 0xff); + out.addByte((v >> 16) & 0xff); + out.addByte((v >> 24) & 0xff); + } else { + final bytes = []; + var v = value; + while (v > 0) { + bytes.add(v & 0xff); + v >>= 8; + } + out.addByte(((bytes.length - 4) << 2) | 0x03); + for (final b in bytes) { + out.addByte(b); + } + } +} + +class _Reader { + _Reader(this._bytes); + final Uint8List _bytes; + int _offset = 0; + + int readByte() { + if (_offset >= _bytes.length) { + throw const FormatException('unexpected end of statement bytes'); + } + return _bytes[_offset++]; + } + + Uint8List readFixed(int length) { + if (_offset + length > _bytes.length) { + throw const FormatException('unexpected end of statement bytes'); + } + final slice = Uint8List.sublistView(_bytes, _offset, _offset + length); + _offset += length; + return Uint8List.fromList(slice); + } + + int readU32() { + final slice = readFixed(4); + return ByteData.view(slice.buffer).getUint32(0, Endian.little); + } + + BigInt readU64() { + final slice = readFixed(8); + final value = ByteData.view(slice.buffer).getUint64(0, Endian.little); + return BigInt.from(value).toUnsigned(64); + } + + Uint8List readBytes() => readFixed(readCompact()); + + int readCompact() { + final first = readByte(); + switch (first & 0x03) { + case 0: + return first >> 2; + case 1: + return (first >> 2) | (readByte() << 6); + case 2: + final b1 = readByte(); + final b2 = readByte(); + final b3 = readByte(); + return (first >> 2) | (b1 << 6) | (b2 << 14) | (b3 << 22); + default: + final length = (first >> 2) + 4; + var value = 0; + for (var i = 0; i < length; i++) { + value |= readByte() << (8 * i); + } + return value; + } + } +} diff --git a/dart/truapi_smoldot/lib/src/statement_store_handlers.dart b/dart/truapi_smoldot/lib/src/statement_store_handlers.dart new file mode 100644 index 00000000..0922db60 --- /dev/null +++ b/dart/truapi_smoldot/lib/src/statement_store_handlers.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; + +import 'backend.dart'; +import 'hex.dart'; +import 'json_rpc_client.dart'; +import 'statement_codec.dart'; + +/// Resolves the [JsonRpcClient] for the statement-store-enabled chain. +typedef StatementClientResolver = Future Function(); + +/// Backs the TrUAPI **StatementStore** service with a smoldot light client. +/// +/// `submit` and `subscribe` map onto smoldot's `statement_submit` / +/// `statement_subscribeStatement`; the typed [SignedStatement] is transcoded to +/// and from Substrate statement bytes (see `statement_codec.dart`). Topic +/// filtering ([RemoteStatementStoreSubscribeRequest]) is applied host-side over +/// the decoded statements. +/// +/// StatementStore requests carry no genesis hash, so a handler is bound to a +/// single chain (the one added with `statementStore` enabled). +/// +/// `createProof` / `createProofAuthorized` are **not** supported: statement +/// proofs are produced by the wallet, not a light client. Both return +/// `Err(RemoteStatementStoreCreateProofErrorUnknown)`. +class SmoldotStatementStoreHandlers implements StatementStoreHostHandlers { + SmoldotStatementStoreHandlers(this._client); + + /// Resolve the statement-store chain client from a [SmoldotChainBackend] by + /// its genesis hash. The chain must have been registered with a + /// [ChainSource] whose `enableStatementStore` is `true`. + SmoldotStatementStoreHandlers.backend( + SmoldotChainBackend backend, + Uint8List genesisHash, + ) : _client = (() => backend.clientFor(genesisHash)); + + final StatementClientResolver _client; + + static const _signingUnsupported = + 'statement proofs are created by the wallet, not the smoldot light client'; + + @override + Future> submit( + CallContext ctx, + SignedStatement request, + ) async { + try { + final client = await _client(); + final encoded = encodeStatement(request); + await client.request('statement_submit', [bytesToHex(encoded)]); + return Ok(unitValue); + } catch (error) { + return Err(GenericError(reason: error.toString())); + } + } + + @override + Stream subscribe( + CallContext ctx, + RemoteStatementStoreSubscribeRequest request, + ) { + final matches = _matcher(request); + StreamSubscription? inner; + late StreamController controller; + + Future start() async { + try { + final client = await _client(); + final (_, events) = await client.subscribe( + 'statement_subscribeStatement', + const [], + 'statement_unsubscribeStatement', + ); + inner = events.listen( + (event) { + final statement = _tryDecode(event); + if (statement == null || !matches(statement.topics)) return; + if (!controller.isClosed) { + controller.add( + RemoteStatementStoreSubscribeItem( + statements: [statement], + isComplete: true, + ), + ); + } + }, + onError: (Object error) { + if (!controller.isClosed) controller.addError(error); + }, + onDone: () { + if (!controller.isClosed) controller.close(); + }, + ); + } catch (error) { + if (!controller.isClosed) { + controller.addError(error); + await controller.close(); + } + } + } + + controller = StreamController( + onListen: start, + onCancel: () async => inner?.cancel(), + ); + return controller.stream; + } + + @override + Future< + Result> createProof( + CallContext ctx, + RemoteStatementStoreCreateProofRequest request, + ) async => + Err( + const RemoteStatementStoreCreateProofErrorUnknown( + reason: _signingUnsupported, + ), + ); + + @override + Future< + Result> createProofAuthorized( + CallContext ctx, + Statement request, + ) async => + Err( + const RemoteStatementStoreCreateProofErrorUnknown( + reason: _signingUnsupported, + ), + ); + + /// Build the topic predicate for [request] (AND for MatchAll, OR for + /// MatchAny). An empty filter matches every statement. + bool Function(List topics) _matcher( + RemoteStatementStoreSubscribeRequest request, + ) { + final (wanted, all) = switch (request) { + RemoteStatementStoreSubscribeRequestMatchAll(:final value) => ( + value.map(bytesToHex).toSet(), + true, + ), + RemoteStatementStoreSubscribeRequestMatchAny(:final value) => ( + value.map(bytesToHex).toSet(), + false, + ), + }; + return (topics) { + if (wanted.isEmpty) return true; + final have = topics.map(bytesToHex).toSet(); + return all ? wanted.every(have.contains) : wanted.any(have.contains); + }; + } + + SignedStatement? _tryDecode(Object? event) { + try { + final hex = _statementHex(event); + return hex == null ? null : decodeStatement(hexToBytes(hex)); + } catch (_) { + return null; + } + } + + /// A `statement_subscribeStatement` notification is a hex statement, or an + /// object wrapping one. + String? _statementHex(Object? event) { + if (event is String) return event; + if (event is Map) { + final value = + event['statement'] ?? event['encoded'] ?? event['encodedStatement']; + if (value is String) return value; + } + return null; + } +} diff --git a/dart/truapi_smoldot/lib/truapi_smoldot.dart b/dart/truapi_smoldot/lib/truapi_smoldot.dart new file mode 100644 index 00000000..8342238f --- /dev/null +++ b/dart/truapi_smoldot/lib/truapi_smoldot.dart @@ -0,0 +1,28 @@ +/// Backs the TrUAPI Dart host's chain-facing services with a smoldot light +/// client. +/// +/// Maps the typed TrUAPI **Chain** and **StatementStore** SCALE protocols onto +/// Polkadot JSON-RPC over a [JsonRpcClient], which runs on a `smoldot_provider` +/// `JsonRpcProvider`. Plug [SmoldotChainHandlers] / [SmoldotStatementStoreHandlers] +/// into your generated `TruapiHostHandlers`: +/// +/// ```dart +/// final backend = await SmoldotChainBackend.create(chains: { +/// '0x': ChainSource(chainSpec: westendSpec), +/// }); +/// final chain = SmoldotChainHandlers.backend(backend); +/// // ... wire `chain` into your TruapiHostHandlers.chain getter. +/// ``` +library; + +export 'src/backend.dart' show ChainSource, SmoldotChainBackend; +export 'src/chain_handlers.dart' show ChainClientResolver, SmoldotChainHandlers; +export 'src/hex.dart' show bytesToHex, hexToBytes; +export 'src/json_rpc_client.dart' show JsonRpcClient, JsonRpcException; +export 'src/statement_codec.dart' + show decodeStatement, encodeStatement, maxStatementTopics; +export 'src/statement_store_handlers.dart' + show SmoldotStatementStoreHandlers, StatementClientResolver; +// Re-export the smoldot configuration types a consumer needs. +export 'package:smoldot_provider/smoldot_provider.dart' + show SmoldotConfig, StatementStoreConfig; diff --git a/dart/truapi_smoldot/pubspec.yaml b/dart/truapi_smoldot/pubspec.yaml new file mode 100644 index 00000000..6402c2f2 --- /dev/null +++ b/dart/truapi_smoldot/pubspec.yaml @@ -0,0 +1,31 @@ +name: truapi_smoldot +description: >- + Backs the TrUAPI Dart host's Chain (and StatementStore) services with a smoldot + light client. Maps the typed TrUAPI Chain SCALE protocol onto Polkadot + JSON-RPC (chainHead_v1_* / chainSpec_v1_* / transaction_v1_* / statement_*) over + a smoldot_provider JsonRpcProvider, implementing the generated host handler + interfaces. +version: 0.1.0 +repository: https://github.com/paritytech/truapi +publish_to: none + +environment: + sdk: ^3.5.0 + +# Cross-repo local-dev wiring: `truapi` is this repo's host package; the smoldot +# light-client provider lives in snowpinelabs/polkadart. None are published, so +# these are path/override deps for local development. For a real release, pin +# them to published or git refs. +dependencies: + truapi: + path: ../truapi + smoldot_provider: + path: ../../../polkadart-snowpinelabs/packages/smoldot_provider + +dependency_overrides: + smoldot: + path: ../../../polkadart-snowpinelabs/packages/smoldot + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.0 diff --git a/dart/truapi_smoldot/test/chain_handlers_test.dart b/dart/truapi_smoldot/test/chain_handlers_test.dart new file mode 100644 index 00000000..dfd0078d --- /dev/null +++ b/dart/truapi_smoldot/test/chain_handlers_test.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:smoldot_provider/smoldot_provider.dart'; +import 'package:truapi/truapi.dart'; +import 'package:truapi_smoldot/truapi_smoldot.dart'; +import 'package:test/test.dart'; + +/// A [JsonRpcProvider] that auto-answers requests from a `method -> result` map. +JsonRpcProvider scriptedProvider(Map responses) => + (onMessage) => _ScriptedConnection(responses, onMessage); + +class _ScriptedConnection implements JsonRpcConnection { + _ScriptedConnection(this._responses, this._onMessage); + final Map _responses; + final void Function(String message) _onMessage; + bool _closed = false; + + @override + void send(String message) { + final request = jsonDecode(message) as Map; + final method = request['method'] as String; + final id = request['id']; + scheduleMicrotask(() { + if (_closed) return; + if (_responses.containsKey(method)) { + _onMessage(jsonEncode( + {'jsonrpc': '2.0', 'id': id, 'result': _responses[method]})); + } else { + _onMessage(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': {'code': -32601, 'message': 'no script for $method'}, + })); + } + }); + } + + @override + void disconnect() => _closed = true; +} + +SmoldotChainHandlers handlersWith(Map responses) { + final client = JsonRpcClient(scriptedProvider(responses)); + return SmoldotChainHandlers((_) async => client); +} + +const _ctx = CallContext('p:1'); +final _genesis = Uint8List.fromList([1, 2, 3]); + +void main() { + test('getSpecGenesisHash maps chainSpec_v1_genesisHash', () async { + final handlers = handlersWith({'chainSpec_v1_genesisHash': '0xdeadbeef'}); + final result = await handlers.getSpecGenesisHash( + _ctx, + RemoteChainSpecGenesisHashRequest(genesisHash: _genesis), + ); + final ok = result as Ok; + expect(ok.value.genesisHash, hexToBytes('0xdeadbeef')); + }); + + test('getSpecChainName maps chainSpec_v1_chainName', () async { + final handlers = handlersWith({'chainSpec_v1_chainName': 'Westend'}); + final result = await handlers.getSpecChainName( + _ctx, + RemoteChainSpecChainNameRequest(genesisHash: _genesis), + ); + final ok = result as Ok; + expect(ok.value.chainName, 'Westend'); + }); + + test('getSpecProperties JSON-encodes chainSpec_v1_properties', () async { + final handlers = handlersWith({ + 'chainSpec_v1_properties': {'ss58Format': 42, 'tokenDecimals': 12}, + }); + final result = await handlers.getSpecProperties( + _ctx, + RemoteChainSpecPropertiesRequest(genesisHash: _genesis), + ); + final ok = result as Ok; + expect( + jsonDecode(ok.value.properties), + {'ss58Format': 42, 'tokenDecimals': 12}, + ); + }); + + test('a JSON-RPC error maps to Err(GenericError)', () async { + final handlers = handlersWith(const {}); // no script → error response + final result = await handlers.getSpecGenesisHash( + _ctx, + RemoteChainSpecGenesisHashRequest(genesisHash: _genesis), + ); + expect(result.isErr, isTrue); + expect((result as Err).error.reason, contains('no script')); + }); +} diff --git a/dart/truapi_smoldot/test/chain_head_test.dart b/dart/truapi_smoldot/test/chain_head_test.dart new file mode 100644 index 00000000..f6d0ea36 --- /dev/null +++ b/dart/truapi_smoldot/test/chain_head_test.dart @@ -0,0 +1,330 @@ +import 'dart:async'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi_smoldot/truapi_smoldot.dart'; +import 'package:test/test.dart'; + +import 'support/fake_chain.dart'; + +const _genesisHex = '0x0102'; +final _genesis = hexToBytes(_genesisHex); + +void main() { + group('followHeadSubscribe', () { + test('maps follow events into typed items', () async { + final chain = FakeChain() + ..responders['chainHead_v1_follow'] = (_) => 'sub-1'; + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + + final items = []; + final sub = handlers + .followHeadSubscribe( + const CallContext('req-1'), + RemoteChainHeadFollowRequest( + genesisHash: _genesis, + withRuntime: false, + ), + ) + .listen(items.add); + + await pumpEventQueue(); + chain.notify('sub-1', { + 'event': 'initialized', + 'finalizedBlockHashes': ['0xaa'], + }); + chain.notify('sub-1', { + 'event': 'newBlock', + 'blockHash': '0xbb', + 'parentBlockHash': '0xaa', + }); + chain.notify( + 'sub-1', {'event': 'bestBlockChanged', 'bestBlockHash': '0xbb'}); + chain.notify('sub-1', { + 'event': 'finalized', + 'finalizedBlockHashes': ['0xbb'], + 'prunedBlockHashes': [], + }); + chain.notify('sub-1', { + 'event': 'operationCallDone', + 'operationId': 'op-1', + 'output': '0xc0de', + }); + chain.notify('sub-1', {'event': 'stop'}); + await pumpEventQueue(); + await sub.cancel(); + + expect(chain.paramsFor('chainHead_v1_follow'), [false]); + expect(items, hasLength(6)); + expect( + (items[0] as RemoteChainHeadFollowItemInitialized).finalizedBlockHashes, + [hexToBytes('0xaa')], + ); + final newBlock = items[1] as RemoteChainHeadFollowItemNewBlock; + expect(newBlock.blockHash, hexToBytes('0xbb')); + expect(newBlock.parentBlockHash, hexToBytes('0xaa')); + expect( + (items[2] as RemoteChainHeadFollowItemBestBlockChanged).bestBlockHash, + hexToBytes('0xbb'), + ); + expect( + (items[3] as RemoteChainHeadFollowItemFinalized).finalizedBlockHashes, + [hexToBytes('0xbb')], + ); + final callDone = items[4] as RemoteChainHeadFollowItemOperationCallDone; + expect(callDone.operationId, 'op-1'); + expect(callDone.output, hexToBytes('0xc0de')); + expect(items[5], isA()); + }); + + test('decodes a withRuntime "valid" runtime spec', () async { + final chain = FakeChain() + ..responders['chainHead_v1_follow'] = (_) => 'sub-1'; + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + + RemoteChainHeadFollowItem? first; + final sub = handlers + .followHeadSubscribe( + const CallContext('req-1'), + RemoteChainHeadFollowRequest( + genesisHash: _genesis, + withRuntime: true, + ), + ) + .listen((item) => first ??= item); + + await pumpEventQueue(); + chain.notify('sub-1', { + 'event': 'initialized', + 'finalizedBlockHashes': ['0xaa'], + 'finalizedBlockRuntime': { + 'type': 'valid', + 'spec': { + 'specName': 'westend', + 'implName': 'parity-westend', + 'specVersion': 1014000, + 'implVersion': 0, + 'transactionVersion': 26, + 'apis': {'0xdf6acb689907609b': 5}, + }, + }, + }); + await pumpEventQueue(); + await sub.cancel(); + + final runtime = (first as RemoteChainHeadFollowItemInitialized) + .finalizedBlockRuntime as RuntimeTypeValid; + expect(runtime.value.specName, 'westend'); + expect(runtime.value.specVersion, 1014000); + expect(runtime.value.transactionVersion, 26); + expect(runtime.value.apis.single.name, '0xdf6acb689907609b'); + expect(runtime.value.apis.single.version, 5); + }); + }); + + group('chainHead operations', () { + Future<(FakeChain, SmoldotChainHandlers, StreamSubscription)> + startFollow() async { + final chain = FakeChain() + ..responders['chainHead_v1_follow'] = (_) => 'sub-1'; + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + final sub = handlers + .followHeadSubscribe( + const CallContext('req-1'), + RemoteChainHeadFollowRequest( + genesisHash: _genesis, + withRuntime: false, + ), + ) + .listen((_) {}); + await pumpEventQueue(); + return (chain, handlers, sub); + } + + test('getHeadHeader passes the follow sub id and returns the header', + () async { + final (chain, handlers, sub) = await startFollow(); + chain.responders['chainHead_v1_header'] = (_) => '0xdead'; + + final result = await handlers.getHeadHeader( + const CallContext('req-2'), + RemoteChainHeadHeaderRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + hash: hexToBytes('0xbb'), + ), + ); + + final ok = result as Ok; + expect(ok.value.header, hexToBytes('0xdead')); + expect(chain.paramsFor('chainHead_v1_header'), ['sub-1', '0xbb']); + await sub.cancel(); + }); + + test('getHeadBody returns a started operation', () async { + final (chain, handlers, sub) = await startFollow(); + chain.responders['chainHead_v1_body'] = + (_) => {'result': 'started', 'operationId': 'op-7'}; + + final result = await handlers.getHeadBody( + const CallContext('req-2'), + RemoteChainHeadBodyRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + hash: hexToBytes('0xbb'), + ), + ); + + final ok = result as Ok; + final started = ok.value.operation as OperationStartedResultStarted; + expect(started.operationId, 'op-7'); + await sub.cancel(); + }); + + test('getHeadStorage encodes query items and a child trie', () async { + final (chain, handlers, sub) = await startFollow(); + chain.responders['chainHead_v1_storage'] = + (_) => {'result': 'started', 'operationId': 'op-8'}; + + await handlers.getHeadStorage( + const CallContext('req-2'), + RemoteChainHeadStorageRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + hash: hexToBytes('0xbb'), + items: [ + StorageQueryItem( + key: hexToBytes('0x26aa'), + queryType: StorageQueryType.value, + ), + StorageQueryItem( + key: hexToBytes('0x3a63'), + queryType: StorageQueryType.descendantsHashes, + ), + ], + childTrie: hexToBytes('0x99'), + ), + ); + + expect(chain.paramsFor('chainHead_v1_storage'), [ + 'sub-1', + '0xbb', + [ + {'key': '0x26aa', 'type': 'value'}, + {'key': '0x3a63', 'type': 'descendantsHashes'}, + ], + '0x99', + ]); + await sub.cancel(); + }); + + test('unpinHead / continueHead / stopHeadOperation return unit', () async { + final (chain, handlers, sub) = await startFollow(); + chain.responders['chainHead_v1_unpin'] = (_) => null; + chain.responders['chainHead_v1_continue'] = (_) => null; + chain.responders['chainHead_v1_stopOperation'] = (_) => null; + + final unpin = await handlers.unpinHead( + const CallContext('req-2'), + RemoteChainHeadUnpinRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + hashes: [hexToBytes('0xbb'), hexToBytes('0xcc')], + ), + ); + expect(unpin.isOk, isTrue); + expect(chain.paramsFor('chainHead_v1_unpin'), [ + 'sub-1', + ['0xbb', '0xcc'], + ]); + + final cont = await handlers.continueHead( + const CallContext('req-2'), + RemoteChainHeadContinueRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + operationId: 'op-1', + ), + ); + expect(cont.isOk, isTrue); + expect(chain.paramsFor('chainHead_v1_continue'), ['sub-1', 'op-1']); + + final stop = await handlers.stopHeadOperation( + const CallContext('req-2'), + RemoteChainHeadStopOperationRequest( + genesisHash: _genesis, + followSubscriptionId: 'req-1', + operationId: 'op-1', + ), + ); + expect(stop.isOk, isTrue); + await sub.cancel(); + }); + + test('an operation against an unknown follow id is an Err', () async { + final chain = FakeChain(); + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + + final result = await handlers.getHeadHeader( + const CallContext('req-2'), + RemoteChainHeadHeaderRequest( + genesisHash: _genesis, + followSubscriptionId: 'missing', + hash: hexToBytes('0xbb'), + ), + ); + + expect(result.isErr, isTrue); + expect((result as Err).error.reason, contains('no active follow')); + }); + }); + + group('transaction', () { + test('broadcastTransaction returns the operation id', () async { + final chain = FakeChain() + ..responders['transaction_v1_broadcast'] = (_) => 'tx-op'; + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + + final result = await handlers.broadcastTransaction( + const CallContext('req-1'), + RemoteChainTransactionBroadcastRequest( + genesisHash: _genesis, + transaction: hexToBytes('0xabcd'), + ), + ); + + final ok = + result as Ok; + expect(ok.value.operationId, 'tx-op'); + expect(chain.paramsFor('transaction_v1_broadcast'), ['0xabcd']); + }); + + test('stopTransaction returns unit', () async { + final chain = FakeChain() + ..responders['transaction_v1_stop'] = (_) => null; + final handlers = SmoldotChainHandlers( + (_) async => JsonRpcClient(chain.provider), + ); + + final result = await handlers.stopTransaction( + const CallContext('req-1'), + RemoteChainTransactionStopRequest( + genesisHash: _genesis, + operationId: 'tx-op', + ), + ); + + expect(result.isOk, isTrue); + expect(chain.paramsFor('transaction_v1_stop'), ['tx-op']); + }); + }); +} diff --git a/dart/truapi_smoldot/test/chain_integration_test.dart b/dart/truapi_smoldot/test/chain_integration_test.dart new file mode 100644 index 00000000..f2d42c28 --- /dev/null +++ b/dart/truapi_smoldot/test/chain_integration_test.dart @@ -0,0 +1,89 @@ +@Tags(['network']) +@Timeout(Duration(minutes: 3)) +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi_smoldot/truapi_smoldot.dart'; +import 'package:test/test.dart'; + +/// Westend's well-known genesis hash (the registry key for its [ChainSource]). +final _westendGenesis = hexToBytes( + '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', +); + +const _specPath = + '../../../polkadart-snowpinelabs/packages/smoldot/test/fixtures/westend.json'; + +void main() { + group('SmoldotChainHandlers against live Westend', () { + late SmoldotChainBackend backend; + late SmoldotChainHandlers chain; + + setUpAll(() async { + final spec = await File(_specPath).readAsString(); + backend = await SmoldotChainBackend.create( + chains: {bytesToHex(_westendGenesis): ChainSource(chainSpec: spec)}, + ); + chain = SmoldotChainHandlers.backend(backend); + }); + + tearDownAll(() => backend.dispose()); + + test('getSpecChainName returns Westend', () async { + final result = await chain.getSpecChainName( + const CallContext('1'), + RemoteChainSpecChainNameRequest(genesisHash: _westendGenesis), + ); + final ok = result as Ok; + expect(ok.value.chainName, 'Westend'); + }); + + test('getSpecGenesisHash matches the known genesis', () async { + final result = await chain.getSpecGenesisHash( + const CallContext('2'), + RemoteChainSpecGenesisHashRequest(genesisHash: _westendGenesis), + ); + final ok = result as Ok; + expect(ok.value.genesisHash, _westendGenesis); + }); + + test('follow → getHeadHeader returns the finalized block header', () async { + const followId = 'follow-1'; + final initialized = Completer(); + final sub = chain + .followHeadSubscribe( + const CallContext(followId), + RemoteChainHeadFollowRequest( + genesisHash: _westendGenesis, + withRuntime: false, + ), + ) + .listen((event) { + if (event is RemoteChainHeadFollowItemInitialized && + !initialized.isCompleted) { + initialized.complete(event); + } + }); + + final init = await initialized.future; + expect(init.finalizedBlockHashes, isNotEmpty); + + // The follow is still live, so the operation can reference its id. + final header = await chain.getHeadHeader( + const CallContext('h-1'), + RemoteChainHeadHeaderRequest( + genesisHash: _westendGenesis, + followSubscriptionId: followId, + hash: init.finalizedBlockHashes.first, + ), + ); + final ok = header as Ok; + expect(ok.value.header, isNotNull); + + await sub.cancel(); + }); + }); +} diff --git a/dart/truapi_smoldot/test/json_rpc_client_test.dart b/dart/truapi_smoldot/test/json_rpc_client_test.dart new file mode 100644 index 00000000..2b7f1eb2 --- /dev/null +++ b/dart/truapi_smoldot/test/json_rpc_client_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:smoldot_provider/smoldot_provider.dart'; +import 'package:truapi_smoldot/src/json_rpc_client.dart'; +import 'package:test/test.dart'; + +/// In-memory [JsonRpcProvider] that records sent frames and lets a test deliver +/// inbound frames. +class _FakeProvider { + void Function(String message)? _onMessage; + final List sent = []; + bool disconnected = false; + + JsonRpcProvider get provider => (onMessage) { + _onMessage = onMessage; + return _FakeConnection(this); + }; + + void deliver(String message) => _onMessage?.call(message); + + /// The `id` of the most recently sent frame. + Object? get lastId => (jsonDecode(sent.last) as Map)['id']; + + /// The `method` of the most recently sent frame. + Object? get lastMethod => + (jsonDecode(sent.last) as Map)['method']; +} + +class _FakeConnection implements JsonRpcConnection { + _FakeConnection(this._provider); + final _FakeProvider _provider; + @override + void send(String message) => _provider.sent.add(message); + @override + void disconnect() => _provider.disconnected = true; +} + +void main() { + late _FakeProvider fake; + late JsonRpcClient client; + + setUp(() { + fake = _FakeProvider(); + client = JsonRpcClient(fake.provider); + }); + + test('request resolves with the result', () async { + final future = client.request('system_chain'); + fake.deliver(jsonEncode({'id': fake.lastId, 'result': 'Westend'})); + expect(await future, 'Westend'); + }); + + test('request throws JsonRpcException on an error response', () async { + final future = client.request('bad_method'); + fake.deliver(jsonEncode({ + 'id': fake.lastId, + 'error': {'code': -32601, 'message': 'Method not found'}, + })); + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', -32601) + .having((e) => e.message, 'message', 'Method not found')), + ); + }); + + test('subscribe streams notifications and unsubscribes on cancel', () async { + final subFuture = client.subscribe( + 'chainHead_v1_follow', [false], 'chainHead_v1_unfollow'); + fake.deliver(jsonEncode({'id': fake.lastId, 'result': 'sub-A'})); + final (subId, stream) = await subFuture; + expect(subId, 'sub-A'); + + final items = []; + final sub = stream.listen(items.add); + fake.deliver(jsonEncode({ + 'method': 'chainHead_v1_followEvent', + 'params': { + 'subscription': 'sub-A', + 'result': {'event': 'initialized'}, + }, + })); + await Future.delayed(Duration.zero); + expect(items, [ + {'event': 'initialized'}, + ]); + + await sub.cancel(); + expect(fake.lastMethod, 'chainHead_v1_unfollow'); + }); + + test('notifications arriving before registration are buffered', () async { + final subFuture = client.subscribe('m', const [], 'unsub'); + final reqId = fake.lastId; + // Response (carries the subscription id) then a notification, both before + // the subscribe continuation registers the controller. + fake.deliver(jsonEncode({'id': reqId, 'result': 'sub-B'})); + fake.deliver(jsonEncode({ + 'method': 'x', + 'params': {'subscription': 'sub-B', 'result': 42}, + })); + final (_, stream) = await subFuture; + expect(await stream.first, 42); + }); + + test('close fails pending requests and disconnects', () async { + final future = client.request('system_chain'); + // Attach the error expectation before closing so the failure isn't an + // unhandled async error. + final expectation = expectLater(future, throwsStateError); + await client.close(); + expect(fake.disconnected, isTrue); + await expectation; + }); +} diff --git a/dart/truapi_smoldot/test/statement_codec_test.dart b/dart/truapi_smoldot/test/statement_codec_test.dart new file mode 100644 index 00000000..97c6066d --- /dev/null +++ b/dart/truapi_smoldot/test/statement_codec_test.dart @@ -0,0 +1,117 @@ +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi_smoldot/truapi_smoldot.dart'; +import 'package:test/test.dart'; + +Uint8List _filled(int length, int value) => + Uint8List.fromList(List.filled(length, value)); + +void main() { + group('encodeStatement', () { + test('a proof-only statement is a one-field Vec', () { + final statement = SignedStatement( + proof: StatementProofSr25519( + signature: _filled(64, 0x01), + signer: _filled(32, 0x02), + ), + topics: const [], + ); + + final bytes = encodeStatement(statement); + + // compact(1) ++ tag(0) ++ proof variant(0) ++ sig(64) ++ signer(32). + expect(bytes[0], 0x04); // compact-encoded field count 1 + expect(bytes[1], 0x00); // AuthenticityProof field tag + expect(bytes[2], 0x00); // Sr25519 proof variant + expect(bytes.length, 3 + 64 + 32); + }); + + test('emits topics as Topic1..Topic4 in ascending tag order', () { + final statement = SignedStatement( + proof: StatementProofEd25519( + signature: _filled(64, 0x00), + signer: _filled(32, 0x00), + ), + topics: [_filled(32, 0xa1), _filled(32, 0xa2)], + ); + + final bytes = encodeStatement(statement); + + // 3 fields: proof + 2 topics. + expect(bytes[0], 3 << 2); + // Find the topic field tags (4 and 5) after the proof field. + final proofLen = 1 + 1 + 64 + 32; // tag + variant + sig + signer + expect(bytes[1 + proofLen], 0x04); // Topic1 tag + expect(bytes[1 + proofLen + 1 + 32], 0x05); // Topic2 tag + }); + + test('rejects more than four topics', () { + final statement = SignedStatement( + proof: StatementProofSr25519( + signature: _filled(64, 0), + signer: _filled(32, 0), + ), + topics: [for (var i = 0; i < 5; i++) _filled(32, i)], + ); + expect(() => encodeStatement(statement), throwsArgumentError); + }); + }); + + group('round-trip', () { + final proofs = [ + StatementProofSr25519(signature: _filled(64, 1), signer: _filled(32, 2)), + StatementProofEd25519(signature: _filled(64, 3), signer: _filled(32, 4)), + StatementProofEcdsa(signature: _filled(65, 5), signer: _filled(33, 6)), + StatementProofOnChain( + who: _filled(32, 7), + blockHash: _filled(32, 8), + event: BigInt.from(9), + ), + ]; + + for (final proof in proofs) { + test('encode/decode preserves a ${proof.runtimeType} statement', () { + final statement = SignedStatement( + proof: proof, + decryptionKey: _filled(32, 0xd0), + expiry: BigInt.from(1893456000) << 32, + channel: _filled(32, 0xc0), + topics: [_filled(32, 0x11), _filled(32, 0x22), _filled(32, 0x33)], + data: Uint8List.fromList([1, 2, 3, 4, 5]), + ); + + // SignedStatement.== is identity-based for its byte fields, so assert + // round-trip via the canonical encoding: decode then re-encode. + final bytes = encodeStatement(statement); + expect(encodeStatement(decodeStatement(bytes)), bytes); + }); + } + + test('preserves a minimal statement (proof only, no optionals)', () { + final statement = SignedStatement( + proof: StatementProofEcdsa( + signature: _filled(65, 0x42), + signer: _filled(33, 0x43), + ), + topics: const [], + ); + final bytes = encodeStatement(statement); + expect(encodeStatement(decodeStatement(bytes)), bytes); + }); + + test('expiry maps to the high 32 bits (priority) and back', () { + const seconds = 1893456000; + final statement = SignedStatement( + proof: StatementProofSr25519( + signature: _filled(64, 0), + signer: _filled(32, 0), + ), + expiry: BigInt.from(seconds) << 32, + topics: const [], + ); + final decoded = decodeStatement(encodeStatement(statement)); + expect(decoded.expiry, BigInt.from(seconds) << 32); + }); + }); +} diff --git a/dart/truapi_smoldot/test/statement_store_test.dart b/dart/truapi_smoldot/test/statement_store_test.dart new file mode 100644 index 00000000..fdf7dd33 --- /dev/null +++ b/dart/truapi_smoldot/test/statement_store_test.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +import 'package:truapi/truapi.dart'; +import 'package:truapi_smoldot/truapi_smoldot.dart'; +import 'package:test/test.dart'; + +import 'support/fake_chain.dart'; + +Uint8List _filled(int length, int value) => + Uint8List.fromList(List.filled(length, value)); + +SignedStatement _statement({List topics = const []}) => + SignedStatement( + proof: StatementProofSr25519( + signature: _filled(64, 0x01), + signer: _filled(32, 0x02), + ), + topics: topics, + ); + +const _ctx = CallContext('req-1'); + +void main() { + group('submit', () { + test('encodes the statement and calls statement_submit', () async { + final chain = FakeChain()..responders['statement_submit'] = (_) => null; + final handlers = SmoldotStatementStoreHandlers( + () async => JsonRpcClient(chain.provider), + ); + + final statement = _statement(topics: [_filled(32, 0xaa)]); + final result = await handlers.submit(_ctx, statement); + + expect(result.isOk, isTrue); + final sentHex = chain.paramsFor('statement_submit').single as String; + // Compare via canonical bytes (SignedStatement.== is identity-based). + expect(hexToBytes(sentHex), encodeStatement(statement)); + }); + + test('a rejected submit maps to Err(GenericError)', () async { + final chain = FakeChain(); // no responder → JSON-RPC error + final handlers = SmoldotStatementStoreHandlers( + () async => JsonRpcClient(chain.provider), + ); + + final result = await handlers.submit(_ctx, _statement()); + expect(result.isErr, isTrue); + }); + }); + + group('subscribe', () { + test('decodes notifications and applies a MatchAll topic filter', () async { + final chain = FakeChain() + ..responders['statement_subscribeStatement'] = (_) => 'stmt-sub'; + final handlers = SmoldotStatementStoreHandlers( + () async => JsonRpcClient(chain.provider), + ); + + final topicA = _filled(32, 0xa1); + final topicB = _filled(32, 0xb2); + + final pages = []; + final sub = handlers + .subscribe( + _ctx, + RemoteStatementStoreSubscribeRequestMatchAll([topicA, topicB]), + ) + .listen(pages.add); + + await pumpEventQueue(); + // Matches: carries both topics. + final both = _statement(topics: [topicA, topicB]); + chain.notify('stmt-sub', bytesToHex(encodeStatement(both))); + // Does not match: only one of the two required topics. + chain.notify( + 'stmt-sub', + bytesToHex(encodeStatement(_statement(topics: [topicA]))), + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(pages, hasLength(1)); + expect(pages.single.isComplete, isTrue); + expect( + encodeStatement(pages.single.statements.single), + encodeStatement(both), + ); + // Cancelling unsubscribes. + expect( + chain.requests + .any((r) => r['method'] == 'statement_unsubscribeStatement'), + isTrue, + ); + }); + + test('MatchAny passes a statement sharing one topic', () async { + final chain = FakeChain() + ..responders['statement_subscribeStatement'] = (_) => 'stmt-sub'; + final handlers = SmoldotStatementStoreHandlers( + () async => JsonRpcClient(chain.provider), + ); + + final wanted = _filled(32, 0xa1); + final other = _filled(32, 0xff); + + final pages = []; + final sub = handlers + .subscribe( + _ctx, + RemoteStatementStoreSubscribeRequestMatchAny([wanted]), + ) + .listen(pages.add); + + await pumpEventQueue(); + chain.notify( + 'stmt-sub', + bytesToHex(encodeStatement(_statement(topics: [other, wanted]))), + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(pages, hasLength(1)); + }); + }); + + group('createProof', () { + test('createProof is unsupported (signing is the wallet)', () async { + final handlers = SmoldotStatementStoreHandlers( + () async => throw StateError('unused'), + ); + final result = await handlers.createProof( + _ctx, + RemoteStatementStoreCreateProofRequest( + productAccountId: const ProductAccountId( + dotNsIdentifier: 'truapi-playground.dot', + derivationIndex: 0, + ), + statement: const Statement(topics: []), + ), + ); + expect(result.isErr, isTrue); + expect( + (result as Err).error, + isA(), + ); + }); + + test('createProofAuthorized is unsupported', () async { + final handlers = SmoldotStatementStoreHandlers( + () async => throw StateError('unused'), + ); + final result = await handlers.createProofAuthorized( + _ctx, + const Statement(topics: []), + ); + expect(result.isErr, isTrue); + }); + }); +} diff --git a/dart/truapi_smoldot/test/support/fake_chain.dart b/dart/truapi_smoldot/test/support/fake_chain.dart new file mode 100644 index 00000000..9520a516 --- /dev/null +++ b/dart/truapi_smoldot/test/support/fake_chain.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:smoldot_provider/smoldot_provider.dart'; + +/// A controllable JSON-RPC chain for tests: answers requests from a +/// `method -> handler` table (recording every request it sees) and can push +/// subscription notifications. No FFI / native library involved. +class FakeChain { + void Function(String message)? _onMessage; + bool _closed = false; + + /// Every decoded request the client sent, in order. + final List> requests = []; + + /// Per-method responders. A responder receives the request params and returns + /// the JSON-RPC `result`; throwing yields a JSON-RPC error response. + final Map params)> responders = {}; + + JsonRpcProvider get provider => (onMessage) { + _onMessage = onMessage; + return _FakeConnection(this); + }; + + void _send(String message) { + final request = jsonDecode(message) as Map; + requests.add(request); + final method = request['method'] as String; + final params = (request['params'] as List).cast(); + final responder = responders[method]; + scheduleMicrotask(() { + if (_closed) return; + if (responder == null) { + _emit({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'error': {'code': -32601, 'message': 'no responder for $method'}, + }); + return; + } + try { + _emit({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': responder(params), + }); + } catch (error) { + _emit({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'error': {'code': -32000, 'message': '$error'}, + }); + } + }); + } + + /// Push a subscription notification for [subscription]. + void notify( + String subscription, + Object? result, { + String method = 'subscription_event', + }) { + _emit({ + 'jsonrpc': '2.0', + 'method': method, + 'params': {'subscription': subscription, 'result': result}, + }); + } + + void _emit(Object json) => _onMessage?.call(jsonEncode(json)); + + /// The recorded params of the first request to [method]. + List paramsFor(String method) => + (requests.firstWhere((r) => r['method'] == method)['params'] as List) + .cast(); +} + +class _FakeConnection implements JsonRpcConnection { + _FakeConnection(this._chain); + final FakeChain _chain; + + @override + void send(String message) => _chain._send(message); + + @override + void disconnect() => _chain._closed = true; +} diff --git a/docs/design/dart-flutter-support.md b/docs/design/dart-flutter-support.md new file mode 100644 index 00000000..3c3b5648 --- /dev/null +++ b/docs/design/dart-flutter-support.md @@ -0,0 +1,529 @@ +# Flutter / Dart support for TrUAPI + +**Status:** Proposal / research — not yet implemented +**Author:** (research pass) +**Scope:** Add a first-class Dart client (and, later, host) target so Flutter/Dart +products can speak the TrUAPI wire protocol, while the Rust crates remain the single +source of truth. + +--- + +## TL;DR + +- TrUAPI's Rust→TypeScript pipeline is **pure build-time code generation**. There is + **no WASM** anywhere in the repo (no `wasm-bindgen`, no `wasm-pack`, no `cdylib`, no + `.wasm` artifacts). The Rust crates are never executed at runtime by JS; `truapi-codegen` + reads **rustdoc JSON** and emits TypeScript source. (The "wasm host" mentioned in the + task brief does not exist — and we do not need it.) +- The codegen already separates a **language-agnostic IR** (`rust/crates/truapi-codegen/src/rustdoc.rs` + → `ApiDefinition`) from the **TypeScript emitter** (`ts.rs`). Adding Dart is therefore a + **sibling emitter** (`dart.rs`) plus a hand-written **Dart runtime package** — exactly the + shape of the existing `ts.rs` + `js/packages/truapi/src/{scale,transport,client}.ts` split. +- The wire contract is small and SCALE-based: `[requestId: SCALE str][u8 discriminant][payload]`, + with versioned `Vn` envelopes at `#[codec(index = n-1)]`. The primitive surface is narrow + (no floats, no maps, no big-int beyond u64/u128), which makes a faithful Dart SCALE codec + tractable. +- **Recommended approach:** keep Rust as truth → reuse the existing IR → add a `--dart-output` + emitter → ship a pure-Dart `truapi` pub package (hand-written SCALE codec + transport + + providers + generated code) → guarantee parity with golden cross-language wire vectors. + +--- + +## 1. How TrUAPI works today (research findings) + +### 1.1 The pipeline is codegen, not WASM + +``` +rust/crates/truapi/ Rust traits define the protocol; each method tagged #[wire(id = N)] + │ + │ cargo +nightly rustdoc -p truapi --output-format json + ▼ +target/doc/truapi.json rustdoc JSON (types, traits, signatures, doc comments) + │ + │ cargo run -p truapi-codegen (rustdoc.rs parses JSON → ApiDefinition IR) + ▼ +ApiDefinition (IR) language-agnostic: traits, methods, wire ids, type defs + │ + │ ts.rs emitter + ▼ +js/packages/truapi/src/generated/{types,client,wire-table,index}.ts (+ host, playground, explorer, examples) +``` + +Verified facts: + +- `scripts/codegen.sh` runs `cargo +nightly rustdoc ... --output-format json` then + `cargo run -p truapi-codegen` with `--output`, `--host-output`, `--playground-output`, + `--explorer-output`, `--client-examples-output`, `--codec-version`. +- `truapi-codegen/src/main.rs` parses the JSON (`rustdoc::parse`), extracts the API + (`rustdoc::extract_api`), then calls `ts::generate(...)` and the optional emitters. +- **No runtime Rust.** `grep -r "wasm-bindgen|wasm-pack|cdylib|wasm32"` over the repo + returns nothing. The only crate that produces a binary is `truapi-codegen` (a CLI used at + build time). The generated TS uses `scale-ts` (pure JS) for serialization. + +**Implication for Dart:** we do **not** need to compile Rust to WASM or FFI. We add an +emitter that walks the same IR and prints Dart, and we hand-write the Dart runtime +(codec + transport) once. Rust stays the source of truth because every type, method, wire +id, and doc comment is *derived from* the rustdoc JSON. + +### 1.2 The IR (`rustdoc.rs`) — the reuse point + +`ApiDefinition` is already emitter-neutral: + +- `TraitDef { name, module_path, methods, docs }` +- `MethodDef { name, kind, params, return_type, wire, docs }` + - `MethodKind` ∈ `{ Request, Subscription, ResultSubscription }` + - `WireAttrs { request_id, response_id, start_id, stop_id, interrupt_id, receive_id }` + - `ReturnType` ∈ `{ Result{ok,err}, Subscription(item), ResultSubscription{item,err} }` +- `TypeDef { name, module_path, generic_params, kind, docs }` + - `TypeDefKind` ∈ `{ Alias(TypeRef), Struct([FieldDef]), TupleStruct([TypeRef]), Enum([VariantDef]) }` + - `VariantFields` ∈ `{ Unit, Unnamed([TypeRef]), Named([FieldDef]) }` +- `TypeRef` ∈ `{ Primitive(str), Named{name,args}, Vec, Option, Tuple, Array(inner,len), Generic, Unit }` +- `public_trait_order` — source order of the `TrUApi` super-trait bounds; drives stable emission. + +`ts.rs` consumes exactly this. `dart.rs` will consume exactly this. **No changes to +`rustdoc.rs` are required** for a first Dart client (only additive helpers if we later find +gaps). + +### 1.3 The wire protocol & runtime contract (what the Dart runtime must reproduce) + +Frame (`transport.ts::encodeWireMessage`): + +``` +[ requestId : SCALE str ][ discriminant : u8 ][ payload : SCALE bytes ] +``` + +- **Request/response:** client sends `request_id` frame with `requestId = "p:"`; host replies + with the matching `response_id` frame and the same `requestId`. Payload is a versioned + `Result` envelope (`{ tag:"V1", value: Result }`). +- **Subscriptions:** client sends `start_id`; host streams `receive_id` frames and ends with + `interrupt_id` (typed reason → error; empty → complete). Client sends `stop_id` to cancel. +- **Versioned envelopes:** each `Vn` arm encodes as SCALE enum index `n-1`. The client picks the + **highest** wrapper variant ≤ the target protocol version (`method_wire_version` in `ts.rs`). +- **Handshake special case:** `System::handshake` auto-responds to inbound + `host_handshake_request` frames; the inner request carries the codec version. Legacy dotli + hosts ping every 50ms until they see a response (see `client.ts`). A Dart transport must + reproduce this auto-handshake to be a drop-in. + +The TypeScript runtime that has no generated counterpart (i.e. the parts we must hand-port): + +| TS file | Responsibility | Dart equivalent (hand-written) | +|---|---|---| +| `scale.ts` | SCALE primitives/combinators (wraps `scale-ts`) + `Hex`, `lazy`, `indexedTaggedUnion`, `OptionBool`, `Status`, `TaggedUnion` | `scale.dart` | +| `transport.ts` | `Provider` interface, frame encode/decode, iframe/MessagePort providers | `transport.dart` + `providers/*.dart` | +| `client.ts` | `createTransport`: request correlation, subscription lifecycle, auto-handshake | `transport_impl.dart` | +| `neverthrow` (dep) | `Result` / `ResultAsync` | `result.dart` (Dart 3 sealed) | + +### 1.4 Scope of the generated surface + +From the inventory pass: + +- **14 service traits** (super-trait `TrUApi`): Account, Chain, Chat, CoinPayment, Entropy, + LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, + StatementStore, System, Theme. +- **~64 wire methods** (~49 request/response, ~15 subscriptions). +- **~186 `v01` concrete types** + **~179 `versioned_type!` envelopes** (currently all V1). +- **Primitive surface is narrow and codec-friendly:** `u8/u16/u32/u64/u128`, `i*`, `bool`, + `String`, `Vec` (dominant), `Vec`, `Option`, `[u8; N]`, tuples, `Compact<_>`, + `OptionBool`. **No** `f32/f64`, **no** `HashMap/BTreeMap`, **no** explicit enum discriminants, + **no** U256. (`u64/u128` need `BigInt` in Dart — see §3.) +- **Framework types the codegen skips** (defined in `truapi/src/lib.rs`): `CallContext`, + `CallError` (`Domain/Denied/Unsupported/MalformedFrame/HostFailure`), `Subscription`, + `CancellationToken`, `RequestId`, `RuntimeFailure`. The Dart runtime supplies hand-written + equivalents (`CallError`, `Subscription`/`Stream`), same as the TS runtime does. + +--- + +## 2. Strategy + +**Mirror the TS architecture, one layer at a time, with the wire format as the contract.** + +1. **Reuse the IR.** No new parser. `dart.rs` is a peer of `ts.rs` under `truapi-codegen`. +2. **Hand-write the Dart runtime once** (codec, transport, providers, Result) — the analogue of + the hand-written `scale.ts`/`transport.ts`/`client.ts`. +3. **Generate** Dart types, codecs, wire table, and service clients into a git-ignored + `generated/` directory inside the Dart package (same convention as `js/packages/truapi/src/generated`). +4. **Prove parity** with golden wire vectors produced from the Rust/TS side and asserted byte-for-byte + in Dart (`test/`). This is what makes "Rust is the source of truth" enforceable, not aspirational. +5. **Wire it into the build** (`scripts/codegen.sh`, `Makefile`, CI) so a Rust trait change + regenerates Dart in the same run as TS, and CI fails if generated Dart drifts. + +Both client and **host** dispatcher are implemented (Phase 6). The host reuses the client's generated +`types.dart`, so it lives in the same `dart/truapi` package, exposed via `package:truapi/host.dart`. + +--- + +## 3. Type & codec mapping (Rust → TS → Dart) + +The Dart emitter mirrors `codec_expr_mode` / `ts_type_with_named` in `ts.rs`. + +| Rust / `TypeRef` | TS type | TS codec | **Dart type** | **Dart codec** | Notes | +|---|---|---|---|---|---| +| `bool` | `boolean` | `S.bool` | `bool` | `S.bool` | | +| `u8 u16 u32` | `number` | `S.u8/u16/u32` | `int` | `S.u8/u16/u32` | fits 64-bit `int` | +| `i8 i16 i32` | `number` | `S.i8/i16/i32` | `int` | `S.i8/i16/i32` | | +| `u64 u128 i64 i128` | `bigint` | `S.u64/...` | `BigInt` | `S.u64/...` | Dart `int` is 64-bit **signed**; u64/u128 must use `BigInt` | +| `Compact<_>` | `number \| bigint` | `S.compact` | `BigInt` | `S.compact` | normalize to `BigInt` for one path | +| `OptionBool` | `boolean \| undefined` | `S.OptionBool` | `bool?` | `S.optionBool` | 1 byte: 0/1/2 | +| `String` | `string` | `S.str` | `String` | `S.str` | | +| `Vec` | `HexString` | `S.Hex()` | `Uint8List` | `S.bytes()` | see decision D4 (bytes vs hex) | +| `[u8; N]` | `HexString` | `S.Hex(N)` | `Uint8List` | `S.bytesFixed(N)` | | +| `Vec` | `Array` | `S.Vector(c)` | `List` | `S.vector(c)` | | +| `Option` | `T \| undefined` | `S.Option(c)` | `T?` | `S.option(c)` | nullable, idiomatic | +| `(A, B, …)` tuple | `[A, B]` | `S.Tuple(...)` | `(A, B)` record | `S.tuple2(a,b)` | Dart 3 records | +| `()` unit | `undefined` | `S._void` | `void`/`null` | `S.unit` | | +| struct | `interface` | `S.Struct({...})` | immutable class | `S.struct(...)` | const ctor, `==`/`hashCode` | +| unit-only enum | string union | `S.Status(...)` | Dart `enum` | `S.status(values)` | exhaustive `switch` | +| mixed enum | tagged union | `S.TaggedUnion` | **sealed class** | `S.taggedUnion(...)` | Dart 3 sealed + patterns | +| `Vn` envelope | `{tag,value}` union | `S.indexedTaggedUnion` | sealed `Versioned` | `S.indexedTaggedUnion` | index `n-1` | +| `Result` | `ResultAsync` | `S.Result` | `Result` | `S.result(ok,err)` | sealed `Ok`/`Err` | +| `Subscription` | `ObservableLike` | — | `Stream` | — | idiomatic Dart streams | + +**Naming** (mirror `ts.rs`): trait `Foo` → `FooClient`; method `host_account_get` → `accountGet` +(`strip_prefix` + lowerCamelCase); struct/enum names stay PascalCase; client facade `TrUApiClient` +with camelCase service getters. Reserved-word fields (`is`, `in`, `default`, …) get a trailing +underscore or `@JsonKey`-style remap — handled in the emitter. + +--- + +## 4. Dart / Flutter best practices to apply + +- **Pure-Dart core, no Flutter SDK dependency.** The `truapi` package depends only on `dart:typed_data`/ + `dart:async`. It then works in Flutter (all platforms), Dart CLI, and server. A Flutter-specific + provider (if ever needed) lives behind a thin optional import, not in the core. +- **Dart 3 language features as the natural fit for the Rust shapes:** + - `sealed class` + exhaustive `switch`/pattern matching for enums, tagged unions, `Result`, `Versioned`. + - **records** `(A, B)` for Rust tuples. + - nullable types `T?` for `Option` (don't invent an `Option` box — be idiomatic). +- **Immutability:** generated data classes use `final` fields, `const` constructors where possible, + value `==`/`hashCode`, and `copyWith`. Generate this boilerplate ourselves — **do not** pull in + `freezed`/`build_runner` (we already own a generator; adding a second codegen toolchain is churn). +- **Bytes:** use `Uint8List` and `BytesBuilder` for buffers; never `List` on hot paths. Provide + `hex`/`fromHex` helpers for ergonomics, but keep wire ops on bytes. +- **`BigInt` discipline:** `u64/u128/i64/i128` are `BigInt` to avoid silent truncation; document it. +- **Subscriptions as `Stream`:** back each subscription with a `StreamController` whose `onCancel` + sends the `_stop` frame; map typed `interrupt` to a `SubscriptionInterrupted` error and + empty interrupt to normal stream completion. This is the idiomatic replacement for the TS + ES-Observable interop. +- **Errors as values:** requests return `Future>` (sealed `Result`), mirroring + `neverthrow`. Reserve thrown exceptions for transport/decode faults. +- **Tooling:** `dart format`, `package:lints`/`flutter_lints` with `analysis_options.yaml`, + `dart analyze --fatal-infos`, `dart test`. Generated files carry + `// Auto-generated by truapi-codegen. Do not edit.` + an `// ignore_for_file:` header for lints + that don't apply to generated code. +- **Docs:** forward Rust doc comments to Dart `///` doc comments (the IR already carries `docs`), + stripping the ` ```ts ` playground example blocks (the TS emitter does this in + `strip_playground_doc_blocks` — port the same filter). +- **Versioning:** `truapi` pub package version tracks the crate/`@parity/truapi` version + (extend `scripts/sync-cargo-version.mjs` / changeset flow to bump `pubspec.yaml`). + +--- + +## 5. Proposed repository layout + +Mirror `js/packages/` with a `dart/` tree (single client package for v1): + +``` +dart/ + truapi/ # pub package: `name: truapi` + pubspec.yaml + analysis_options.yaml + lib/ + truapi.dart # barrel (exports runtime + generated) + src/ + scale.dart # hand-written SCALE codec (port of scale.ts) + result.dart # sealed Result + transport.dart # Provider interface + frame encode/decode (port of transport.ts) + transport_impl.dart # createTransport: correlation, subs, auto-handshake (port of client.ts) + providers/ + loopback_provider.dart # in-memory pipe for tests + message_port_provider.dart # Flutter Web (dart:js_interop) — parity w/ TS + generated/ # GIT-IGNORED, emitted by truapi-codegen --dart-output + types.dart + codecs.dart # (or fold codecs into types.dart, mirroring ts) + client.dart + wire_table.dart + index.dart + test/ + scale_test.dart + wire_vectors_test.dart # golden cross-language parity + transport_test.dart + # (future) truapi_host/ # Dart host dispatcher — Phase 6 +``` + +Generated Dart is **git-ignored** like the TS `generated/`, and produced by `scripts/codegen.sh`. +(If consumers must `pub get` without a Rust toolchain, we can optionally commit a generated snapshot +later — decision D6.) + +`truapi-codegen` changes: + +``` +rust/crates/truapi-codegen/src/ + main.rs # add --dart-output (+ later --dart-host-output) flags + dart.rs # NEW: Dart emitter (peer of ts.rs) + dart/ # NEW: submodules if it grows (mirrors ts/{examples,playground,explorer}.rs) +``` + +--- + +## 6. Key design decisions (recommendations + rationale) + +| # | Decision | Recommendation | Why / alternatives | +|---|---|---|---| +| **D1** | **Transport target** — how does a Flutter product reach the host? | **RESOLVED: native bridge.** Native Flutter (iOS/Android/desktop) reaches the host over a **new host-side transport** (not the web `postMessage` path). The `Provider` interface is unchanged; a native `Provider` carries raw SCALE frame bytes over the chosen channel. Requires host-side coordination (a matching native endpoint). See §6.1 for channel options. | Everything above the `Provider` interface stays transport-agnostic; only the provider + the host's transport endpoint are new. | +| **D2** | **SCALE library** | **Hand-roll combinator codecs** in `scale.dart` (port of `scale.ts`, ~300 lines). | The primitive set is tiny and fixed; combinators (`struct/vector/option/enum/indexedTaggedUnion`) give exact wire control and zero heavy deps. Alt: `package:polkadart_scale_codec` is mature but registry/metadata-oriented — a different model than the combinator codecs we need to mirror. | +| **D3** | **`Result` type** | **Hand-rolled Dart 3 `sealed class Result`** with `Ok`/`Err`. | Idiomatic pattern matching, no dependency. Alt: `package:fpdart`/`result_dart` add API surface and a dep for a 30-line type. | +| **D4** | **`Vec` / `[u8; N]` representation** | **`Uint8List`** in public API + `hex`/`fromHex` helpers. | More idiomatic and efficient than the TS `HexString`. The wire bytes are identical; only the in-memory surface differs. (If strict TS API parity matters for some consumer, expose hex getters.) | +| **D5** | **Subscriptions** | **`Stream`** with `StreamController(onCancel: sendStop)`. | First-class Dart; replaces ES-Observable interop. Typed interrupt → `SubscriptionInterrupted`; empty interrupt → `onDone`. | +| **D6** | **Commit generated Dart?** | **Git-ignore** (match TS), regenerate in `codegen.sh`/CI. Optionally commit a snapshot later if we publish to pub.dev. | Consistency with the existing convention; avoids stale generated noise in diffs. | +| **D7** | **Immutable class generation** | **Emit plain classes** (final fields, const ctor, `==`/`hashCode`, `copyWith`) — no `freezed`. | We already own the generator; a second build_runner toolchain is unjustified churn. | +| **D8** | **Host (dispatcher) target** | **DONE (Phase 6).** Shipped in the same `dart/truapi` package via `package:truapi/host.dart`. | A Flutter host app needs the host side; it reuses the client's `types.dart`, so no separate package. Handlers take/return inner (selected-version) types. | + +### 6.1 Native bridge transport (D1 = native) + +The decision is **native bridge**: native Flutter products reach the host over a real +bidirectional byte channel, not the browser `postMessage` pipe. The `Provider` contract is +identical to the web case (`postMessage(Uint8List)`, `subscribe(cb)`, `subscribeClose(cb)`, +`dispose()`) — it simply transports raw SCALE wire frames over a native channel. **The host +must expose a matching endpoint**, so this requires coordination with the host/dotli team (a +new host capability beyond today's webview `postMessage` bridge). + +Candidate channels (pick one in Phase 0, with the host team): + +| Channel | When it fits | Dart side | Host side | Notes | +|---|---|---|---|---| +| **Flutter `BasicMessageChannel`** (BinaryCodec) | Host is the **native shell embedding the Flutter engine** | `BasicMessageChannel` provider (needs `package:flutter`) | platform-side channel handler | Cleanest if the host owns the Flutter embedding; the only option that pulls in the Flutter SDK, so keep it in a thin optional sub-package, not the pure-Dart core | +| **Local socket / WebSocket** (`dart:io` / `web_socket_channel`) | Host and product are **separate processes** on device/desktop | socket provider (pure Dart) | host listens on a local port/UDS | Process-isolated; works for desktop and sidecar models; needs a framing/auth handshake | +| **stdio / named pipe** | Host **spawns** the product (CLI/desktop) | `dart:io` stdin/stdout provider | host pipes | Simple for spawned children; length-prefix the frames | + +Whichever is chosen, the wire **frame format is unchanged** (`[requestId: SCALE str][u8 id][payload]`), +so the codec, codegen, transport correlation, and parity vectors are all identical to the web path — +only the `Provider` implementation and the host's transport endpoint differ. Recommendation: prototype +against a `LoopbackProvider` first (Phases 1–4 need no real channel), then implement the chosen native +provider once the host endpoint exists. + +--- + +## 7. Phased plan + +Each phase is independently shippable and verifiable. + +### Phase 0 — Spike & decisions (de-risk) +Confirm the rustdoc-JSON → Dart path end-to-end on a *single* method before building the full emitter, +and resolve the transport question (D1). +- Stand up an empty `dart/truapi` pub package (`pubspec.yaml`, `analysis_options.yaml`, CI lint). +- Hand-write `scale.dart` for the **primitive subset actually used** (bool, u8/16/32, u64/128 as BigInt, + str, bytes, vector, option, struct, enum/taggedUnion, indexedTaggedUnion, compact, optionBool). +- Hand-write a `LoopbackProvider` + minimal `transport.dart`. +- Manually transcribe **one** request method (e.g. `System::handshake`) and **one** subscription, encode a + payload, and **diff the bytes against the TS client / a Rust-emitted vector**. Lock the frame format. +- **Pick the native channel** (§6.1: `BasicMessageChannel` vs socket/WebSocket vs stdio) **with the host + team**, and confirm the host can expose the matching endpoint. (D1 is already resolved to *native*.) + +### Phase 1 — SCALE runtime (hand-written, fully tested) +Production `scale.dart` with parity tests. +- Implement every combinator the emitter will reference; match `scale-ts`/`parity_scale_codec` byte output. +- `compact` (4 modes incl. big-int), `OptionBool`, fixed/var byte arrays, `Result`, `Tuple`, + `indexedTaggedUnion` (index = n−1), `lazy` (recursive codecs). +- Golden tests: a corpus of `(type, value, hex)` vectors generated from Rust/TS, asserted in Dart. + +### Phase 2 — Dart emitter in `truapi-codegen` +`dart.rs` peer to `ts.rs`, driven by the existing IR. +- `--dart-output` flag in `main.rs`; `ts::generate`-shaped `dart::generate`. +- Emit `types.dart` (data classes, enums, sealed unions, `Versioned`, codecs), `wire_table.dart` + (request/subscription frame-id constants), `client.dart` (service `XxxClient` classes + `TrUApiClient` + facade + `createClient`), `index.dart` barrel. +- Port the version-selection logic (`method_wire_version`, `selected_public_aliases`, + `versioned_wrapper_emit_versions`) verbatim in behavior. +- Port doc-comment forwarding + ` ```ts ` block stripping. +- Reserved-word/identifier sanitization for Dart. +- Unit tests on emitter output (snapshot of generated Dart for a fixtured mini-API). + +### Phase 3 — Transport & providers +Port `transport.ts` + `client.ts` semantics to Dart. +- `Provider` interface; `transport.dart` frame encode/decode (`scanStrEnd`, etc.). +- `createTransport`: `request` correlation (`Future`), `subscribeRaw` → `Stream`, + **auto-handshake** to inbound `host_handshake_request`, idempotent `dispose`, close propagation. +- `LoopbackProvider` (tests) and the **native bridge provider** selected in §6.1 (`BasicMessageChannel` / + socket / stdio). The native channel can land after the loopback path is green, in lockstep with the + host-side endpoint. +- Transport unit tests (request/response, subscription receive/interrupt/stop, close races). + +### Phase 4 — End-to-end parity & conformance +Make "Rust is the source of truth" enforceable. +- **Golden wire-vector corpus**: extend the Rust/TS test tooling to emit canonical SCALE bytes for + representative request/response/subscription payloads across all 14 services; assert byte-identical + encode **and** decode in Dart (`wire_vectors_test.dart`). +- Cross-client smoke: drive the Dart client against the existing `@parity/truapi-host` dispatcher (or a + recorded transcript) over a loopback, exercising one method per service. +- Mirror the TS "wire-equality" + "wire-table-loop" smoke tests in Dart. + +### Phase 5 — Build integration, CI, packaging, docs +- Add Dart generation to `scripts/codegen.sh` and a `make dart` / extend `make codegen`. +- CI job: regenerate Dart + `dart analyze --fatal-infos` + `dart test`; **fail if generated Dart drifts** + from committed source intent (regen-and-diff check, same spirit as TS). +- `pubspec.yaml` version sync with the crate (extend `sync-cargo-version.mjs` / changeset flow). +- README for `dart/truapi` (install, `createClient`, request + subscription examples), and update + top-level `README.md` + `CLAUDE.md` layout sections (required by repo convention). +- (If publishing) pub.dev metadata, example app, `CHANGELOG.md`. + +### Phase 6 — (Optional) Dart host dispatcher +Only if a Dart/Flutter **host** is needed. +- Port the `truapi-host` generated surface (`generate_host` / `generate_host_server` in `ts.rs`): + typed handler interfaces per service, dispatch table keyed by wire id, decode→handle→encode, + subscription frame ports. +- `--dart-host-output` flag + `dart/truapi_host` package. + +--- + +## 7.1 Implementation status + +**HOST-ONLY (the Dart client was removed).** Products are web apps that use the +TS/JS `@parity/truapi` client over the normal web route, so the Dart package ships +**only the host**. The generated client facade and the client runtime +(`createTransport`/`subscribeStream`/`Subscription`/`HandshakeResponder`) and their +tests are gone; `truapi-codegen` no longer emits `client.dart` (only `types.dart`, +`wire_table.dart`, `index.dart`, `host.dart`). The package entry point is +`package:truapi/truapi.dart` (host). + +**Host complete and verified.** Verified locally: `cargo build`/`test --workspace` +green, `cargo +nightly fmt --check` + `clippy -- -D warnings` clean; Dart +`dart analyze` clean, `dart format` clean, `dart test` = **28 tests green** (scale + +cross-language wire vectors + host dispatch driven by raw wire frames). + +**Host:** `package:truapi/truapi.dart` — implement the per-service +`TruapiHostHandlers` and wire them with `createTruapiServer(provider, handlers)`. +The generated dispatch entries + `lib/src/host/host_server.dart` runtime handle +versioned wrapping, SCALE codec, and the frame lifecycle. Generated by +`truapi-codegen --dart-host-output` (shared types via `--dart-output`). + +What exists now: + +- **Runtime** (`dart/truapi/lib/src/`): `scale.dart` (SCALE codec incl. `Unit`, `vectorFixed`, `versioned`), `result.dart` (sealed `Result`/`Ok`/`Err`), `transport.dart` (`Provider`, frame codec, `createTransport`, `subscribeStream`, `SubscriptionInterrupted`, injectable `HandshakeResponder`), `providers/loopback_provider.dart`, barrel `lib/truapi.dart`. +- **Emitter** (`rust/crates/truapi-codegen/src/dart.rs`, wired via `--dart-output` in `main.rs`): generates `types.dart`, `wire_table.dart`, `client.dart`, `index.dart` (~7.9k lines: 14 service clients, ~64 methods, ~360 types). Handles structs, Dart enums (unit-only), sealed classes (mixed enums), typedef aliases, generic `Component

`, versioned-wrapper selection, and the auto-handshake responder. +- **Parity** (`dart/truapi/test/`): `wire_vectors_test.dart` asserts the generated Dart codecs are **byte-identical to `parity_scale_codec`** using golden vectors from `rust/crates/truapi/examples/wire_vectors.rs`; `generated_client_test.dart` round-trips Ok + typed Err through the generated `TruapiClient` over `LoopbackChannel`. Plus `scale_test.dart` (16) and `transport_test.dart` (5). +- **Build/CI** (Phase 5): `--dart-output` in `scripts/codegen.sh` (+ best-effort `dart format`), a `make dart` target, a `dart` CI job in `.github/workflows/ci.yml` (regen → `dart format --set-exit-if-changed` → `dart analyze --fatal-infos` → `dart test`), `pubspec.yaml` kept in lockstep by `scripts/sync-cargo-version.mjs`, `dart/truapi/README.md`, and updated top-level `README.md` + `CLAUDE.md`. + +**Remaining (not blocking use):** pick the §6.1 native channel and stand up the host-side endpoint (the one external dependency — everything above `Provider` is done and proven on `LoopbackChannel`); broaden the golden-vector corpus toward all 14 services; optional Phase 6 Dart host dispatcher. + +## 8. Implementation checklist + +### Phase 0 — Spike & decisions +- [x] Create `dart/truapi` package skeleton (`pubspec.yaml`, `analysis_options.yaml`, `lib/`, `test/`) +- [x] CI/lint runs `dart format --set-exit-if-changed` + `dart analyze` (the `dart` CI job) +- [x] Minimal `scale.dart` (primitive subset + struct/vector/option/enum) +- [x] `LoopbackProvider` + `transport.dart` +- [x] Handshake + subscription proven via byte diff vs Rust (`wire_vectors`) + e2e (`generated_client_test`) +- [x] **D1 resolved: native bridge** (Flutter products reach the host over a native channel) +- [ ] **Native channel chosen** (§6.1) + host confirms it can expose the matching endpoint +- [x] D2–D7 confirmed (or amended) in this doc + +### Phase 1 — SCALE runtime +- [x] `bool, u8, u16, u32, i8, i16, i32` +- [x] `u64, u128, i64, i128` as `BigInt` +- [x] `compact` (all length modes, incl. big-int mode) +- [x] `str`, `bytes()` (Vec), `bytesFixed(N)` ([u8; N]) +- [x] `vector(c)`, `option(c)`, `tupleN(...)` / record codecs, `unit` +- [x] `result(ok, err)`, `optionBool`, `lazy`, `versioned` (index = n−1) +- [~] struct / unit-enum / mixed-enum codecs — emitted inline per-type by `dart.rs` (Phase 2) rather than as generic combinators +- [x] `scale_test.dart` green (16 tests, incl. canonical SCALE vectors: compact, str, options) +- [ ] Golden cross-language `(type, value, hex)` corpus (Phase 4) + +### Phase 2 — Dart emitter (`dart.rs`) +- [x] `--dart-output` flag in `main.rs`; `dart::generate` entry +- [x] `types.dart`: immutable data classes (final fields, const ctor, value `==`/`hashCode`, `toString`). copyWith intentionally omitted — nullable-field unset ambiguity; DTOs are immutable and constructed directly. +- [x] `types.dart`: Dart `enum` for unit-only, `sealed class` (+ exhaustive `switch` codec) for mixed enums +- [x] Versioned wrappers handled via `S.versioned(n-1, innerCodec)` inline (public type = stripped inner), not a separate `Versioned` type — matches the TS `.value`-stripping surface +- [x] `wire_table.dart`: `RequestFrameIds`/`SubscriptionFrameIds` consts, id inference ported from `ts.rs` +- [x] `client.dart`: `XxxClient` classes, `TruapiClient` facade, `createClient` (+ auto-handshake responder) +- [x] Port version selection (`method_wire_version`, max-≤-target inner selection) +- [x] Doc-comment forwarding + ` ```ts ` block stripping +- [x] Dart reserved-word / identifier sanitization + generic `Component

` codec functions +- [x] Validated by generated output compiling clean (`dart analyze`) + Phase 4 parity/e2e tests (in lieu of Rust snapshot unit tests) + +### Phase 3 — Transport & providers +- [x] `Provider` interface + `transport.dart` frame encode/decode +- [x] `createTransport`: request correlation → `Future>` +- [x] Subscription lifecycle → `Stream` (`subscribeStream` helper: receive/interrupt/stop, `onCancel` → stop frame, typed `SubscriptionInterrupted`) +- [x] Auto-handshake mechanism — injectable `HandshakeResponder` (generated client wires it in Phase 2) +- [x] Idempotent `dispose` + provider-close propagation +- [x] `LoopbackProvider` (done) + native bridge provider (§6.1) — host endpoint coordinated (pending) +- [x] `transport_test.dart` — 5 tests green (request Ok/Err, subscription receive+interrupt, stop frame, auto-handshake) + +### Phase 4 — Parity & conformance +- [x] Golden wire-vector corpus emitted from Rust (`cargo run -p truapi --example wire_vectors` → `dart/truapi/test/wire_vectors.json`, 11 representative vectors covering struct/Vec/Option/sealed+unit enum/compact/OptionBool/`[u8;32]`/versioned envelope) +- [x] `wire_vectors_test.dart`: Dart generated codecs **byte-identical to `parity_scale_codec`** (encode); decode covered by `scale_test.dart` round-trips +- [x] Generated `TruapiClient` ↔ fake host over `LoopbackChannel`, Ok + typed Err round-trips (`generated_client_test.dart`) +- [~] Broaden golden corpus toward all 14 services (current set is representative; add more vectors as needed) + +### Phase 5 — Build, CI, packaging, docs +- [x] Dart generation added to `scripts/codegen.sh` (+ best-effort `dart format`) + `make dart` target +- [x] CI: `dart` job regenerates + `dart format --set-exit-if-changed` + `dart analyze --fatal-infos` + `dart test` (drift-safe: generated from source each run) +- [x] `pubspec.yaml` version sync via `scripts/sync-cargo-version.mjs` +- [x] `dart/truapi/README.md` with client + subscription examples +- [x] Update top-level `README.md` and `CLAUDE.md` layout (repo convention) + +### Phase 6 — Dart host dispatcher +- [x] `--dart-host-output` flag (host lives in the same `dart/truapi` package, exported via `package:truapi/host.dart` — no separate package needed since it reuses the client's `types.dart`) +- [x] Hand-written dispatcher runtime `lib/src/host/host_server.dart` (`createHostServer`, `CallContext`, `SubscriptionFramePort`, request/subscription entries, pending/active subscription state machine — port of `truapi-host/src/index.ts`) +- [x] Generated `host.dart`: per-service typed handler interfaces, public `buildEntries`, `TruapiHostHandlers` + `createTruapiServer` +- [x] Host dispatcher tests: generated client ↔ generated host over `LoopbackChannel` (request round-trip + subscription) — `host_server_test.dart` +- [x] One-shot host scaffold: `--dart-host-scaffold-output` (+ `make dart-scaffold`) emits `example/host_scaffold.dart` — a `ScaffoldHostHandlers` implementing every service method with `throw UnimplementedError(...)`, with heuristic backing notes (smoldart light-client vs host-local vs wallet) + +--- + +## 9. Parity / conformance strategy (how Rust stays the source of truth) + +1. **Single IR, two emitters.** Dart and TS are both derived from the same rustdoc JSON. A Rust trait + change regenerates both in one `codegen.sh` run; neither can silently diverge in *shape*. +2. **Golden wire vectors** lock the *bytes*. The corpus is produced from the Rust/TS side and checked + into the test suite; Dart must encode and decode each vector identically. A codec bug surfaces as a + failing byte diff, not a runtime mystery. +3. **Drift check in CI.** Regenerate Dart and fail if it differs from intent (same guard the TS client + uses). Stale generated code cannot merge. +4. **Append-only wire ids** (existing invariant) mean older Dart consumers stay compatible across + protocol revisions, just like TS. + +--- + +## 10. Open decisions needing input + +- **D1 — RESOLVED: native bridge.** Native Flutter (iOS/Android/desktop) reaches the host over a new + host-side transport. **Remaining sub-decision:** which native channel (§6.1 — `BasicMessageChannel`, + local socket/WebSocket, or stdio) and confirming the **host team** can stand up the matching endpoint. + This is the main external dependency: Phases 1–4 proceed on a `LoopbackProvider`, but a real + end-to-end demo needs the host's native transport to exist. +- **Publishing:** internal package (path/git dependency) vs pub.dev publication? Affects D6 (commit + generated code) and packaging tasks in Phase 5. +- **Client vs host scope:** confirm host (Phase 6) is out of scope for the first delivery. +- **Minimum Dart/Flutter SDK** target (Dart 3 sealed classes + records assumed; confirm ≥ Dart 3.0). + +--- + +## 11. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Native bridge (D1) depends on the host exposing a new transport endpoint | Decouple: Phases 1–4 run on `LoopbackProvider`; coordinate the §6.1 channel + host endpoint early so the native provider lands without blocking codec/codegen work | +| SCALE byte drift (e.g. `compact`, `u128`, enum index) | Golden cross-language vectors in Phase 1/4; byte-diff in CI | +| rustdoc JSON format changes break the parser | Already a shared risk with TS; the IR is the same code path, so TS CI catches it | +| `int` truncation for `u64/u128` | Mandate `BigInt` for 64/128-bit; lint/test for it | +| Generated-code lint noise | `// ignore_for_file:` header + exclude `generated/` from `dart format` diff gate | +| Two version sources (crate vs pubspec) drift | Extend `sync-cargo-version.mjs`/changeset flow to bump `pubspec.yaml` | + +--- + +## 12. Appendix — key source references + +- IR / rustdoc parsing: `rust/crates/truapi-codegen/src/rustdoc.rs` +- TS emitter (template for `dart.rs`): `rust/crates/truapi-codegen/src/ts.rs` + (`generate`, `generate_client`, `generate_types`, `generate_wire_table`, + `codec_expr_mode`, `ts_type_with_named`, `method_wire_version`) +- CLI / flags: `rust/crates/truapi-codegen/src/main.rs` +- `#[wire]` / `versioned_type!` macros: `rust/crates/truapi-macros/src/lib.rs` +- Hand-written TS runtime to port: `js/packages/truapi/src/{scale,transport,client,index}.ts` +- Host dispatcher (Phase 6 reference): `js/packages/truapi-host/src/index.ts`, + `ts.rs::generate_host` / `generate_host_server` +- Framework types skipped by codegen: `rust/crates/truapi/src/lib.rs` +- Service traits + super-trait: `rust/crates/truapi/src/api/*.rs` (`mod.rs` = `TrUApi`) +- Build pipeline: `scripts/codegen.sh`, top-level `Makefile` +``` diff --git a/docs/design/smoldot-provider.md b/docs/design/smoldot-provider.md new file mode 100644 index 00000000..6998aa22 --- /dev/null +++ b/docs/design/smoldot-provider.md @@ -0,0 +1,622 @@ +# `smoldot_provider` — backing TrUAPI's chain services with a smoldot light client + +**Status:** Proposal / research — not yet implemented +**Scope:** A new Dart package that lets a Flutter/Dart TrUAPI **host** serve the +chain-facing services from an in-process [smoldot](https://github.com/smol-dot/smoldot) +light client, with **zero changes to the `truapi` package interfaces**. +**Depends on:** `package:truapi` (this repo, `dart/truapi`) and **`package:smoldot` +v1.2.0 from the `snowpinelabs/polkadart` fork** (`/home/ubuntu/claude/polkadart-snowpinelabs`, +branch `chore/upgrade-smoldot-1.2.0`) — this wraps **smoldot-light 1.2.0** (modern +JSON-RPC v2 API) and adds native **statement-store** support, unlike the +`justkawal/polkadart` `packages/smoldot` (v0.1.0, smoldot-light 0.18). Use the +snowpinelabs fork. + +--- + +## TL;DR + +- Target **`package:smoldot` v1.2.0 (snowpinelabs fork)**, which wraps + smoldot-light 1.2.0 — the full modern Polkadot JSON-RPC v2 API. +- The TrUAPI **Chain** service is, almost line-for-line, a wrapper over the modern + JSON-RPC v2 families — `chainHead_v1_*`, `chainSpec_v1_*`, `transaction_v1_*`. + **smoldot 1.2.0 speaks exactly these.** So the Chain service maps onto smoldot + cleanly; that's the bulk of the chain surface (and `chainHead_v1_storage` now + reads child tries natively, covering `get_head_storage`'s `childTrie`). +- **StatementStore `submit` + `subscribe` are now in scope** — smoldot-light 1.0+ + ships Substrate's statement-store protocol (`statement_submit` / + `statement_subscribeStatement`), enabled per chain via + `AddChainConfig.statementStore`. Only StatementStore's `create_proof*` (signing) + stays out (wallet, not a light client). +- **smoldot is not a TrUAPI `Provider` by itself.** A TrUAPI `Provider` is the + byte-frame transport between a product and its host; smoldot is a *chain + JSON-RPC* backend. `smoldot_provider` bridges the two: it implements the + generated **`ChainHostHandlers`** (and the smoldot-backable parts of + **`StatementStoreHostHandlers`**) against smoldot, and (optionally) wraps an + embedded host server as a turnkey TrUAPI `Provider` — so from the client's side + it behaves "just like the js/ts providers," with **no `truapi` interface change**. +- **Signing and the rest of Preimage stay out** — signing is the wallet's; Preimage + is a content store that smoldot 1.2.0 doesn't serve cleanly yet (see §2). +- Two real dependencies shape the work: (1) **native smoldot library** must be + built and bundled per platform (FFI), and (2) even at v1.2.0 the **`package:smoldot` + JSON-RPC handler is unchanged** — it doesn't surface subscription ids or do + chainHead/statement-style unsubscribe — so `smoldot_provider` must work around or + upstream-fix that. + +--- + +## 0. Status & layering (current) + +The work splits into **two packages / two layers**: + +| Layer | Package | Repo | Status | +|---|---|---|---| +| **A — chain connection** | `smoldot_provider` | **polkadart** (`packages/smoldot_provider`) | **DONE** (branch `feat/smoldot-provider`) | +| **B — TrUAPI mapping** | `truapi_smoldot` | **truapi** (`dart/truapi_smoldot`) | **Chain DONE** (validated vs live Westend); **StatementStore** implemented, pending live validation; turnkey provider descoped | + +**Layer B progress (`dart/truapi_smoldot`):** +- [x] Package skeleton + cross-repo dep wiring (path deps on `truapi`, `smoldot_provider`; `smoldot` override). `dart pub get` resolves. +- [x] `JsonRpcClient` over a `smoldot_provider` `JsonRpcProvider` (request/response by id, subscriptions by `params.subscription`, per-method unsubscribe, orphan-notification buffering). 5 unit tests green (fake provider, no FFI). +- [x] `SmoldotChainBackend` (client + chain-per-genesis + `JsonRpcClient`) + hex codec. Lazily `addChain`s per genesis hash, caches a `JsonRpcClient` future per chain, optional relay-chain + statement-store. +- [x] `SmoldotChainHandlers`: `chainSpec_v1_*` methods (genesis hash, chain name, properties) + error→`Err(GenericError)` guard. 4 unit tests green (scripted provider, no FFI). +- [x] `SmoldotChainHandlers`: the `chainHead_v1_*` follow/operation engine + `getHeadHeader`/`getHeadBody`/`getHeadStorage`/`callHead`/unpin/continue/stop. A follow-session registry keyed by `followSubscriptionId` (= the follow call's `CallContext.requestId`, which the product echoes on every operation) maps operations onto the smoldot follow subscription; `followHeadSubscribe` streams every `chainHead_v1_follow` event (lifecycle + operation results) decoded into typed `RemoteChainHeadFollowItem`s. Uses an explicit `StreamController` (not `async*`/`await for`, which deadlocks `cancel()`) to forward cancellation → `chainHead_v1_unfollow`. 7 unit tests green (fake chain, no FFI). +- [x] `SmoldotChainHandlers`: `transaction_v1_*` (`broadcast`/`stop`). 2 unit tests green. +- [x] `SmoldotStatementStoreHandlers`: `submit` (↔ `statement_submit`) + `subscribe` (↔ `statement_subscribeStatement`, host-side topic filter for `MatchAll`/`MatchAny`); `createProof`/`createProofAuthorized` return `Err(Unknown)` (signing is the wallet's, not a light client's). Includes a self-contained Substrate `sp_statement_store::Statement` SCALE codec (`statement_codec.dart`) transcoding the typed `SignedStatement` ⇄ statement bytes. 13 unit tests green (codec round-trip per proof variant + handler behaviour over a fake chain). **Open validation point:** the `expiry: u64` ⇄ Substrate `Priority(u32)` mapping (`priority = expiry >> 32`) and the `statement_subscribeStatement` notification shape / dump-vs-live framing are best-effort from spec and must be confirmed against a live statement-store-enabled chain (no public network runs it yet, so this stays a documented follow-up). +- [ ] Turnkey provider — **descoped for now.** Products are web apps on the TS route, so the Dart side only needs the host handlers, which a Flutter host wires into its own `TruapiHostHandlers` + transport (native-bridge decision D1) via the generated `buildChainEntries`/`buildStatementStoreEntries`. Revisit only if an in-process Dart product↔host transport is needed. +- [x] Live integration test (FFI + network): `SmoldotChainHandlers` end-to-end over a real smoldot light client against **Westend** (`test/chain_integration_test.dart`). Validates `getSpecChainName` → "Westend", `getSpecGenesisHash` → the known genesis, and `followHeadSubscribe` → `getHeadHeader` (the operation correlates to the live follow by id and returns a real finalized header). Opt-in via `@Tags(['network'])` + `dart_test.yaml` skip; run with `dart test --run-skipped -t network` and `LD_LIBRARY_PATH` pointing at `packages/smoldot/native/linux`. The default `dart test` run stays offline (33 unit tests, 0 network). +- [ ] Live integration test for **StatementStore** submit/subscribe — pending a statement-store-enabled chain reachable by a light client (none public yet). This is the validation gap noted above for the `expiry`⇄`priority` mapping and the subscribe notification framing. + +- **Layer A — `smoldot_provider` (DONE).** The Dart equivalent of polkadot-api's + `@polkadot-api/sm-provider`: `getSmProvider(Chain | Future)` turns a smoldot + chain into a standard string-based `JsonRpcProvider` + (`(onMessage) => { send, disconnect }`). Plus `getRawProvider` over a minimal + `RawJsonRpcChain`. This is "the provider," with the same interface as the JS/TS path, + so consumers run their own JSON-RPC client over it. Light-client only, by design. + Prereq also done: the snowpinelabs `package:smoldot` `Chain` now exposes the raw JS + interface (`sendJsonRpc`/`nextJsonRpcResponse`/`jsonRpcResponses`; branch + `feat/smoldot-raw-jsonrpc-chain-api`). Both verified against live Westend. +- **Layer B — `truapi_smoldot` (remaining).** Consumes a `JsonRpcProvider` from + `smoldot_provider`, runs a JSON-RPC client over it, and maps TrUAPI **Chain** (↔ + `chainHead_v1_*`/`chainSpec_v1_*`/`transaction_v1_*`) and **StatementStore** + `submit`/`subscribe` (↔ `statement_*`) onto the generated `ChainHostHandlers` / + `StatementStoreHostHandlers`, plus a turnkey TrUAPI `Provider`. Lives in the truapi + repo (it depends on both `package:truapi` and `package:smoldot_provider`). This is the + bulk of §§2–4 below, now built on the Layer-A provider rather than raw FFI. + +> Naming note: §§5–8 below were written before the split and use "`smoldot_provider`" +> for the whole thing. Read those as **Layer B (`truapi_smoldot`)**, which now `import`s +> the Layer-A `smoldot_provider` `JsonRpcProvider` instead of re-deriving a raw client. + +--- + +## 1. Architecture & terminology (read this first) + +There are **two different things called "provider"** in play. Keeping them +separate is the whole key to this design. + +| | **TrUAPI `Provider`** | **Chain JSON-RPC provider** | +|---|---|---| +| Defined in | `package:truapi` (`lib/src/transport.dart`) | `package:smoldot` (`Chain.request/subscribe`); also polkadart's `Provider` | +| Carries | TrUAPI wire frames `[requestId][u8 id][payload]` | Polkadot JSON-RPC requests/notifications | +| Connects | product (client) ↔ host (dispatcher) | host (or any app) ↔ a chain | +| Examples | `LoopbackChannel`, MessagePort/iframe (TS), a native bridge | `WsProvider`, smoldot `Chain` | + +**Where smoldot fits.** A TrUAPI host implements every service. For the **Chain** +service, the host needs a chain connection — in the desktop (novasama) host that's +its node/light client; in Dart that's **smoldot**. So smoldot backs the host's +**Chain handlers**. It does *not* replace the product↔host transport. + +``` + product (TrUAPI client) + │ createClient(provider) + │ TrUAPI wire frames + ▼ + TrUAPI host dispatcher (createTruapiServer / createHostServer) + │ ChainHostHandlers ← implemented by smoldot_provider + ▼ + SmoldotChainHandlers ──JSON-RPC (chainHead_v1_*, chainSpec_v1_*, transaction_v1_*)──▶ smoldot light client ──▶ chain +``` + +**Two ways to consume `smoldot_provider`** (it ships both; same core underneath): + +1. **Host handlers (primary, for a Flutter host).** You build a TrUAPI host and + plug `SmoldotChainHandlers` into your `TruapiHostHandlers.chain`. Your product + (TS, or Dart) talks to your host over its real transport; the host answers Chain + calls from smoldot. This is the literal answer to "back the host's chain logic + with smoldot." +2. **Turnkey TrUAPI `Provider` (convenience, all-in-one-process).** For a Dart app + that wants the typed TrUAPI client to read chain data directly with no separate + host process, `createSmoldotProvider(...)` returns a TrUAPI `Provider` that + embeds a host server (Chain via smoldot + your handlers for other services). + Then `createClient(provider)` "just works" — this is the "smoldot as the + provider, like js/ts providers" experience, with **no `truapi` interface change**. + +Both are implemented purely against the **existing** `ChainHostHandlers` / +`HostDispatchEntry` / `Provider` interfaces. Nothing in `package:truapi` changes. + +--- + +## 2. Scope — what smoldot can and cannot back + +From the per-method mapping (§3), the chain-facing TrUAPI services split as follows +**for smoldot-light 1.2.0**: + +| Service / method | smoldot-backable? | Why | +|---|---|---| +| **Chain** (all 13) | ✅ **Yes — core deliverable** | 1:1 with `chainHead_v1_*` / `chainSpec_v1_*` / `transaction_v1_*`; read-only chain access + tx broadcast | +| **StatementStore::subscribe** | ✅ **Yes** | `statement_subscribeStatement` (topic filter), with `AddChainConfig.statementStore` enabled | +| **StatementStore::submit** | ✅ **Yes** | `statement_submit` (a signed statement) | +| **StatementStore::create_proof / create_proof_authorized** | ❌ No | **Signing** — the host's wallet, not a light client | +| **Preimage** (`lookup_subscribe`, `submit`) | ⚠️ Not yet | Content store: `submit` writes (needs signing/extrinsic); `lookup` could be `pallet_preimage` storage reads via `chainHead_v1_storage` (awkward, polling) or `bitswap_v1_get` (smoldot ≥ 3.1, not in 1.2.0). Treat as out for now; revisit | + +**In scope for `smoldot_provider`:** the **Chain** service (complete) and the +**StatementStore** `submit` + `subscribe`. **Out of scope:** StatementStore +`create_proof*` (signing), Preimage, and every other service (Account, Signing, +Theme, …) — the host supplies those itself (e.g. `create_proof*` via its wallet). + +> Note: `Chain::broadcast_transaction` *submits* a signed extrinsic +> (`transaction_v1_broadcast`). The transaction must already be signed (by the +> host's wallet/Signing service) — smoldot only broadcasts bytes. Everything else +> in Chain is read-only and ideal for a light client. + +--- + +## 3. Chain → JSON-RPC mapping + +Every Chain method carries a `genesisHash` (identifies the target chain) and uses +SCALE `Vec` / hex on the wire. The mapping to smoldot's JSON-RPC: + +| Chain method | wire | JSON-RPC | Notes | +|---|---|---|---| +| `follow_head_subscribe` | sub 76 | `chainHead_v1_follow` | subscription; notifications → `RemoteChainHeadFollowItem` variants (`Initialized`/`NewBlock`/`BestBlockChanged`/`Finalized`/`Operation*`/`Stop`) | +| `get_head_header` | req 80 | `chainHead_v1_header` | returns the SCALE header inline (`{ header: Option }`) | +| `get_head_body` | req 82 | `chainHead_v1_body` | returns `{ operation: Started{operationId} \| LimitReached }`; body arrives as `OperationBodyDone` on the follow stream | +| `get_head_storage` | req 84 | `chainHead_v1_storage` | operation; results stream as `OperationStorageItems`/`OperationStorageDone` | +| `call_head` | req 86 | `chainHead_v1_call` | operation; result as `OperationCallDone`; needs `withRuntime:true` on follow | +| `unpin_head` | req 88 | `chainHead_v1_unpin` | GC of pinned blocks | +| `continue_head` | req 90 | `chainHead_v1_continue` | resume a `WaitingForContinue` operation | +| `stop_head_operation` | req 92 | `chainHead_v1_stopOperation` | cancel an operation | +| `get_spec_genesis_hash` | req 94 | `chainSpec_v1_genesisHash` | | +| `get_spec_chain_name` | req 96 | `chainSpec_v1_chainName` | | +| `get_spec_properties` | req 98 | `chainSpec_v1_properties` | JSON properties string | +| `broadcast_transaction` | req 100 | `transaction_v1_broadcast` | returns an `operationId` (Option); **submits** signed tx bytes | +| `stop_transaction` | req 102 | `transaction_v1_stop` | cancels a broadcast | + +**The follow/operation correlation is the heart of the adapter.** In `chainHead_v1`, +`body`/`storage`/`call` don't return their data directly — they return an +`operationId`, and the data arrives later as a `chainHead_v1_followEvent` +notification (`operationBodyDone`, etc.) on the *follow* subscription. TrUAPI models +this exactly (operation-started response + `Operation*Done` follow items). So the +adapter must, per active follow subscription, route operation events back through +the follow item stream — it does **not** invent a request/response correlation; +it mirrors the JSON-RPC one. + +The TrUAPI `followSubscriptionId` carried by `get_head_*` requests equals the host +dispatcher's `requestId` for the follow `start` frame (= `CallContext.requestId` of +`follow_head_subscribe`). So `SmoldotChainHandlers` keys its per-follow state by +that id and looks it up on each operation call. (See §5.3.) + +### 3.1 StatementStore → JSON-RPC (smoldot-light ≥ 1.0) + +Enabled per chain by passing `AddChainConfig.statementStore` (a `StatementStoreConfig`). + +| StatementStore method | wire | JSON-RPC | Notes | +|---|---|---|---| +| `subscribe` | sub 56 | `statement_subscribeStatement` | `MatchAll`/`MatchAny(Vec)` → topic filter params; notifications → `RemoteStatementStoreSubscribeItem { statements, is_complete }` | +| `submit` | req 62 | `statement_submit` | a `SignedStatement` (SCALE-encoded statement bytes) → `()` | +| `create_proof` | req 60 | — (host wallet) | **signing**; out of scope | +| `create_proof_authorized` | req 132 | — (host wallet) | **signing**; out of scope | + +The statement wire format (`SignedStatement`, `StatementProof`, topics) must map to +smoldot's statement encoding for `statement_submit` and from the +`statement_subscribeStatement` notifications — a focused codec task, fixtured like +the chain codecs. `create_proof*` are left to the host's signer (the provider can +delegate to an app-supplied callback or return `UnableToSign`). + +--- + +## 4. The `package:smoldot` surface (v1.2.0) and its gaps + +`package:smoldot` v1.2.0 (snowpinelabs fork, `packages/smoldot`) is FFI bindings to +**smoldot-light 1.2.0** (Rust `smoldot-light = "1.2"`). It exposes the full modern +JSON-RPC v2 API plus the statement-store protocol, and ships convenience method +constants (`chainHead_v1_*`, `transaction_v1_*`, `statement_*`, `bitswap_v1_get`). + +```dart +final client = SmoldotClient(config: SmoldotConfig(maxChains: 8, maxLogLevel: 3)); +await client.initialize(); +final chain = await client.addChain(AddChainConfig( + chainSpec: assetHubSpecJson, + potentialRelayChains: [relay.chainId], // parachain → its relay + statementStore: StatementStoreConfig(), // enable statement_* on this chain +)); +final resp = await chain.request('chainSpec_v1_genesisHash', []); // Future +final stream = chain.subscribe('chainHead_v1_follow', [false]); // Stream +// resp.result is the decoded JSON value; stream items carry params.result of each notification +await client.dispose(); +``` + +- `Chain.request(method, params) → Future` (`.result` = decoded JSON). +- `Chain.subscribe(method, params) → Stream` (each item = a + notification's `params.result`). +- Multi-chain: `addChain` with `potentialRelayChains` for parachains. +- FFI → native lib per platform (Android/iOS/macOS/Linux/Windows). + +**The "gap" is in the Dart convenience layer, not in smoldot — and the fix is the +same one the JS/TS world already uses.** smoldot itself (both the Rust FFI and the +official `smoldot` **JS** bindings) exposes only a *raw* JSON-RPC interface: +`sendJsonRpc(request: string)` + `nextJsonRpcResponse(): Promise` / +`jsonRpcResponses` — **no** `subscribe()` helper and **no** subscription-id +management. The caller owns request ids, subscription-id correlation, and framing. +The JS/TS consumers (substrate-connect, polkadot-api, and any TS TrUAPI host) run +their **own JSON-RPC client** over that raw interface — which is precisely why they +have the subscription id (it's in the subscribe *response*) and do correct +per-method unsubscribe. There is no gap there to "fix"; the correlation is just part +of the consumer. + +The Dart `package:smoldot` had instead added a *convenience* layer +(`JsonRpcHandler.request/subscribe/unsubscribe`) that hid the raw interface and +dropped the subscription id. **DONE:** the snowpinelabs fork's `Chain` now exposes +the raw JS interface directly — `sendJsonRpc(String)`, +`nextJsonRpcResponse() -> Future`, `jsonRpcResponses -> Stream` +(branch `feat/smoldot-raw-jsonrpc-chain-api`; the incomplete convenience layer was +removed, `JsonRpcHandler` → `RawJsonRpc`). So `smoldot_provider` builds its own +JSON-RPC client over `Chain.sendJsonRpc` / `Chain.jsonRpcResponses` — exactly the +JS architecture, no reaching into FFI internals. + +Remaining real constraints (not "gaps"): +- **Polling/pull model**: `nextJsonRpcResponse` yields one message at a time (same + as JS); our client runs a read-loop. Pin the `chore/upgrade-smoldot-1.2.0` commit. +- **Native library bundling** (BUILD.md; `rust/rust-toolchain.toml` pins rustc ≥ 1.85 + for smoldot-light 1.2.0's edition-2024) — a real packaging task. + +So Phase 1 builds `smoldot_provider`'s **own JSON-RPC client** over the raw +interface (the JS architecture), giving us subscription ids and correct +per-method unsubscribe for free — no dependence on the package's convenience layer. + +--- + +## 5. Package design: `smoldot_provider` + +### 5.1 Layout + +``` +dart/smoldot_provider/ # new package in this repo (sibling of dart/truapi) + pubspec.yaml # deps: truapi (path), smoldot (path/git), test + lib/ + smoldot_provider.dart # barrel + src/ + backend.dart # SmoldotChainBackend: client + chains keyed by genesis hash + json_rpc.dart # OUR JSON-RPC client over smoldot's raw sendJsonRpc/nextJsonRpcResponse (request + subscribe-with-id + per-method unsubscribe), mirroring substrate-connect/polkadot-api + chain_handlers.dart # SmoldotChainHandlers implements ChainHostHandlers (the engine) + statement_handlers.dart # SmoldotStatementStoreHandlers implements StatementStoreHostHandlers + follow.dart # per-follow state: smoldot sub id + operation→follow routing + codec/ + params.dart # TrUAPI Chain request types → JSON-RPC params (bytes↔hex) + events.dart # JSON-RPC follow events → RemoteChainHeadFollowItem + responses.dart # JSON-RPC results → TrUAPI Chain responses + statements.dart # SignedStatement / topics ⇄ statement_submit / _subscribeStatement + provider.dart # createSmoldotProvider(...): turnkey TrUAPI Provider + test/ + codec_test.dart # mapping unit tests (recorded JSON-RPC fixtures) + chain_handlers_test.dart # chain handlers against a fake JSON-RPC chain + statement_handlers_test.dart # statement handlers against a fake JSON-RPC chain + integration_test.dart # real smoldot + Paseo/Westend (tagged, opt-in) + example/ + host_with_smoldot.dart # compose Smoldot{Chain,StatementStore}Handlers into a host +``` + +### 5.2 Public API (sketch — implements existing interfaces only) + +```dart +// Manages the smoldot client and one chain per genesis hash, from app-supplied specs. +class SmoldotChainBackend { + static Future create({ + // per genesis hash: chain spec, optional relay spec, and whether to enable statement-store + required Map chains, + SmoldotConfig config, + }); + Future dispose(); +} + +// The core deliverable for a Flutter host: drop into TruapiHostHandlers.chain. +class SmoldotChainHandlers implements ChainHostHandlers { + SmoldotChainHandlers(SmoldotChainBackend backend); + // ...all 13 Chain methods, backed by smoldot... +} + +// Statement store via smoldot (subscribe + submit). create_proof* delegate to the +// host's signer (app-supplied callback) or return UnableToSign. +class SmoldotStatementStoreHandlers implements StatementStoreHostHandlers { + SmoldotStatementStoreHandlers(SmoldotChainBackend backend, {StatementSigner? signer}); +} + +// Convenience: a turnkey TrUAPI Provider (embedded host: smoldot Chain [+ statements] +// + your other services). +Future createSmoldotProvider({ + required SmoldotChainBackend backend, + TruapiHostHandlers? otherServices, // optional: your Account/Signing/… handlers + StatementSigner? statementSigner, // optional: enable create_proof* via your wallet + HostServerHooks? hooks, +}); +``` + +`SmoldotChainHandlers` / `SmoldotStatementStoreHandlers` implement the **generated** +`ChainHostHandlers` / `StatementStoreHostHandlers` (no changes to them). +`createSmoldotProvider` returns the **existing** `Provider` type. Statement-store +chains must be created with `AddChainConfig.statementStore` enabled (D6). + +### 5.3 The follow/operation engine (`follow.dart` + `chain_handlers.dart`) + +Per active TrUAPI follow subscription, keyed by `followSubscriptionId` +(= the follow start's `CallContext.requestId`): + +- `followHeadSubscribe(ctx, req)` → + - resolve the smoldot `Chain` for `req.genesisHash` (via backend), + - `chain.subscribeWithId('chainHead_v1_follow', [req.withRuntime])` → `(smoldotSubId, jsonStream)`, + - register `_FollowState(followId: ctx.requestId, smoldotSubId, chain)`, + - return a `Stream` mapping each json event via `codec/events.dart`; + on stream cancel → `chain.request('chainHead_v1_unfollow', [smoldotSubId])` + drop state. +- `getHeadHeader/Body/Storage/Call/unpin/continue/stopOperation(ctx, req)` → + - look up `_FollowState` by `req.followSubscriptionId`, + - call the matching `chainHead_v1_*` request with `state.smoldotSubId` + mapped params, + - map the JSON result → TrUAPI response (operation-started or inline value). + - Operation **data** is delivered through the existing follow stream (the + `Operation*Done` events) — no extra correlation needed. +- `getSpec*` → `chainSpec_v1_*` on the resolved chain. +- `broadcastTransaction` → `transaction_v1_broadcast`; `stopTransaction` → `transaction_v1_stop`. + +Genesis-hash routing: the backend lazily `addChain`s from the app-supplied spec +and verifies `chainSpec_v1_genesisHash` matches the requested hash. + +### 5.4 Type mapping (`codec/`) + +The adapter converts between TrUAPI Chain types (the generated `types.dart`: +`Uint8List`, structs, the `RemoteChainHeadFollowItem` sealed class) and JSON-RPC +JSON (hex strings, objects). Examples: +- `Vec` / `[u8;N]` ↔ `0x…` hex. +- `RemoteChainHeadFollowItem.Initialized{finalizedBlockHashes, finalizedBlockRuntime}` + ↔ `{ event: "initialized", finalizedBlockHashes:[...], finalizedBlockRuntime:{...} }`. +- `StorageQueryItem{key, queryType}` ↔ `{ key:"0x…", type:"value|hash|…" }`. +- `OperationStartedResult` ↔ `{ result:"started", operationId } | { result:"limitReached" }`. + +This layer is pure, deterministic, and the most test-worthy part (record real +smoldot JSON-RPC notifications as fixtures and assert both directions). + +--- + +## 6. Key design decisions (recommendations) + +| # | Decision | Recommendation | Why / alternatives | +|---|---|---|---| +| **D1** | What is `smoldot_provider`? | **Both:** core `SmoldotChainHandlers` (host handlers) **+** convenience `createSmoldotProvider` (turnkey TrUAPI `Provider`). | Covers the Flutter-host case (primary) and the all-in-one-process product case, with one engine. No `truapi` change either way. | +| **D2** | Where does the package live? | **`dart/smoldot_provider/` in this (truapi) repo**, sibling to `dart/truapi`. | Reuses the generated `ChainHostHandlers`/types; versioned with the protocol. Alt: standalone repo or in the host app — fine, but loses co-location. | +| **D3** | `package:smoldot` dependency | **RESOLVED: snowpinelabs fork, v1.2.0** (`chore/upgrade-smoldot-1.2.0`), pinned by commit; isolate behind `src/json_rpc.dart`. | Wraps smoldot-light 1.2.0 (modern JSON-RPC v2 + statement store); the justkawal v0.1.0 wraps 0.18 and lacks statement store. | +| **D4** | Subscription-id / unsubscribe | **DONE.** The fork's `Chain` now exposes the raw `sendJsonRpc`/`nextJsonRpcResponse`/`jsonRpcResponses` (branch `feat/smoldot-raw-jsonrpc-chain-api`). `smoldot_provider` runs its own JSON-RPC client over it (the JS/TS approach). | smoldot is raw-JSON-RPC by design; JS consumers own correlation — so do we. Gives subscription ids + correct unsubscribe; no convenience-layer fork. | +| **D5** | Scope | **Chain (all) + StatementStore `submit`/`subscribe`.** `create_proof*` (signing), Preimage, and other services are the host's own. | smoldot 1.2.0 serves chain + statement-store protocol, but not signing or content-store preimages. | +| **D6** | Chain specs + statement store | **App supplies `{genesisHash: (chainSpec, relaySpec?, enableStatementStore)}`.** | smoldot needs a chain spec to add a chain; statement-store services require `AddChainConfig.statementStore` on that chain. Ship Paseo Asset Hub (+ relay) and Westend specs as examples. | +| **D7** | Native library | **Treat as a first-class packaging phase**; for Flutter, a plugin that bundles per-platform binaries. | FFI light client; can't ship Dart-only. | + +--- + +## 7. Phased plan + +### Phase 0 — Spike & decisions (de-risk the dependency) — DONE +- ✅ snowpinelabs `package:smoldot` v1.2.0 running locally; native lib loads (rustc ≥ 1.85). +- ✅ Raw interface exposed on `Chain` (`sendJsonRpc`/`nextJsonRpcResponse`/`jsonRpcResponses`) + and driven **end-to-end against live Westend** (legacy `system_chain` + modern + `chainSpec_v1_genesisHash`), through the `smoldot_provider` `JsonRpcProvider`. +- ✅ `smoldot_provider` (Layer A) built + tested: `getSmProvider` over a smoldot chain. +- Remaining spike bits (carry into Layer B): exercise `chainHead_v1_follow` (capture follow + sub id from the subscribe **response**) + `chainHead_v1_header`, and `statement_subscribeStatement`; + capture their notification shapes as codec fixtures. Decide D6 (which chains enable statements). + +### Phase 1 — JSON-RPC client + chain backend (Layer B) — over the `smoldot_provider` provider +- Run a JSON-RPC client over a `JsonRpcProvider` (from `smoldot_provider.getSmProvider`): + a read-loop routing responses by request id and notifications by `params.subscription`; + `request(method,params)`, `subscribe(...) → (id, Stream)`, per-method unsubscribe + (`chainHead_v1_unfollow`, `statement_unsubscribeStatement`). (The provider already owns + the raw send/receive; this is the correlation layer, mirroring substrate-connect/papi.) +- A small chain registry: one `JsonRpcProvider` per genesis hash from app-supplied specs + (with `AddChainConfig.statementStore` where enabled), verify genesis via + `chainSpec_v1_genesisHash`, parachain (relay) wiring, lifecycle/dispose. +- Unit-test the client against a fake `JsonRpcProvider` (no FFI). + +### Phase 1 — smoldot backend + our JSON-RPC client +- `SmoldotChainBackend`: init `SmoldotClient`, lazily `addChain` per genesis hash from + app-supplied specs (with `AddChainConfig.statementStore` where enabled), verify genesis + via `chainSpec_v1_genesisHash`, parachain (relay) wiring, lifecycle/dispose. +- `src/json_rpc.dart`: **our own JSON-RPC client** over smoldot's raw + `sendJsonRpc`/`nextJsonRpcResponse` — a read-loop that routes responses by request id + and notifications by `params.subscription`; `request(method,params)`, + `subscribe(method,params) → (String id, Stream)`, and per-method unsubscribe + (`chainHead_v1_unfollow`, `statement_unsubscribeStatement`). Mirrors substrate-connect. +- (Optional, recommended) upstream a clean `Chain.sendJsonRpc`/`jsonRpcResponses` + passthrough to the snowpinelabs fork; until then use the public `bindings`/`chainId`. +- Unit-test the client against a fake/in-memory raw JSON-RPC chain. + +### Phase 2 — Codec layer (the mapping) +- `codec/params.dart`, `codec/events.dart`, `codec/responses.dart`: bidirectional + TrUAPI Chain types ↔ JSON-RPC JSON for every method + every follow event variant. +- `codec/statements.dart`: `SignedStatement` / topics ⇄ `statement_submit` params and + `statement_subscribeStatement` notifications (+ the `RemoteStatementStoreSubscribeItem` + `{statements, is_complete}` shape). +- `codec_test.dart`: drive with recorded real-smoldot fixtures from Phase 0; assert + both directions (params, results, events, statements) — this is the correctness core. + +### Phase 3 — Service handlers +- **`SmoldotChainHandlers`** (`implements ChainHostHandlers`): the follow/operation engine + (§5.3) — per-follow state keyed by `followSubscriptionId`, operation routing through the + follow stream, genesis-hash routing; all 13 Chain methods; error mapping; lifecycle + (TrUAPI follow cancel → `chainHead_v1_unfollow` + cleanup). +- **`SmoldotStatementStoreHandlers`** (`implements StatementStoreHostHandlers`): `subscribe` + → `statement_subscribeStatement` (topic filter; cancel → `statement_unsubscribeStatement`), + `submit` → `statement_submit`; `create_proof*` delegate to an app-supplied `StatementSigner` + or return `UnableToSign`. +- `chain_handlers_test.dart` + `statement_handlers_test.dart`: handlers driven against a + fake JSON-RPC chain (no native lib). + +### Phase 4 — Turnkey TrUAPI `Provider` +- `createSmoldotProvider(...)`: build an embedded host (`createHostServer` with + `buildChainEntries(SmoldotChainHandlers)` + `buildStatementStoreEntries(...)` + optional + app handlers) over an in-process channel; expose the client side as a `Provider`. +- e2e over `LoopbackChannel`: generated TrUAPI **client** → smoldot provider → fake chain, + exercising `getSpecChainName`, `followHeadSubscribe`, `getHeadHeader`, and + `statementStore.subscribe`/`submit`. +- (Note the related client-side gap in §10 for *Dart products* using chain follow.) + +### Phase 5 — Native library & platform packaging +- Reproducible build of smoldot-light native libs (per BUILD.md) for target platforms. +- Flutter plugin packaging that bundles the binaries (Android `.so`, iOS/macOS + `.dylib`/xcframework, Linux `.so`, Windows `.dll`); document desktop dev setup. +- Example Flutter host app loading the lib and running a real chain. + +### Phase 6 — Testing & conformance +- Unit (codec) green on fixtures; handler tests on the fake chain. +- Integration (`integration_test.dart`, opt-in/tagged): real smoldot + Westend/Paseo — + follow → header/body/storage/call → unpin/unfollow; spec methods; broadcast a tx + (signed test extrinsic) end to end. +- e2e: TrUAPI client → turnkey provider → live chain. + +### Phase 7 — Upstream / dependency hardening — DONE +- **Done:** the snowpinelabs `package:smoldot` `Chain` now exposes the raw JS interface + (`sendJsonRpc(String)` + `nextJsonRpcResponse()` + `jsonRpcResponses`); the incomplete + convenience layer was removed (`JsonRpcHandler` → `RawJsonRpc`). Branch + `feat/smoldot-raw-jsonrpc-chain-api` (analyze/format clean; verified against live Westend). +- Remaining: merge that branch; pin the resulting commit. + +### Phase 8 — Docs, CI, packaging +- `dart/smoldot_provider/README.md` (host-handlers usage + turnkey provider). +- CI: analyze + codec/handler tests on every push; integration tests on a manual/nightly + job (native lib + network). +- Version alignment with `package:truapi`; publish decision. + +--- + +## 8. Implementation checklist + +### Phase 0 — Spike — DONE +- [x] smoldot v1.2.0 native lib loads on the dev platform (rustc ≥ 1.85) +- [x] `SmoldotClient` + `addChain` (Westend) running locally +- [x] Raw `Chain` interface + `smoldot_provider.getSmProvider` driven end-to-end on live + Westend (`system_chain` + `chainSpec_v1_genesisHash`) +- [x] `smoldot_provider` (Layer A): `getSmProvider`/`getRawProvider` + unit + integration tests +- [ ] `chainHead_v1_follow`/`_header` + `statement_subscribeStatement` exercised; fixtures captured (→ Layer B) +- [ ] Decision D6 fixed (which chains enable statements) + +### Phase 1 — JSON-RPC client + chain registry (Layer B, over `smoldot_provider`) +- [ ] JSON-RPC client over a `JsonRpcProvider` (`request` + `subscribe`-with-id + per-method unsubscribe) +- [ ] Chain registry (one provider per genesis hash + `statementStore`, verify genesis, relay, dispose) +- [ ] Client unit tests (fake `JsonRpcProvider`) + +### Phase 2 — Codec +- [ ] `params.dart` — all Chain request types → JSON-RPC params +- [ ] `events.dart` — follow events → `RemoteChainHeadFollowItem` (all variants) +- [ ] `responses.dart` — results → header/body/storage/call/spec/tx responses +- [ ] `statements.dart` — `SignedStatement`/topics ⇄ `statement_submit`/`_subscribeStatement` +- [ ] `codec_test.dart` green on recorded fixtures (both directions) + +### Phase 3 — Service handlers +- [ ] `SmoldotChainHandlers`: `_FollowState` + per-follow routing; all 13 methods; error mapping; cancel → `chainHead_v1_unfollow` +- [ ] `SmoldotStatementStoreHandlers`: `subscribe`/`submit` via `statement_*`; `create_proof*` → signer/UnableToSign +- [ ] `chain_handlers_test.dart` + `statement_handlers_test.dart` green (fake chain) + +### Phase 4 — Turnkey provider +- [ ] `createSmoldotProvider(...)` (embedded host: Chain + StatementStore + composition slot) +- [ ] e2e: TrUAPI client → provider → fake chain (chain request + follow + statement sub/submit) + +### Phase 5 — Native & packaging +- [ ] Reproducible native build (target platforms) +- [ ] Flutter plugin bundling per-platform binaries +- [ ] Example Flutter host app on a real chain + +### Phase 6 — Testing +- [ ] Codec + handler unit tests green +- [ ] Integration (real smoldot + Westend/Paseo), opt-in +- [ ] Full e2e against a live chain + +### Phase 7 — Upstream +- [ ] Raw `Chain.sendJsonRpc`/`jsonRpcResponses` passthrough upstreamed to snowpinelabs fork (or driven via public `bindings`/`chainId`) +- [ ] Fork commit pinned + +### Phase 8 — Docs/CI +- [ ] README (both usages) +- [ ] CI: analyze + unit; nightly/manual integration +- [ ] Version alignment + publish decision + +--- + +## 9. Testing strategy + +- **Codec unit tests (no native lib):** record real smoldot `chainHead_v1` JSON-RPC + notifications/results in Phase 0; assert TrUAPI⇄JSON both directions. This is where + correctness lives and runs in plain CI. +- **Handler tests (no native lib):** drive `SmoldotChainHandlers` against a fake + JSON-RPC chain (scripted responses/notifications) — covers the follow/operation + routing and lifecycle without smoldot or network. +- **Integration (opt-in):** real smoldot + Westend/Paseo; slow, network + native lib; + manual/nightly CI job. +- **e2e:** generated TrUAPI client → turnkey provider → chain (fake in CI, live in the + nightly job). + +The fake-chain seam means the bulk of `smoldot_provider` is testable in ordinary CI +with no native dependency. + +--- + +## 10. Open decisions / questions + +- **Native build & platforms (D7):** which platforms first (desktop Linux/macOS for + dev; Android/iOS for the app)? Who owns the reproducible smoldot native build — + reuse the snowpinelabs fork's `rust/` + `tool/`, or build here? +- **smoldot fork/version (D3) — RESOLVED:** snowpinelabs `package:smoldot` v1.2.0 + (smoldot-light 1.2.0), pinned by commit. (justkawal v0.1.0 wraps 0.18 and lacks the + statement store.) +- **Raw-interface passthrough (D4):** ship now by reaching the public `bindings`/`chainId`, + or upstream a `Chain.sendJsonRpc`/`jsonRpcResponses` first? (Either works; upstream is tidier.) +- **Chain specs + statement store (D6):** which specs (Paseo Asset Hub + relay, Westend), + and which chains enable `statementStore`? App supplies the rest. Confirm target chains. +- **Client-side subscription id (related, optional, *not* required for the host path):** + the Dart TrUAPI **client**'s subscription methods return `Stream` and don't + surface the subscription id, which a *Dart product* needs to call chain operations + (`followSubscriptionId`). For the **host** use (your case) this is irrelevant — the + remote product supplies the id. For the turnkey-provider-with-a-Dart-product case, + we'd add an opt-in client API to expose it (mirrors TS `Subscription.subscriptionId`) + — a small, backward-compatible addition, tracked separately, **not** a protocol change. +- **Transaction status:** `broadcast_transaction` (`transaction_v1_broadcast`) is + fire-and-broadcast (no status stream in the Chain trait). Confirm the host doesn't + need `transactionWatch_v1_*` status events surfaced elsewhere. + +--- + +## 11. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| `package:smoldot`'s convenience layer is incomplete (subscription id, unsubscribe) | Bypass it — run our own JSON-RPC client over the **raw** `sendJsonRpc`/`nextJsonRpcResponse` (the JS/TS approach); pin the fork; optional upstream passthrough (Phase 7) | +| Statement wire format ⇄ smoldot statement encoding mismatch | Fixture-driven `codec/statements.dart` tests against real `statement_*` notifications | +| Native lib build/bundling friction across platforms | Dedicated Phase 5; start with desktop dev; reuse polkadart's build tooling | +| chainHead_v1 follow/operation correlation bugs | Mirror the JSON-RPC model exactly; fixture-driven codec tests + fake-chain handler tests | +| smoldot warm-up/sync latency in tests | `waitUntilSynced`/finalized-head gating; mark integration tests slow/opt-in | +| Genesis-hash → chain-spec mismatch | Verify `chainSpec_v1_genesisHash` on add; fail loudly | +| Scope creep into Preimage/StatementStore/signing | Explicitly out of scope (§2); host owns them | + +--- + +## 12. Appendix — key references + +- TrUAPI Chain trait + doc examples: `rust/crates/truapi/src/api/chain.rs`; types: + `rust/crates/truapi/src/v01/chain.rs`, `v01/common.rs`. +- Generated Dart host handler interface: `ChainHostHandlers` in + `dart/truapi/lib/src/generated/host.dart`; types in `…/generated/types.dart`. +- TrUAPI host runtime: `dart/truapi/lib/src/host/host_server.dart` + (`createHostServer`, `buildChainEntries`, `Provider`). +- smoldot Dart bindings (target): `polkadart-snowpinelabs/packages/smoldot/lib/` + (`smoldot.dart`, `src/client.dart`, `src/chain.dart`, `src/json_rpc.dart`, + `src/bindings.dart` — raw `sendJsonRpcRequest`/`nextJsonRpcResponse`); build: + `packages/smoldot/BUILD.md`, `rust/rust-toolchain.toml`, `rust/Cargo.toml` (`smoldot-light = "1.2"`). +- smoldot **JS** raw interface (the model we mirror): `smol-dot/smoldot` + `wasm-node/javascript/src/public-types.ts` — `Chain.sendJsonRpc(string)`, + `nextJsonRpcResponse()`, `jsonRpcResponses` (no `subscribe` helper; caller owns correlation). +- polkadart JSON-RPC provider reference: `polkadart/packages/polkadart/lib/apis/provider.dart`. +- Modern Polkadot JSON-RPC spec: `chainHead_v1_*`, `chainSpec_v1_*`, `transaction_v1_*`, + `statement_*` (paritytech/json-rpc-interface-spec; smoldot is the reference implementation). diff --git a/rust/crates/truapi-codegen/src/dart.rs b/rust/crates/truapi-codegen/src/dart.rs new file mode 100644 index 00000000..a8dc72b5 --- /dev/null +++ b/rust/crates/truapi-codegen/src/dart.rs @@ -0,0 +1,1446 @@ +//! Dart code generation from extracted API definitions. +//! +//! Sibling of [`crate::ts`]: consumes the same [`ApiDefinition`] IR and emits the +//! typed Dart **host** surface — `types.dart`, `wire_table.dart`, `index.dart`, +//! and `host.dart` (handler interfaces + dispatch entries + `createTruapiServer`). +//! No client facade is emitted: products are web apps that use the TS/JS client. +//! The Rust crate stays the single source of truth — every Dart type, codec, wire +//! id, and doc comment is derived from the rustdoc JSON. +//! +//! Mapping highlights (mirrors `ts.rs`): +//! - Versioned `Vn` wrapper enums are not emitted as Dart types; each wrapper's +//! selected (highest `≤ target`) inner type is emitted under its stripped +//! public name, and the wire wrapping is `S.versioned(n - 1, innerCodec)`. +//! - Unit-only enums become Dart `enum`s; mixed enums become sealed classes. +//! - Structs become immutable classes; `Component

` becomes a generic class +//! with a generic codec function. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Write; +use std::fs; +use std::path::Path; + +use anyhow::{Result, anyhow, bail}; +use convert_case::{Case, Casing}; + +use crate::rustdoc::*; + +/// Generate the shared Dart types + wire table into `output_dir`. +/// +/// The Dart package is host-only (products are web apps that use the TS/JS +/// client), so no client facade is emitted — only the protocol types and wire +/// table the host needs, consumed by the generated `host.dart`. +pub fn generate(api: &ApiDefinition, output_dir: &str, target_version: u32) -> Result<()> { + fs::create_dir_all(output_dir)?; + let g = Gen::new(api, target_version); + + fs::write(Path::new(output_dir).join("types.dart"), g.types()?)?; + fs::write( + Path::new(output_dir).join("wire_table.dart"), + g.wire_table()?, + )?; + fs::write( + Path::new(output_dir).join("index.dart"), + format!("{HEADER}\nexport 'types.dart';\nexport 'wire_table.dart';\n"), + )?; + Ok(()) +} + +/// Header comment for the one-shot host scaffold. +const SCAFFOLD_HEADER: &str = "\ +// TrUAPI host scaffold — a starting point for a Flutter/Dart host. +// +// Copy this into your host app and replace each `throw UnimplementedError(...)` +// with your host logic, then start the server with your transport Provider: +// +// final server = createTruapiServer(provider, ScaffoldHostHandlers()); +// // ... later: server.dispose(); +// +// Backing notes (heuristic — adjust to your host): +// smoldart (light client, when ready): Chain, Preimage, Payment, CoinPayment, +// StatementStore +// host-local: System, Theme, LocalStorage, +// Notifications, Permissions, Entropy, +// ResourceAllocation +// wallet / keystore: Account, Signing +// messaging: Chat +// +// This file is a one-shot scaffold (not regenerated by the build): edit freely. + +"; + +/// Generate a one-shot, editable host scaffold to the file at `out_file`. +pub fn generate_host_scaffold( + api: &ApiDefinition, + out_file: &str, + target_version: u32, +) -> Result<()> { + if let Some(parent) = Path::new(out_file).parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + let g = Gen::new(api, target_version); + fs::write(out_file, g.host_scaffold()?)?; + Ok(()) +} + +/// Generate the Dart host dispatcher (`host.dart`) into `output_dir`. +/// +/// The host reuses the client's generated `types.dart` (inner types + codecs): +/// it emits one typed handler interface per service, the dispatch-entry +/// builders, and `createTruapiServer`, all bound to the hand-written +/// `src/host/host_server.dart` runtime. Handlers receive and return the inner +/// (selected-version) types; the generated entries handle versioned wire +/// wrapping. +pub fn generate_host(api: &ApiDefinition, output_dir: &str, target_version: u32) -> Result<()> { + fs::create_dir_all(output_dir)?; + let g = Gen::new(api, target_version); + fs::write(Path::new(output_dir).join("host.dart"), g.host()?)?; + Ok(()) +} + +/// Selected inner shape of a versioned wrapper variant. +enum WrapKind { + Unit, + Tuple(TypeRef), +} + +/// A versioned wrapper enum (`Vn` variants), keyed by version number. +struct Wrapper { + variants: BTreeMap, +} + +struct Gen<'a> { + api: &'a ApiDefinition, + wrappers: HashMap, + target_version: u32, + /// Full names of the version-prefixed types to emit (the selected version + /// per base). Others are elided. + emit_prefixed: HashSet, +} + +const HEADER: &str = + "// Auto-generated by truapi-codegen. Do not edit.\n// ignore_for_file: type=lint\n"; + +impl<'a> Gen<'a> { + fn new(api: &'a ApiDefinition, target_version: u32) -> Self { + let wrappers = api + .types + .iter() + .filter_map(|ty| detect_wrapper(ty).map(|w| (ty.name.clone(), w))) + .collect(); + + // Pick the highest version-prefixed inner per base (≤ target). + let mut selected: BTreeMap = BTreeMap::new(); + for ty in &api.types { + if let Some((version, base)) = version_prefixed_type(&ty.name) { + if version > target_version { + continue; + } + let entry = selected + .entry(base.to_string()) + .or_insert((version, ty.name.clone())); + if version > entry.0 { + *entry = (version, ty.name.clone()); + } + } + } + let emit_prefixed = selected.into_values().map(|(_, name)| name).collect(); + + Self { + api, + wrappers, + target_version, + emit_prefixed, + } + } + + // --- types.dart ------------------------------------------------------- + + fn types(&self) -> Result { + let mut out = String::new(); + writeln!( + out, + "{HEADER}\nimport 'dart:typed_data';\n\nimport '../scale.dart' as S;\n" + ) + .unwrap(); + + for ty in &self.api.types { + if detect_wrapper(ty).is_some() { + continue; // wrapper enums are wire-only; not Dart types + } + if version_prefixed_type(&ty.name).is_some() && !self.emit_prefixed.contains(&ty.name) { + continue; + } + let name = public_name(&ty.name); + self.write_type(&mut out, ty, &name)?; + writeln!(out).unwrap(); + self.write_type_codec(&mut out, ty, &name)?; + writeln!(out).unwrap(); + } + Ok(out) + } + + fn write_type(&self, out: &mut String, ty: &TypeDef, name: &str) -> Result<()> { + write_doc(out, "", ty.docs.as_deref()); + let generics = generic_decl(&ty.generic_params); + match &ty.kind { + TypeDefKind::Alias(target) => { + writeln!( + out, + "typedef {name}{generics} = {};", + self.dart_type(target)? + ) + .unwrap(); + } + TypeDefKind::Struct(fields) => self.write_struct(out, name, &generics, fields)?, + TypeDefKind::TupleStruct(types) => { + self.write_tuple_struct(out, name, &generics, types)? + } + TypeDefKind::Enum(variants) => { + if is_unit_only_enum(ty) { + self.write_unit_enum(out, name, variants)?; + } else { + self.write_sealed_enum(out, name, &generics, variants)?; + } + } + } + Ok(()) + } + + fn write_struct( + &self, + out: &mut String, + name: &str, + generics: &str, + fields: &[FieldDef], + ) -> Result<()> { + let dart: Vec<(String, String, bool)> = fields + .iter() + .map(|f| { + let optional = matches!(f.type_ref, TypeRef::Option(_)); + Ok((field_name(&f.name), self.dart_type(&f.type_ref)?, optional)) + }) + .collect::>()?; + + writeln!(out, "class {name}{generics} {{").unwrap(); + // Constructor. + writeln!(out, " const {name}({{").unwrap(); + for (fname, _, optional) in &dart { + if *optional { + writeln!(out, " this.{fname},").unwrap(); + } else { + writeln!(out, " required this.{fname},").unwrap(); + } + } + writeln!(out, " }});").unwrap(); + // Fields. + for ((fname, ftype, _), field) in dart.iter().zip(fields) { + write_doc(out, " ", field.docs.as_deref()); + writeln!(out, " final {ftype} {fname};").unwrap(); + } + // Equality + toString. + write_value_equality( + out, + name, + generics, + &dart.iter().map(|(n, _, _)| n.clone()).collect::>(), + ); + writeln!(out, "}}").unwrap(); + Ok(()) + } + + fn write_tuple_struct( + &self, + out: &mut String, + name: &str, + generics: &str, + types: &[TypeRef], + ) -> Result<()> { + let dart: Vec<(String, String)> = types + .iter() + .enumerate() + .map(|(i, t)| Ok((format!("field{i}"), self.dart_type(t)?))) + .collect::>()?; + writeln!(out, "class {name}{generics} {{").unwrap(); + let params = dart + .iter() + .map(|(n, _)| format!("this.{n}")) + .collect::>() + .join(", "); + writeln!(out, " const {name}({params});").unwrap(); + for (n, t) in &dart { + writeln!(out, " final {t} {n};").unwrap(); + } + write_value_equality( + out, + name, + generics, + &dart.iter().map(|(n, _)| n.clone()).collect::>(), + ); + writeln!(out, "}}").unwrap(); + Ok(()) + } + + fn write_unit_enum(&self, out: &mut String, name: &str, variants: &[VariantDef]) -> Result<()> { + writeln!(out, "enum {name} {{").unwrap(); + for variant in variants { + write_doc(out, " ", variant.docs.as_deref()); + writeln!(out, " {},", enum_value_name(&variant.name)).unwrap(); + } + writeln!(out, "}}").unwrap(); + Ok(()) + } + + fn write_sealed_enum( + &self, + out: &mut String, + name: &str, + generics: &str, + variants: &[VariantDef], + ) -> Result<()> { + writeln!(out, "sealed class {name}{generics} {{").unwrap(); + writeln!(out, " const {name}();").unwrap(); + writeln!(out, "}}").unwrap(); + + for variant in variants { + let vname = format!("{name}{}", variant.name); + write_doc(out, "", variant.docs.as_deref()); + match &variant.fields { + VariantFields::Unit => { + writeln!( + out, + "final class {vname}{generics} extends {name}{} {{", + type_args(generics) + ) + .unwrap(); + writeln!(out, " const {vname}();").unwrap(); + write_value_equality(out, &vname, generics, &[]); + writeln!(out, "}}").unwrap(); + } + VariantFields::Unnamed(types) => { + let dart: Vec<(String, String)> = types + .iter() + .enumerate() + .map(|(i, t)| { + let fname = if types.len() == 1 { + "value".to_string() + } else { + format!("field{i}") + }; + Ok((fname, self.dart_type(t)?)) + }) + .collect::>()?; + writeln!( + out, + "final class {vname}{generics} extends {name}{} {{", + type_args(generics) + ) + .unwrap(); + let params = dart + .iter() + .map(|(n, _)| format!("this.{n}")) + .collect::>() + .join(", "); + writeln!(out, " const {vname}({params});").unwrap(); + for (n, t) in &dart { + writeln!(out, " final {t} {n};").unwrap(); + } + write_value_equality( + out, + &vname, + generics, + &dart.iter().map(|(n, _)| n.clone()).collect::>(), + ); + writeln!(out, "}}").unwrap(); + } + VariantFields::Named(fields) => { + let dart: Vec<(String, String, bool)> = fields + .iter() + .map(|f| { + let optional = matches!(f.type_ref, TypeRef::Option(_)); + Ok((field_name(&f.name), self.dart_type(&f.type_ref)?, optional)) + }) + .collect::>()?; + writeln!( + out, + "final class {vname}{generics} extends {name}{} {{", + type_args(generics) + ) + .unwrap(); + writeln!(out, " const {vname}({{").unwrap(); + for (fname, _, optional) in &dart { + if *optional { + writeln!(out, " this.{fname},").unwrap(); + } else { + writeln!(out, " required this.{fname},").unwrap(); + } + } + writeln!(out, " }});").unwrap(); + for (fname, ftype, _) in &dart { + writeln!(out, " final {ftype} {fname};").unwrap(); + } + write_value_equality( + out, + &vname, + generics, + &dart.iter().map(|(n, _, _)| n.clone()).collect::>(), + ); + writeln!(out, "}}").unwrap(); + } + } + } + Ok(()) + } + + fn write_type_codec(&self, out: &mut String, ty: &TypeDef, name: &str) -> Result<()> { + let codec_var = codec_name(name); + if ty.generic_params.is_empty() { + let expr = self.type_codec_expr(ty, name)?; + writeln!(out, "final S.Codec<{name}> {codec_var} = {expr};").unwrap(); + } else { + // Generic codec function: one Codec

param per generic parameter. + let params = ty + .generic_params + .iter() + .map(|p| format!("S.Codec<{p}> {}", codec_param(p))) + .collect::>() + .join(", "); + let generics = generic_decl(&ty.generic_params); + let ret = format!("{name}{}", type_args(&generics)); + let expr = self.type_codec_expr(ty, &ret)?; + writeln!( + out, + "S.Codec<{ret}> {codec_var}{generics}({params}) => {expr};" + ) + .unwrap(); + } + Ok(()) + } + + fn type_codec_expr(&self, ty: &TypeDef, type_name: &str) -> Result { + match &ty.kind { + TypeDefKind::Alias(target) => self.codec_expr(target), + TypeDefKind::Struct(fields) => self.struct_codec(type_name, fields), + TypeDefKind::TupleStruct(types) => self.tuple_struct_codec(type_name, types), + TypeDefKind::Enum(variants) => { + if is_unit_only_enum(ty) { + Ok(format!( + "S.Codec<{type_name}>(\n (out, v) => out.addByte(v.index),\n (i) => {type_name}.values[i.takeByte()],\n)" + )) + } else { + self.sealed_codec(type_name, variants) + } + } + } + } + + fn struct_codec(&self, type_name: &str, fields: &[FieldDef]) -> Result { + let mut enc = String::new(); + let mut dec = String::new(); + for field in fields { + let fname = field_name(&field.name); + let codec = self.codec_expr(&field.type_ref)?; + writeln!(enc, " {codec}.encInto(out, v.{fname});").unwrap(); + writeln!(dec, " {fname}: {codec}.decFrom(i),").unwrap(); + } + Ok(format!( + "S.Codec<{type_name}>(\n (out, v) {{\n{enc} }},\n (i) => {ctor}(\n{dec} ),\n)", + ctor = strip_type_args(type_name), + )) + } + + fn tuple_struct_codec(&self, type_name: &str, types: &[TypeRef]) -> Result { + let mut enc = String::new(); + let mut dec = String::new(); + for (i, ty) in types.iter().enumerate() { + let codec = self.codec_expr(ty)?; + writeln!(enc, " {codec}.encInto(out, v.field{i});").unwrap(); + writeln!(dec, " {codec}.decFrom(i),").unwrap(); + } + Ok(format!( + "S.Codec<{type_name}>(\n (out, v) {{\n{enc} }},\n (i) => {ctor}(\n{dec} ),\n)", + ctor = strip_type_args(type_name), + )) + } + + fn sealed_codec(&self, type_name: &str, variants: &[VariantDef]) -> Result { + let base = strip_type_args(type_name); + let mut enc = String::new(); + let mut dec = String::new(); + for (idx, variant) in variants.iter().enumerate() { + let vname = format!("{base}{}", variant.name); + match &variant.fields { + VariantFields::Unit => { + writeln!(enc, " case {vname}():\n out.addByte({idx});").unwrap(); + writeln!(dec, " case {idx}:\n return const {vname}();").unwrap(); + } + VariantFields::Unnamed(types) => { + writeln!(enc, " case {vname}():\n out.addByte({idx});").unwrap(); + let mut dec_args = Vec::new(); + for (i, ty) in types.iter().enumerate() { + let codec = self.codec_expr(ty)?; + let accessor = if types.len() == 1 { + "value".to_string() + } else { + format!("field{i}") + }; + writeln!(enc, " {codec}.encInto(out, v.{accessor});").unwrap(); + dec_args.push(format!("{codec}.decFrom(i)")); + } + writeln!( + dec, + " case {idx}:\n return {vname}({});", + dec_args.join(", ") + ) + .unwrap(); + } + VariantFields::Named(fields) => { + writeln!(enc, " case {vname}():\n out.addByte({idx});").unwrap(); + let mut dec_args = Vec::new(); + for field in fields { + let fname = field_name(&field.name); + let codec = self.codec_expr(&field.type_ref)?; + writeln!(enc, " {codec}.encInto(out, v.{fname});").unwrap(); + dec_args.push(format!("{fname}: {codec}.decFrom(i)")); + } + writeln!( + dec, + " case {idx}:\n return {vname}({});", + dec_args.join(", ") + ) + .unwrap(); + } + } + } + Ok(format!( + "S.Codec<{type_name}>(\n (out, v) {{\n switch (v) {{\n{enc} }}\n }},\n (i) {{\n switch (i.takeByte()) {{\n{dec} default:\n throw FormatException('unknown {base} discriminant');\n }}\n }},\n)" + )) + } + + // --- wire_table.dart -------------------------------------------------- + + fn wire_table(&self) -> Result { + let mut out = String::new(); + writeln!(out, "{HEADER}\nimport '../transport.dart';\n").unwrap(); + + let mut entries: Vec<(u8, String)> = Vec::new(); + for trait_def in &self.api.traits { + for method in &trait_def.methods { + if !self.method_included(method)? { + continue; + } + let wc = wire_const(&trait_def.name, &method.name); + let (sort, line) = self.wire_entry(method, &wc)?; + entries.push((sort, line)); + } + } + entries.sort_by_key(|(s, _)| *s); + for (_, line) in entries { + writeln!(out, "{line}").unwrap(); + } + Ok(out) + } + + fn wire_entry(&self, method: &MethodDef, wc: &str) -> Result<(u8, String)> { + match method.kind { + MethodKind::Request => { + let request = method + .wire + .request_id + .ok_or_else(|| anyhow!("method `{}` missing request_id", method.name))?; + let response = method.wire.response_id.unwrap_or(request + 1); + Ok(( + request, + format!( + "const {wc} = RequestFrameIds(request: {request}, response: {response});" + ), + )) + } + MethodKind::Subscription | MethodKind::ResultSubscription => { + let start = method + .wire + .start_id + .ok_or_else(|| anyhow!("method `{}` missing start_id", method.name))?; + let stop = method.wire.stop_id.unwrap_or(start + 1); + let interrupt = method.wire.interrupt_id.unwrap_or(start + 2); + let receive = method.wire.receive_id.unwrap_or(start + 3); + Ok(( + start, + format!( + "const {wc} = SubscriptionFrameIds(start: {start}, stop: {stop}, interrupt: {interrupt}, receive: {receive});" + ), + )) + } + } + } + + /// Lowered request payload: the public arg declaration, the value + /// expression, and the wire codec (already versioned). + fn emit_payload(&self, method: &MethodDef, wire_version: Option) -> Result { + match method.params.as_slice() { + [] => { + let version = wire_version.unwrap_or(1); + Ok(Payload { + arg_decl: String::new(), + codec: format!("S.versioned({}, S.unit)", version - 1), + }) + } + [param] => { + if let TypeRef::Named { name, args } = ¶m.type_ref + && args.is_empty() + && let Some(wrapper) = self.wrappers.get(name) + { + let (version, kind) = self.select(wrapper)?; + return Ok(match kind { + WrapKind::Unit => Payload { + arg_decl: String::new(), + codec: format!("S.versioned({}, S.unit)", version - 1), + }, + WrapKind::Tuple(inner) => Payload { + arg_decl: format!("{} request", self.dart_type(inner)?), + codec: format!( + "S.versioned({}, {})", + version - 1, + self.codec_expr(inner)? + ), + }, + }); + } + // Non-wrapper single param (unversioned). + Ok(Payload { + arg_decl: format!("{} request", self.dart_type(¶m.type_ref)?), + codec: self.codec_expr(¶m.type_ref)?, + }) + } + _ => bail!( + "method `{}` has more than one request parameter", + method.name + ), + } + } + + /// Resolve a (possibly versioned-wrapper) type to its public Dart type and + /// its inner codec (no version byte — the version is applied by the caller). + fn resolve_inner(&self, ty: &TypeRef) -> Result<(String, String)> { + if let TypeRef::Named { name, args } = ty + && args.is_empty() + && let Some(wrapper) = self.wrappers.get(name) + { + let (_, kind) = self.select(wrapper)?; + return Ok(match kind { + WrapKind::Unit => ("S.Unit".to_string(), "S.unit".to_string()), + WrapKind::Tuple(inner) => (self.dart_type(inner)?, self.codec_expr(inner)?), + }); + } + Ok((self.dart_type(ty)?, self.codec_expr(ty)?)) + } + + // --- host.dart -------------------------------------------------------- + + fn host(&self) -> Result { + let mut out = String::new(); + writeln!( + out, + "{HEADER}\nimport 'dart:async';\nimport 'dart:typed_data';\n\nimport '../result.dart';\nimport '../scale.dart' as S;\nimport '../transport.dart';\nimport '../host/host_server.dart';\nimport 'types.dart';\nimport 'wire_table.dart' as W;\n" + ) + .unwrap(); + + let services = self.services()?; + + for (trait_def, methods) in &services { + // Typed handler interface. + write_doc(&mut out, "", trait_def.docs.as_deref()); + writeln!( + out, + "abstract interface class {}HostHandlers {{", + trait_def.name + ) + .unwrap(); + for method in methods { + self.emit_host_signature(&mut out, method)?; + } + writeln!(out, "}}\n").unwrap(); + + // Dispatch-entry builder. Public so hosts can compose a server + // from a subset of services (or add custom entries). + writeln!( + out, + "/// Build the dispatch entries for the [{name}HostHandlers] service.", + name = trait_def.name + ) + .unwrap(); + writeln!( + out, + "List build{name}Entries({name}HostHandlers h) => [", + name = trait_def.name + ) + .unwrap(); + for method in methods { + self.emit_host_entry(&mut out, trait_def, method)?; + } + writeln!(out, "];\n").unwrap(); + } + + // Combined handlers interface. + writeln!(out, "/// All host handler groups, one getter per service.").unwrap(); + writeln!(out, "abstract interface class TruapiHostHandlers {{").unwrap(); + for (trait_def, _) in &services { + writeln!( + out, + " {name}HostHandlers get {field};", + name = trait_def.name, + field = service_field(&trait_def.name) + ) + .unwrap(); + } + writeln!(out, "}}\n").unwrap(); + + // Factory. + writeln!( + out, + "/// Attach a host server to [provider], routing inbound request and" + ) + .unwrap(); + writeln!( + out, + "/// subscription frames to the supplied typed [handlers]." + ) + .unwrap(); + writeln!( + out, + "TruapiHostServer createTruapiServer(\n Provider provider,\n TruapiHostHandlers handlers, [\n HostServerHooks hooks = const HostServerHooks(),\n]) {{\n final entries = [", + ) + .unwrap(); + for (trait_def, _) in &services { + writeln!( + out, + " ...build{name}Entries(handlers.{field}),", + name = trait_def.name, + field = service_field(&trait_def.name) + ) + .unwrap(); + } + writeln!( + out, + " ];\n return createHostServer(provider, entries, hooks);\n}}" + ) + .unwrap(); + + Ok(out) + } + + /// Build one host handler method signature: `{ReturnType} {name}({params})` + /// (no trailing `;` or body). Shared by the interface and the scaffold. + fn host_signature(&self, method: &MethodDef) -> Result { + let name = method_name(&method.name); + let wire_version = self.method_wire_version(method)?; + let payload = self.emit_payload(method, wire_version)?; + let ctx_param = if payload.arg_decl.is_empty() { + "CallContext ctx".to_string() + } else { + format!("CallContext ctx, {}", payload.arg_decl) + }; + let ret = match (&method.kind, &method.return_type) { + (MethodKind::Request, ReturnType::Result { ok, err }) => { + let (ok_type, _) = self.resolve_inner(ok)?; + let (err_type, _) = self.resolve_inner(call_error_inner(err).unwrap_or(err))?; + format!("Future>") + } + (MethodKind::Subscription, ReturnType::Subscription(item)) => { + let (item_type, _) = self.resolve_inner(item)?; + format!("Stream<{item_type}>") + } + (MethodKind::ResultSubscription, ReturnType::ResultSubscription { item, .. }) => { + let (item_type, _) = self.resolve_inner(item)?; + format!("Stream<{item_type}>") + } + (kind, ret) => bail!( + "host signature mismatch for `{}`: {:?} vs {:?}", + method.name, + kind, + ret + ), + }; + Ok(format!("{ret} {name}({ctx_param})")) + } + + /// Emit one handler-interface method signature. + fn emit_host_signature(&self, out: &mut String, method: &MethodDef) -> Result<()> { + write_doc(out, " ", method.docs.as_deref()); + writeln!(out, " {};", self.host_signature(method)?).unwrap(); + Ok(()) + } + + /// Emit a ready-to-edit scaffold implementing every host handler with + /// `throw UnimplementedError(...)`. Meant to be copied into a host app. + fn host_scaffold(&self) -> Result { + let mut out = String::new(); + out.push_str(SCAFFOLD_HEADER); + writeln!( + out, + "import 'dart:typed_data';\n\nimport 'package:truapi/truapi.dart';\n" + ) + .unwrap(); + + let services = self.services()?; + for (trait_def, methods) in &services { + write_doc(&mut out, "", trait_def.docs.as_deref()); + writeln!( + out, + "class {name}Handlers implements {name}HostHandlers {{", + name = trait_def.name + ) + .unwrap(); + for method in methods { + // The scaffold imports `package:truapi/truapi.dart` (which re-exports + // `Unit`), not `scale.dart as S`, so drop the `S.` qualifier. + let sig = self.host_signature(method)?.replace("S.Unit", "Unit"); + let mname = method_name(&method.name); + writeln!( + out, + " @override\n {sig} =>\n throw UnimplementedError('{}.{mname}: not implemented');", + trait_def.name + ) + .unwrap(); + } + writeln!(out, "}}\n").unwrap(); + } + + writeln!( + out, + "/// Aggregates every service handler. Pass to `createTruapiServer`." + ) + .unwrap(); + writeln!( + out, + "class ScaffoldHostHandlers implements TruapiHostHandlers {{" + ) + .unwrap(); + for (trait_def, _) in &services { + writeln!( + out, + " @override\n final {name}HostHandlers {field} = {name}Handlers();", + name = trait_def.name, + field = service_field(&trait_def.name) + ) + .unwrap(); + } + writeln!(out, "}}").unwrap(); + Ok(out) + } + + /// Emit one dispatch entry (request or subscription). + fn emit_host_entry( + &self, + out: &mut String, + trait_def: &TraitDef, + method: &MethodDef, + ) -> Result<()> { + let name = method_name(&method.name); + let wc = wire_const(&trait_def.name, &method.name); + let wire_version = self.method_wire_version(method)?; + let payload = self.emit_payload(method, wire_version)?; + let has_param = !payload.arg_decl.is_empty(); + let call_args = if has_param { "ctx, request" } else { "ctx" }; + let decode_line = if has_param { + format!(" final request = {}.dec(payload);\n", payload.codec) + } else { + String::new() + }; + + match (&method.kind, &method.return_type) { + (MethodKind::Request, ReturnType::Result { ok, err }) => { + let (_, ok_codec) = self.resolve_inner(ok)?; + let (_, err_codec) = self.resolve_inner(call_error_inner(err).unwrap_or(err))?; + let response_codec = match wire_version { + Some(v) => format!("S.versioned({}, S.result({ok_codec}, {err_codec}))", v - 1), + None => format!("S.result({ok_codec}, {err_codec})"), + }; + writeln!( + out, + " RequestEntry(\n ids: W.{wc},\n handle: (ctx, payload) async {{\n{decode_line} final result = await h.{name}({call_args});\n return {response_codec}.enc(result);\n }},\n )," + ) + .unwrap(); + } + (MethodKind::Subscription, ReturnType::Subscription(item)) => { + let (_, item_codec) = self.resolve_inner(item)?; + let item_enc = match wire_version { + Some(v) => format!("S.versioned({}, {item_codec})", v - 1), + None => item_codec, + }; + let void_interrupt = match wire_version { + Some(v) => format!("S.versioned({}, S.unit).enc(S.unitValue)", v - 1), + None => "S.unit.enc(S.unitValue)".to_string(), + }; + writeln!( + out, + " SubscriptionEntry(\n ids: W.{wc},\n start: (ctx, payload, port) {{\n{decode_line} final sub = h.{name}({call_args}).listen(\n (item) => port.sendReceive({item_enc}.enc(item)),\n onError: (Object _) => port.sendInterrupt({void_interrupt}),\n onDone: () => port.sendInterrupt({void_interrupt}),\n cancelOnError: true,\n );\n return () => sub.cancel();\n }},\n )," + ) + .unwrap(); + } + (MethodKind::ResultSubscription, ReturnType::ResultSubscription { item, err }) => { + let (_, item_codec) = self.resolve_inner(item)?; + let err_inner = call_error_inner(err).unwrap_or(err); + let (err_type, err_codec) = self.resolve_inner(err_inner)?; + let item_enc = match wire_version { + Some(v) => format!("S.versioned({}, {item_codec})", v - 1), + None => item_codec, + }; + let reason_enc = match wire_version { + Some(v) => format!("S.versioned({}, {err_codec})", v - 1), + None => err_codec, + }; + writeln!( + out, + " SubscriptionEntry(\n ids: W.{wc},\n start: (ctx, payload, port) {{\n{decode_line} final sub = h.{name}({call_args}).listen(\n (item) => port.sendReceive({item_enc}.enc(item)),\n onError: (Object e) {{\n if (e is SubscriptionInterrupted) {{\n port.sendInterrupt({reason_enc}.enc(e.reason as {err_type}));\n }}\n }},\n cancelOnError: true,\n );\n return () => sub.cancel();\n }},\n )," + ) + .unwrap(); + } + (kind, ret) => bail!( + "host entry mismatch for `{}`: {:?} vs {:?}", + method.name, + kind, + ret + ), + } + Ok(()) + } + + // --- shared helpers --------------------------------------------------- + + fn services(&self) -> Result)>> { + let by_name: HashMap<&str, &TraitDef> = self + .api + .traits + .iter() + .map(|t| (t.name.as_str(), t)) + .collect(); + let mut out = Vec::new(); + for name in &self.api.public_trait_order { + let Some(trait_def) = by_name.get(name.as_str()).copied() else { + bail!("trait `{name}` in TrUApi but not extracted"); + }; + let methods = trait_def + .methods + .iter() + .filter_map(|m| match self.method_included(m) { + Ok(true) => Some(Ok(m)), + Ok(false) => None, + Err(e) => Some(Err(e)), + }) + .collect::>>()?; + if !methods.is_empty() { + out.push((trait_def, methods)); + } + } + Ok(out) + } + + fn method_included(&self, method: &MethodDef) -> Result { + let names = self.method_wrappers(method); + if names.is_empty() { + return Ok(true); + } + Ok(self.method_wire_version(method)?.is_some()) + } + + fn method_wire_version(&self, method: &MethodDef) -> Result> { + let names = self.method_wrappers(method); + if names.is_empty() { + return Ok(None); + } + let mut common: Option> = None; + for name in names { + let wrapper = self + .wrappers + .get(&name) + .ok_or_else(|| anyhow!("unknown wrapper `{name}`"))?; + let versions: Vec = wrapper + .variants + .keys() + .filter(|v| **v <= self.target_version) + .copied() + .collect(); + common = Some(match common { + Some(c) => c.into_iter().filter(|v| versions.contains(v)).collect(), + None => versions, + }); + } + Ok(common.and_then(|v| v.into_iter().max())) + } + + fn method_wrappers(&self, method: &MethodDef) -> Vec { + let mut names = Vec::new(); + for param in &method.params { + self.collect_wrappers(¶m.type_ref, &mut names); + } + match &method.return_type { + ReturnType::Result { ok, err } => { + self.collect_wrappers(ok, &mut names); + self.collect_wrappers(call_error_inner(err).unwrap_or(err), &mut names); + } + ReturnType::Subscription(item) => self.collect_wrappers(item, &mut names), + ReturnType::ResultSubscription { item, err } => { + self.collect_wrappers(item, &mut names); + self.collect_wrappers(call_error_inner(err).unwrap_or(err), &mut names); + } + } + names.sort(); + names.dedup(); + names + } + + fn collect_wrappers(&self, ty: &TypeRef, names: &mut Vec) { + match ty { + TypeRef::Named { name, args } => { + if args.is_empty() && self.wrappers.contains_key(name) { + names.push(name.clone()); + } + for arg in args { + self.collect_wrappers(arg, names); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + self.collect_wrappers(inner, names) + } + TypeRef::Tuple(items) => items.iter().for_each(|i| self.collect_wrappers(i, names)), + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } + } + + fn select<'w>(&self, wrapper: &'w Wrapper) -> Result<(u32, &'w WrapKind)> { + wrapper + .variants + .iter() + .filter(|(v, _)| **v <= self.target_version) + .max_by_key(|(v, _)| **v) + .map(|(v, k)| (*v, k)) + .ok_or_else(|| anyhow!("versioned wrapper has no variant <= target")) + } + + // --- type & codec lowering ------------------------------------------- + + fn dart_type(&self, ty: &TypeRef) -> Result { + match ty { + TypeRef::Primitive(p) => Ok(dart_primitive_type(p)?.to_string()), + TypeRef::Named { name, args } => { + if args.is_empty() + && let Some(wrapper) = self.wrappers.get(name) + { + let (_, kind) = self.select(wrapper)?; + return Ok(match kind { + WrapKind::Unit => "S.Unit".to_string(), + WrapKind::Tuple(inner) => self.dart_type(inner)?, + }); + } + let base = public_name(name); + if args.is_empty() { + Ok(base) + } else { + let args = args + .iter() + .map(|a| self.dart_type(a)) + .collect::>>()? + .join(", "); + Ok(format!("{base}<{args}>")) + } + } + TypeRef::Vec(inner) | TypeRef::Array(inner, _) => match inner.as_ref() { + TypeRef::Primitive(p) if p == "u8" => Ok("Uint8List".to_string()), + _ => Ok(format!("List<{}>", self.dart_type(inner)?)), + }, + TypeRef::Option(inner) => Ok(format!("{}?", self.dart_type(inner)?)), + TypeRef::Tuple(items) => match items.len() { + 0 => Ok("S.Unit".to_string()), + 1 => self.dart_type(&items[0]), + _ => { + let parts = items + .iter() + .map(|i| self.dart_type(i)) + .collect::>>()? + .join(", "); + Ok(format!("({parts})")) + } + }, + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("S.Unit".to_string()), + } + } + + fn codec_expr(&self, ty: &TypeRef) -> Result { + match ty { + TypeRef::Primitive(p) => Ok(dart_primitive_codec(p)?.to_string()), + TypeRef::Named { name, args } => { + if args.is_empty() && self.wrappers.contains_key(name) { + bail!("nested versioned wrapper `{name}` in codec position is unsupported"); + } + let codec = codec_name(&public_name(name)); + if args.is_empty() { + Ok(codec) + } else { + let args = args + .iter() + .map(|a| self.codec_expr(a)) + .collect::>>()? + .join(", "); + Ok(format!("{codec}({args})")) + } + } + TypeRef::Vec(inner) => match inner.as_ref() { + TypeRef::Primitive(p) if p == "u8" => Ok("S.bytes".to_string()), + _ => Ok(format!("S.vector({})", self.codec_expr(inner)?)), + }, + TypeRef::Array(inner, len) => match inner.as_ref() { + TypeRef::Primitive(p) if p == "u8" => Ok(format!("S.bytesFixed({len})")), + _ => Ok(format!("S.vectorFixed({}, {len})", self.codec_expr(inner)?)), + }, + TypeRef::Option(inner) => Ok(format!("S.option({})", self.codec_expr(inner)?)), + TypeRef::Tuple(items) => match items.len() { + 0 => Ok("S.unit".to_string()), + 1 => self.codec_expr(&items[0]), + n @ 2..=4 => { + let parts = items + .iter() + .map(|i| self.codec_expr(i)) + .collect::>>()? + .join(", "); + Ok(format!("S.tuple{n}({parts})")) + } + _ => bail!("tuples with more than 4 elements are not supported"), + }, + TypeRef::Generic(name) => Ok(codec_param(name)), + TypeRef::Unit => Ok("S.unit".to_string()), + } + } +} + +struct Payload { + arg_decl: String, + codec: String, +} + +// --- free helpers --------------------------------------------------------- + +fn detect_wrapper(ty: &TypeDef) -> Option { + if !ty.generic_params.is_empty() { + return None; + } + let TypeDefKind::Enum(variants) = &ty.kind else { + return None; + }; + if variants.is_empty() { + return None; + } + let mut map = BTreeMap::new(); + for variant in variants { + let version = variant_version(&variant.name)?; + let kind = match &variant.fields { + VariantFields::Unit => WrapKind::Unit, + VariantFields::Unnamed(types) if types.len() == 1 => WrapKind::Tuple(types[0].clone()), + _ => return None, + }; + map.insert(version, kind); + } + Some(Wrapper { variants: map }) +} + +fn variant_version(name: &str) -> Option { + let rest = name.strip_prefix('V')?; + if rest.is_empty() { + return None; + } + rest.parse().ok() +} + +fn version_prefixed_type(name: &str) -> Option<(u32, &str)> { + let rest = name.strip_prefix('V')?; + if rest.len() < 3 { + return None; + } + let (version, base) = rest.split_at(2); + if base.is_empty() || !version.chars().all(|c| c.is_ascii_digit()) { + return None; + } + Some((version.parse().ok()?, base)) +} + +fn public_name(name: &str) -> String { + version_prefixed_type(name) + .map(|(_, base)| base.to_string()) + .unwrap_or_else(|| name.to_string()) +} + +fn call_error_inner(ty: &TypeRef) -> Option<&TypeRef> { + match ty { + TypeRef::Named { name, args } if name == "CallError" && args.len() == 1 => Some(&args[0]), + _ => None, + } +} + +fn is_unit_only_enum(ty: &TypeDef) -> bool { + detect_wrapper(ty).is_none() + && matches!(&ty.kind, TypeDefKind::Enum(variants) + if !variants.is_empty() + && variants.iter().all(|v| matches!(v.fields, VariantFields::Unit))) +} + +fn dart_primitive_type(p: &str) -> Result<&'static str> { + Ok(match p { + "bool" => "bool", + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "int", + "u64" | "u128" | "i64" | "i128" => "BigInt", + "compact" => "BigInt", + "optionBool" => "bool?", + "str" => "String", + "f32" | "f64" => "double", + other => bail!("unsupported primitive `{other}` in Dart type generation"), + }) +} + +fn dart_primitive_codec(p: &str) -> Result<&'static str> { + Ok(match p { + "bool" => "S.boolCodec", + "u8" => "S.u8", + "u16" => "S.u16", + "u32" => "S.u32", + "u64" => "S.u64", + "u128" => "S.u128", + "i8" => "S.i8", + "i16" => "S.i16", + "i32" => "S.i32", + "i64" => "S.i64", + "i128" => "S.i128", + "compact" => "S.compact", + "optionBool" => "S.optionBool", + "str" => "S.str", + other => bail!("unsupported primitive `{other}` in Dart codec generation"), + }) +} + +fn codec_name(type_name: &str) -> String { + format!("{}Codec", type_name.to_case(Case::Camel)) +} + +fn codec_param(generic: &str) -> String { + format!("{}Codec", generic.to_case(Case::Camel)) +} + +fn field_name(name: &str) -> String { + sanitize_ident(&name.to_case(Case::Camel)) +} + +fn method_name(name: &str) -> String { + sanitize_ident(&strip_method_prefix(name).to_case(Case::Camel)) +} + +fn enum_value_name(name: &str) -> String { + sanitize_ident(&name.to_case(Case::Camel)) +} + +fn service_field(trait_name: &str) -> String { + sanitize_ident(&trait_name.to_case(Case::Camel)) +} + +fn wire_const(trait_name: &str, method_name: &str) -> String { + format!("{trait_name}_{method_name}").to_case(Case::Camel) +} + +fn strip_method_prefix(name: &str) -> String { + for prefix in ["host_", "remote_", "product_"] { + if let Some(rest) = name.strip_prefix(prefix) { + return rest.to_string(); + } + } + name.to_string() +} + +fn generic_decl(params: &[String]) -> String { + if params.is_empty() { + String::new() + } else { + format!("<{}>", params.join(", ")) + } +} + +/// `` → `` reused as type-argument list (identity here, but kept +/// distinct for clarity at call sites). +fn type_args(generics: &str) -> String { + generics.to_string() +} + +/// Drop the generic parameter list from a type name to get its constructor +/// reference (e.g. `Component

` → `Component`). +fn strip_type_args(type_name: &str) -> String { + match type_name.find('<') { + Some(idx) => type_name[..idx].to_string(), + None => type_name.to_string(), + } +} + +fn sanitize_ident(name: &str) -> String { + const RESERVED: &[&str] = &[ + "abstract", + "as", + "assert", + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "covariant", + "default", + "deferred", + "do", + "dynamic", + "else", + "enum", + "export", + "extends", + "extension", + "external", + "factory", + "false", + "final", + "finally", + "for", + "Function", + "get", + "hide", + "if", + "implements", + "import", + "in", + "interface", + "is", + "late", + "library", + "mixin", + "new", + "null", + "on", + "operator", + "part", + "required", + "rethrow", + "return", + "set", + "show", + "static", + "super", + "switch", + "sync", + "this", + "throw", + "true", + "try", + "typedef", + "var", + "void", + "while", + "with", + "yield", + ]; + if name.is_empty() { + return "field".to_string(); + } + if RESERVED.contains(&name) { + format!("{name}_") + } else { + name.to_string() + } +} + +/// Emit value-equality (`==`/`hashCode`) and `toString` for a generated class. +fn write_value_equality(out: &mut String, name: &str, generics: &str, fields: &[String]) { + let type_with_generics = format!("{name}{}", type_args(generics)); + if fields.is_empty() { + writeln!(out, " @override").unwrap(); + writeln!( + out, + " bool operator ==(Object other) => other is {type_with_generics};" + ) + .unwrap(); + writeln!(out, " @override").unwrap(); + writeln!(out, " int get hashCode => {};", hash_seed(name)).unwrap(); + writeln!(out, " @override").unwrap(); + writeln!(out, " String toString() => '{name}()';").unwrap(); + return; + } + let cmp = fields + .iter() + .map(|f| format!("other.{f} == {f}")) + .collect::>() + .join(" && "); + writeln!(out, " @override").unwrap(); + writeln!( + out, + " bool operator ==(Object other) => other is {type_with_generics} && {cmp};" + ) + .unwrap(); + let hash_args = fields.join(", "); + writeln!(out, " @override").unwrap(); + if fields.len() == 1 { + writeln!(out, " int get hashCode => {hash_args}.hashCode;").unwrap(); + } else { + writeln!(out, " int get hashCode => Object.hash({hash_args});").unwrap(); + } + let to_str = fields + .iter() + .map(|f| format!("{f}: ${f}")) + .collect::>() + .join(", "); + writeln!(out, " @override").unwrap(); + writeln!(out, " String toString() => '{name}({to_str})';").unwrap(); +} + +fn hash_seed(name: &str) -> String { + // Stable nonzero seed for fieldless classes. + format!("{}", name.len() as u32 + 1) +} + +/// Forward a Rust doc comment as Dart `///` lines, dropping ` ```ts ` example +/// blocks (which target the TypeScript playground). +fn write_doc(out: &mut String, indent: &str, docs: Option<&str>) { + let Some(text) = docs else { + return; + }; + let mut in_ts = false; + let mut lines: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed == "```ts" { + in_ts = true; + continue; + } + if in_ts { + if trimmed == "```" { + in_ts = false; + } + continue; + } + lines.push( + line.strip_prefix(' ') + .unwrap_or(line) + .trim_end() + .to_string(), + ); + } + while lines.last().map(|l| l.trim().is_empty()).unwrap_or(false) { + lines.pop(); + } + while lines.first().map(|l| l.trim().is_empty()).unwrap_or(false) { + lines.remove(0); + } + for line in lines { + if line.is_empty() { + writeln!(out, "{indent}///").unwrap(); + } else { + writeln!(out, "{indent}/// {line}").unwrap(); + } + } +} diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index b4d118fe..8bdfd2dc 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use clap::Parser; use std::str::FromStr; +mod dart; mod rustdoc; mod ts; @@ -38,6 +39,25 @@ struct Cli { #[arg(long, default_value_t = 1)] codec_version: u8, + /// Output directory for the shared Dart types + wire table (optional). When + /// set, writes `types.dart`, `wire_table.dart`, and `index.dart`. The Dart + /// package is host-only (no client facade); pair with `--dart-host-output`. + #[arg(long)] + dart_output: Option, + + /// Output directory for the generated Dart host dispatcher (optional). + /// Writes `host.dart` (typed handler interfaces + `createTruapiServer`). + /// Reuses the client's `types.dart`, so point it at the same generated dir. + #[arg(long)] + dart_host_output: Option, + + /// Output FILE for a one-shot, editable Dart host scaffold (optional). + /// Writes a `ScaffoldHostHandlers` implementing every service with + /// `throw UnimplementedError(...)`. Not part of the regular pipeline; run + /// it once to bootstrap a host, then edit the file. + #[arg(long)] + dart_host_scaffold_output: Option, + /// Output directory for generated playground metadata (optional). #[arg(long)] playground_output: Option, @@ -108,6 +128,21 @@ fn main() -> Result<()> { println!( "Generated TypeScript client for TrUAPI V{client_version} codec {codec_version} in {output}", ); + if let Some(path) = &cli.dart_output { + dart::generate(&api, path, client_version) + .with_context(|| format!("writing Dart types to {path}"))?; + println!("Generated Dart types + wire table in {path}"); + } + if let Some(path) = &cli.dart_host_output { + dart::generate_host(&api, path, client_version) + .with_context(|| format!("writing Dart host to {path}"))?; + println!("Generated Dart host in {path}"); + } + if let Some(path) = &cli.dart_host_scaffold_output { + dart::generate_host_scaffold(&api, path, client_version) + .with_context(|| format!("writing Dart host scaffold to {path}"))?; + println!("Generated Dart host scaffold at {path}"); + } if let Some(path) = &cli.playground_output { ts::generate_playground_services(&api, path, client_version, cli.strip_examples) .with_context(|| format!("writing playground metadata to {path}"))?; diff --git a/rust/crates/truapi/examples/wire_vectors.rs b/rust/crates/truapi/examples/wire_vectors.rs new file mode 100644 index 00000000..f1b2218e --- /dev/null +++ b/rust/crates/truapi/examples/wire_vectors.rs @@ -0,0 +1,123 @@ +//! Golden SCALE wire-vector generator for cross-language codec conformance. +//! +//! Encodes representative protocol values with `parity_scale_codec` (the +//! canonical Rust encoder) and writes `{ name: hex }` JSON. The Dart client's +//! `wire_vectors_test.dart` loads the file and asserts its generated codecs +//! produce byte-identical output, so the Rust crate stays the source of truth. +//! +//! Run from the repo root: +//! ```bash +//! cargo run -p truapi --example wire_vectors -- dart/truapi/test/wire_vectors.json +//! ``` + +use parity_scale_codec::{Compact, Encode, OptionBool}; +use truapi::v01; +use truapi::versioned; + +// Sequential `push` reads clearly for a flat list of independent vectors. +#[allow(clippy::vec_init_then_push)] +fn main() { + let mut vectors: Vec<(&str, Vec)> = Vec::new(); + + vectors.push(( + "product_account_id", + v01::ProductAccountId { + dot_ns_identifier: "my-product.dot".to_string(), + derivation_index: 7, + } + .encode(), + )); + + vectors.push(( + "product_account", + v01::ProductAccount { + public_key: vec![1, 2, 3, 4], + } + .encode(), + )); + + vectors.push(( + "legacy_account_some", + v01::LegacyAccount { + public_key: vec![0xaa, 0xbb], + name: Some("Wallet".to_string()), + } + .encode(), + )); + + vectors.push(( + "legacy_account_none", + v01::LegacyAccount { + public_key: vec![], + name: None, + } + .encode(), + )); + + vectors.push(( + "handshake_request", + v01::HostHandshakeRequest { codec_version: 1 }.encode(), + )); + + vectors.push(( + "handshake_error_unsupported", + v01::HostHandshakeError::UnsupportedProtocolVersion.encode(), + )); + + vectors.push(( + "typography_body_large", + v01::TypographyStyle::BodyLargeRegular.encode(), + )); + + vectors.push(( + "dimensions", + v01::Dimensions { + top: Compact(10), + end: Compact(20), + bottom: None, + start: Some(Compact(5)), + } + .encode(), + )); + + vectors.push(( + "button_props", + v01::ButtonProps { + text: "Go".to_string(), + variant: Some(v01::ButtonVariant::Primary), + enabled: OptionBool(Some(true)), + loading: OptionBool(None), + click_action: Some("go".to_string()), + } + .encode(), + )); + + vectors.push(( + "account_get_alias_response", + v01::HostAccountGetAliasResponse { + context: [7u8; 32], + alias: vec![9, 9], + } + .encode(), + )); + + // Versioned envelope: V1 wrapper writes discriminant byte 0x00 then inner. + vectors.push(( + "versioned_handshake_request_v1", + versioned::system::HostHandshakeRequest::V1(v01::HostHandshakeRequest { codec_version: 1 }) + .encode(), + )); + + let entries = vectors + .iter() + .map(|(name, bytes)| format!(" \"{name}\": \"{}\"", hex::encode(bytes))) + .collect::>() + .join(",\n"); + let json = format!("{{\n{entries}\n}}\n"); + + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "dart/truapi/test/wire_vectors.json".to_string()); + std::fs::write(&path, json).expect("write wire vectors"); + eprintln!("Wrote {} vectors to {path}", vectors.len()); +} diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 8584b0c4..c7a9bdd7 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -29,6 +29,8 @@ cargo run -p truapi-codegen -- \ --client-examples-output playground/test/generated/examples \ --host-output js/packages/truapi-host/src/generated \ --explorer-output js/packages/truapi/src/explorer \ + --dart-output dart/truapi/lib/src/generated \ + --dart-host-output dart/truapi/lib/src/generated \ --codec-version 1 node scripts/regen-explorer-versions.mjs @@ -40,6 +42,11 @@ npm exec --yes -- prettier --write \ "playground/test/generated/examples/**/*.ts" \ "js/packages/truapi-host/src/generated/**/*.ts" +# Format the generated Dart client when a Dart SDK is available (optional). +if command -v dart >/dev/null 2>&1; then + dart format dart/truapi/lib/src/generated >/dev/null +fi + # Rebuild dist/ so downstream consumers (in particular the playground, # which picks up @parity/truapi via yarn 1.x file: snapshot) see the # regenerated bindings without a separate npm run build step. @@ -60,6 +67,7 @@ if [ "${TRUAPI_SKIP_PACKAGE_BUILD:-0}" != "1" ]; then npm run build --prefix js/packages/truapi-host fi +echo "Generated Dart client at dart/truapi/lib/src/generated/" echo "Generated client at js/packages/truapi/src/generated/" echo "Generated playground metadata at js/packages/truapi/src/playground/codegen/" echo "Generated client examples at playground/test/generated/examples/" diff --git a/scripts/sync-cargo-version.mjs b/scripts/sync-cargo-version.mjs index fb495dca..50d4af8f 100755 --- a/scripts/sync-cargo-version.mjs +++ b/scripts/sync-cargo-version.mjs @@ -6,6 +6,7 @@ import { dirname, resolve } from "node:path"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const pkgPath = resolve(repoRoot, "js/packages/truapi/package.json"); const cargoPath = resolve(repoRoot, "rust/crates/truapi/Cargo.toml"); +const pubspecPath = resolve(repoRoot, "dart/truapi/pubspec.yaml"); const { version } = JSON.parse(readFileSync(pkgPath, "utf8")); if (typeof version !== "string" || version.length === 0) { @@ -25,8 +26,26 @@ if (!versionLine.test(cargo)) { const next = cargo.replace(versionLine, `version = "${version}"`); if (next === cargo) { console.log(`sync-cargo-version: already at ${version}`); - process.exit(0); +} else { + writeFileSync(cargoPath, next); + console.log( + `sync-cargo-version: bumped rust/crates/truapi/Cargo.toml to ${version}`, + ); } -writeFileSync(cargoPath, next); -console.log(`sync-cargo-version: bumped rust/crates/truapi/Cargo.toml to ${version}`); +// Keep the Dart package version in lockstep too. +const pubspec = readFileSync(pubspecPath, "utf8"); +const pubspecVersionLine = /^version: .*$/m; +if (!pubspecVersionLine.test(pubspec)) { + console.error( + `sync-cargo-version: could not find a \`version:\` line in ${pubspecPath}`, + ); + process.exit(1); +} +const nextPubspec = pubspec.replace(pubspecVersionLine, `version: ${version}`); +if (nextPubspec === pubspec) { + console.log(`sync-cargo-version: dart/truapi already at ${version}`); +} else { + writeFileSync(pubspecPath, nextPubspec); + console.log(`sync-cargo-version: bumped dart/truapi/pubspec.yaml to ${version}`); +}