Skip to content

truapi-provider: UniFFI boundary gaps #463

Description

@decrypto21

truapi-provider's UniFFI boundary has gaps that are not reachable by a shipped consumer yet: there is no @parity/ios-provider release, the release allowlist excludes it, the Maven publication is local-only, and the npm builds are wasm where mod ffi is compiled out.

Android has no working checksum guard

Generated Kotlin puts the checksum comparisons in object IntegrityCheckingUniffiLib and registers the callback vtable in a separate object UniffiLib. Kotlin object init is lazy, and the only reference to the first is uniffiEnsureInitialized(), which nothing calls. iOS runs the equivalent guard from makeRustCall before the vtable is installed.

Consequence: a stale .so with fresh bindings is undetected on Android. Callback arity can change without the C symbol name changing, so the linker does not catch it either, and the result is a wrong-slot RustBuffer read. The Android README now says the guard is iOS-only; the guard itself is generated, so the fix is upstream.

  • confirm against current uniffi, file upstream

A ChainCloseReason variant addition is not detected at runtime

A uniffi enum's type id comes from its module path and name, not its variants, so adding a variant does not move uniffi_truapi_provider_checksum_method_chainmessagelistener_on_closed. Old bindings pass the integrity check, then fail in FfiConverterTypeChainCloseReason.read with unexpectedEnumCase, which becomes ChainProviderError::Listener and is only logged. The host's on_closed never fires.

Both READMEs now say the @unknown default / else guidance is source compatibility only. What is still open is the process.

  • decide if a variant addition requires a coordinated bindings-and-binary release

No Kotlin gate for the provider

make android-check covers :truapi-host only, and no workflow invokes Gradle for :truapi-provider. A naive gate would pass vacuously: build.gradle.kts uses java.srcDirs("src/main/kotlin") while make provider-kotlin writes to the gitignored src/main/kotlin/generated/, so the module compiles zero files without a prior generation.

  • add provider-android-check that generates then compiles, failing on an empty source set

tracing is compiled out of the shipped native artifact

mod logging is #[cfg(all(feature = "js", target_arch = "wasm32"))] and provider builds use --no-default-features --features uniffi, so no subscriber can be installed and warn! fields are never formatted. Every diagnostic in ffi.rs is invisible on device. The unbounded reason strings passed to warn! are also one set_global_default away from being live, and truapi-server calls it at native host boot.

  • decide whether the native provider emits logs at all, and align the code with that

Uncapped resources

The bound on a close reason is 256 Unicode scalar values, which both READMEs now state along with what that means per platform. It has no byte ceiling, so a reason can still reach 1024 bytes.

No cap on concurrent connections. Each costs a pump-thread stack (2.0 MiB measured, Darwin arm64), an add_chain, a 1024-slot frame channel, and about 1.1 MiB of transient chain-spec parse per connect. Frames are capped per connection, not globally, and response frames have no size cap here.

  • decide whether the reason contract should be bytes rather than scalars
  • decide whether a connection cap belongs here or in the host

The StreamEnded invariant is not pinned

ChainCloseReason::StreamEnded's doc asserts that every connection this crate hands a host runs on the embedded light client, whose stream ends only when the connection is closed. True today: responses() is a select over smoldot's stream and an error channel closed only by close(), and the FFI has one constructor whose builder registers no chains, so resolution always falls to the catalog.

Nothing enforces it. JsonRpcConnection::responses promises nothing, ws.rs documents the opposite behaviour, and no test or cfg prevents a ws-backed source becoming FFI-reachable.

  • pin it at LightConnection or trait level, or test that a non-light source cannot reach the FFI

Smaller

  • send on a closed connection returns early with no synthetic error frame, so a consumer correlating by id waits forever. LightConnection::refuse exists for this case.
  • ChainCloseReason::ListenerFailed echoes the ChainProviderError a listener threw, so BadGenesis surfaces as "genesis hash must be 32 bytes" in a close reason.
  • The battery's signing and pairing phases need a personhood-ring member, and running them rewrites explorer/diagnosis-reports/spa/signing-host-cli.md with failure rows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions