The node protocol
How the desktop app and the CLI talk to a running daemon — length-prefixed JSON frames, an HMAC nonce-challenge handshake with capability bound into the signature, and a wire vocabulary that mirrors the core without depending on it.
vike-tradehub-client is the light crate a thin client links to reach a headless node. It carries
no server and no DataFusion; what it defines is the wire schema (proto.rs), the auth primitive
bound to this service (auth.rs), and standalone mirrors of the snapshot and command vocabulary
(wire.rs). The server half lives in the daemon.
Framing
A frame is [u32 len][len bytes of UTF-8 JSON], big-endian, and the codec is re-exported from
vike_datahub_client::proto rather than re-implemented: both localhost services — the
data service and the live-core node — speak the identical
frame with the same pre-allocation guard, MAX_FRAME_LEN. Two primitives matter to a server.
read_frame_raw returns the framed body without decoding, so a well-framed body that fails to
decode into a known request is answered with Response::Error and the connection survives — a
decode error is a bad request, not a bad connection. read_frame_raw_capped reads pre-auth frames
under a ceiling far below MAX_FRAME_LEN, so an unauthenticated peer cannot make the node allocate
from a four-byte length prefix.
The handshake
The core is a headless process that accepts orders, so an unauthenticated peer must never reach the command path. The handshake is a nonce-challenge HMAC:
- Client →
Request::Hello { proto_version }. - Server →
Response::Welcome { proto_version, nonce, features }—nonceis 32 fresh random bytes for this connection. - Client →
Request::Auth { scope, mac }, where the tag isauth::sign(key_for(scope), &nonce, proto_version, scope). - Server recomputes and compares in constant time:
AuthOk { scope }, orAuthDenied { reason }with a reason that never leaks key material. Only afterAuthOkmay the client subscribe, ask for a snapshot, or — withScope::Control— send a command.
The signed message is b"vike-tradehub-auth\0" (a 19-byte, NUL-terminated domain separator), the
protocol version as a big-endian u32, one scope byte (Observe 0x00, Control 0x01), and the
nonce; the tag is HMAC-SHA256. Each field buys a property. The separator makes the preimage disjoint
from every other HMAC the workspace signs — the venue request signers, and the data service's own
vike-datahub-auth\0 — so a tag captured off one surface is useless at the other. The scope byte
binds capability into the signature. The nonce binds the tag to one connection, so a captured
transcript cannot be replayed. The version in the message means a client and server that disagree
on it can never produce a matching tag. Verification uses the hmac crate's constant-time
verify_slice, never a byte-wise ==.
The two scopes sign under different keys. Scope::Observe is snapshots and subscriptions;
Scope::Control additionally admits commands; and because an observe-only key cannot produce a
valid control tag, capability is cryptographic rather than a claim the server trusts. The keys are
VIKE_TRADEHUB_OBSERVE_KEY and VIKE_TRADEHUB_CONTROL_KEY, read by auth::from_vars from the
credential-store map the binary already loaded — not the process environment, where a store-defined
key is invisible. Neither present means nothing to authenticate, the same credential-is-the-gate
idiom the venues use; an absent control key brings the node up observe-only, while an absent
observe key means the daemon starts no node server at all. NodeKeys redacts its Debug, and
NodeKeys::key_id yields a stable nk- plus sixteen hex characters fingerprint an audit record
can name a credential by.
An empty key is not a closed gate
The NodeKeys::key_for doc records a claim that stood until 2026-08-29 — that verifying against an
absent key always fails. It is false: HMAC accepts a zero-length key, so a peer signing with the
empty key produces a tag that verifies. What closes the gate is NodeKeys::has, consulted before
the key; both servers refuse an unkeyed scope without reaching verify at all. The handshake is
also plaintext: it authenticates the connection, not each frame, and confidentiality still comes
from the tunnel or VPN in front of it.
Versions and features
NODE_PROTO_VERSION is 4 — v2 added bounded bar tails to the snapshot, v3 the server-authoritative
Preview dry-run, v4 an optional rationale beside a command. Because the version is folded into the
signed tag, a bump fails the handshake against every running node, so every verb since v4 rides
Welcome.features instead: strategy-verbs, settings-show, settings-write,
strategy-mount-verbs, and the one value-carrying entry, datahub=<addr>, which advertises the
data service's dial address so an operator configures one address and the client dials the data
plane itself — advertisement, never proxying. A client must check for the feature before sending the
verb, so against an older server nothing goes on the wire and the caller gets "this node predates
settings-write" rather than an undecodable-request error.
The vocabulary
Requests after auth: Subscribe { topics } over Topic::{Orders, Positions, Events, All},
Snapshot, Command { cmd, reason }, Preview(cmd), Ping, StrategyStatus, SettingsShow.
Responses: SnapshotFrame, Ack { coid }, Preview { accepted, reason }, Error, Pong,
StrategyStatus, SettingsShow, and SettingsWritten { restart_required } — the one accepted
command that is not an Ack, because nothing entered the core; false means the node hot-applied
the value, and policy keys never are. A command's reason is remote free text landing in the audit
trail: the node sanitizes it, and it never reaches the order request, the core fold, the journal or
a venue.
wire.rs re-declares the field shapes of the core's snapshot and command types rather than
depending on vike-core, for two reasons its header gives: weight, since the live-runtime tree
would otherwise ride into the thin client, and a stable contract, since an internal refactor of
CoreSnapshot cannot then silently change the wire. WireCommand is deliberately the
session-relevant subset — Submit, Cancel, Modify, MassCancel, Flatten, MarketExit,
SetTradingState, UpdateParams, MountStrategy, UnmountStrategy, SetSetting — and not the
reconcile and journal plumbing (ApplySnapshot, ReconcileReports, ConfirmRecon), which is never
a remote client's business. UpdateParams and MountStrategy carry their parameter tables as the
core's own serde JSON rather than a hand mirror that would drift within a release. The server lowers
each variant into the core's real command at its edge and hands it to the
control boundary's CommandSink.
Two handles wrap all of this. RemoteCoreHandle authenticates as Observe, subscribes, and
exposes the latest pushed snapshot off a local arc-swap cell — the read side
observe mode runs on. RemoteControlHandle authenticates as Control,
never subscribes, and offers a non-blocking try_command over a worker thread whose ticket resolves
to that command's outcome. Both run the one crate-private handshake; a version mismatch names both
numbers, and an AuthDenied surfaces as PermissionDenied carrying the server's reason.
The live data seam
DataClient and LiveDataSink — the live mirror of the history store that every venue feed implements, where subscribing returns nothing and every datum arrives through a sink chosen at construction.
Options
The venue-free options core — a normalized chain model, Black–Scholes price and greeks with every transcendental from one portable library, implied vol by bisection, second-order greeks, realized-vol estimators and multi-leg basket analytics.