Skip to content

feat(truapi-provider): native and wasm ChainProvider with WebSocket and embedded smoldot backends - #276

Open
BigTava wants to merge 50 commits into
mainfrom
tiago-truapi-provider
Open

feat(truapi-provider): native and wasm ChainProvider with WebSocket and embedded smoldot backends#276
BigTava wants to merge 50 commits into
mainfrom
tiago-truapi-provider

Conversation

@BigTava

@BigTava BigTava commented Jul 13, 2026

Copy link
Copy Markdown

Summary

  • Adds the truapi-provider crate: a native and wasm32 implementation of truapi_platform::ChainProvider whose per-chain backend is a remote WebSocket JSON-RPC node or an embedded smoldot light client.
  • Serves one embedded smoldot light client per provider so host-internal flows and product connections share sync, peers, and warm state, while each connection keeps an isolated JSON-RPC pipe.

@BigTava
BigTava requested a review from a team July 13, 2026 20:40
@BigTava
BigTava marked this pull request as draft July 13, 2026 20:45
BigTava added 14 commits July 13, 2026 21:46
…d-client locks, panic-free light-client builder, and Apache-2.0 attribution for the vendored platform
… GenericError, and require at least one backend feature
…ed platform client identity, a WS disconnect test, and doc/dep-pin notes
@pgherveou

pgherveou commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

For reference, the design so far has been

  • the core builds, signs, and submits the Transaction, routing chain traffic through
  • the host's own the JSON-RPC connection

Owning the connection end to end make sense as well, but might require some research as well to understand the trade-off, especially on native platform like iOS where a networking stack built on top of the Native SDK might
be more efficient.

Anyway that should not prevent you from testing this PR out.
I would make also the required changes to the dotli submodule, (maybe merge main first and branch of the submodule in hosts/dotli from the current sha).

The core should probably now own the networking settings preference that currently live in dotli

Comment thread rust/crates/truapi-provider/src/config.rs Outdated
/// Warm-start database blob previously returned by the
/// `chainHead_unstable_finalizedDatabase` JSON-RPC function. Invalid
/// blobs are silently ignored by smoldot.
database_content: Option<String>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that a String?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this returned as a String in smoldot? Happy to change it.

Comment thread rust/crates/truapi-provider/src/config.rs Outdated
@BigTava
BigTava marked this pull request as ready for review August 5, 2026 10:34
@BigTava BigTava self-assigned this Aug 7, 2026
Comment on lines +186 to +189
return false;
}
if (theirs.length === 0) {
console.log(" ? " + peer + " served no authorities, confirming the block only");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this fails open. If the peer cant serve the authorities we treat that as agreement, so the corroboration passes without ever comparing them. And state_call is exactly what public endpoints usually refuse or cant answer on a pruned block, so thats the normal path not the rare one.

I tested it. A peer that serves the real parent hash but refuses state_call will accept an all zero authority set. The same peer serving its real authorities rejects it correctly, so the check works when it gets an answer and just skips when it doesnt.

Nothing validates the checkpoint at runtime, we hand it straight to add_chain, so this decides the authority set every light client warp syncs from. Its testnet only right now so not urgent, but can we make an unavailable state_call a hard failure instead of counting it as agreement?

Comment on lines +213 to +228
blockHash = await rpc(peer, "chain_getBlockHash", [number]);
if (!blockHash) {
console.log(" ? " + peer + " has not reached block " + number);
continue;
}
peerParent = (await rpc(peer, "chain_getHeader", [blockHash])).parentHash;
} catch (error) {
console.log(" ? " + peer + " did not answer (" + (error.message || error) + ")");
continue;
}
if (peerParent.toLowerCase() !== parentHash.toLowerCase()) {
console.log(" x " + peer + " disagrees at block " + number);
console.log(" checkpoint parent " + parentHash);
console.log(" peer parent " + peerParent);
process.exit(1);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we only ever check the parent hash here, never the checkpoint header itself. So the state root, extrinsics root and digest are never compared against the peer, which means the checkpoint's own block hash is never verified. A parent hash is shared by every sibling at that height so its cheap to reproduce.

To test this I did it with the real header from networks/paseo.json and just overwrote the state root. The peer agreed on the block, served all 56 real authorities, and it still came back corroborated. Output was identical to the unmodified run.

This also weakens the authority check above it, since the block hash we pass to state_call comes from the peer rather than from our checkpoint. So if the header is forged with the same parent were comparing authorities at a different block than the one were pinning.

Can we hash the checkpoint header and compare it to chain_getBlockHash for that number?

Comment on lines +75 to +80
/// JSON-RPC queue budgets for chains added by this backend. The provider is a
/// trusted in-process client, so the pending cap is generous (smoldot's docs
/// sanction up to `u32::MAX` for trusted callers); an overflow is still handled
/// gracefully by synthesizing an error response rather than hanging the caller.
const MAX_PENDING_REQUESTS: u32 = 1024;
const MAX_SUBSCRIPTIONS: u32 = 1024;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up, these two constants are inert. smoldot stores them and never reads them, and the request channel is async_channel::unbounded() with their own // TODO: capacity? next to it. So the comment here about overflow being handled gracefully, and the synthetic error frame below it, are written against behaviour that doesnt exist.

I measured it. A product that calls send() in a loop and never drains responses() got 1.3 million requests queued in 3 seconds at 470MB rss, zero rejections.

I checked smoldot main too and its the same at 2.0.0, so a version bump doesnt help. ws.rs already does this properly with a bounded buffer and a test. Can we bound the light path the same way? I'll open an issue upstream for the docs.

Cut the "no test for queue full" line, the bounded(16) asymmetry, and the wasm/FFI consequence — the 470MB number carries that on its own.

Happy to share how I measured this if youd like.

Comment on lines +165 to +184
/// Yield every inbound text frame; the stream ends on transport error or EOF,
/// which is the disconnect signal consumers rely on.
fn response_stream<R: TransportReceiverT + Send>(receiver: R) -> impl Stream<Item = String> + Send {
stream::unfold(receiver, |mut receiver| async move {
loop {
match receiver.receive().await {
Ok(ReceivedMessage::Text(text)) => return Some((text, receiver)),
Ok(ReceivedMessage::Bytes(bytes)) => match String::from_utf8(bytes) {
Ok(text) => return Some((text, receiver)),
Err(_) => tracing::warn!("dropping non-UTF-8 binary WebSocket frame"),
},
Ok(ReceivedMessage::Pong) => {}
Err(err) => {
tracing::debug!("WebSocket receive ended: {err}");
return None;
}
}
}
})
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the stream ending is the disconnect signal consumers rely on, but on a half open socket it never ends. Theres no keepalive and no read timeout, and we handle Pong while nothing ever sends a Ping. I have a repro, the stream never terminates and the request downstream stays pending past 10s. Can we add a keepalive ping and a read timeout here?

- name: Create Pull Request
if: steps.diff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a PAT or app token? With the default GITHUB_TOKEN github wont trigger workflows on the PR, so every_catalog_genesis_hash_matches_its_bundled_spec never runs on it.

default: ""
# Push a `truapi-provider-dev` or `truapi-provider-dev-<suffix>` tag to publish
# a dev snapshot; the part after `truapi-provider-dev-` becomes the dist-tag suffix.
push:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this gated the same way release.yml is? Right now any truapi-provider-dev* tag push publishes to npm with no needs:?

responses.next().await
}

/// Close the connection; pending `nextResponse()` calls resolve to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth documenting that calling this isnt optional.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants