0. What Pulse is
Pulse turns decoded Solana shreds into a live transaction feed over QUIC. You subscribe with account or program filters and receive either transaction signatures (sig-first) or decoded transaction bodies (full-tx). It is a ThorNode product; the target and token come from the ThorNode dashboard.- Pulse does not send raw shred bytes (that is Raw Shred Stream) and does not send execution status or metadata (shreds carry none).
- The feed is live only: no history, backfill, cursor, resume, or retransmit.
- A transaction is emitted mid-slot the moment its FEC-set prefix decodes; delivery is not slot-batched.
- Receiving a transaction proves it was observed in shreds, not that it landed or was confirmed. Confirm through Solana RPC when that matters.
1. Facts to assert
- Transport: QUIC over UDP, TLS 1.3, ALPN exactly
pulse(wrong or missing ALPN → handshake rejected). - Validate the server certificate and DNS hostname. Never disable verification for a public endpoint or when sending a token.
- Enable QUIC datagrams on the client; sig-first delivers nothing without them.
- Two tiers, one per connection, chosen by the control message’s
fullflag:false/omitted → sig-first (QUIC datagrams);true→ full-tx (one server-initiated unidirectional stream). - Wire version is negotiated on the first control message. Send
"v": 2. Absent → treated as v1 → connection closed with code 4. - The server answers every control message with a length-prefixed JSON ack on the same bidirectional stream. Read it, bound the read (10 s), treat end-of-stream before a complete envelope as an error.
- Sig-first datagrams are typed and variable-length: 1-byte type tag at offset 0; each type declares a minimum length. Never check
len == N. - The full-tx stream opens with a 6-byte preamble
50 4C 53 32 02 00before any frame; each frame body starts withmsg_type | flags. - Unknown datagram type tags, unknown frame
msg_typevalues, and unknown TLV types must be skipped, never treated as errors. A duplicate TLV type must be rejected. - Endianness: the ack envelope length prefix and the full-tx frame length prefix are big-endian; everything else (datagram fields, frame bodies, TLV lengths and values) is little-endian.
- Numeric ranges and limits below are protocol constants. Per-access capacity (concurrent subscriptions, accounts across filters, whether an unfiltered feed is allowed) is dynamic and shown under Limits in the dashboard.
2. Get the target and token
In the ThorNode dashboard: Endpoints → Endpoints tab → select the pass or rental, network, and location → Token & Endpoints → Streaming → Pulse. Copy two values from the same location:- the Pulse target as
host:port(a hostname, so TLS SNI and certificate validation work); - the location token (the same token used for that location’s RPC/Yellowstone).
ssh -L, HTTP proxies, and TCP-only tunnels cannot carry QUIC.
3. Connect
- Open a QUIC connection to
host:port(UDP). - ALPN list exactly
["pulse"]. - TLS 1.3 client with certificate chain and hostname verification enabled. Set the server name to the dashboard hostname; do not dial a resolved IP without SNI.
- Enable QUIC datagram support.
4. Control message
Open one client-initiated bidirectional stream, write one JSON object, then close (FIN) your write side. Open the first control stream within about 300 ms of connecting and complete its JSON within another ~300 ms; the server closes idle connections that never subscribe.
Rules:
- Every key must base58-decode to exactly 32 bytes; otherwise the whole message is rejected (first message → close code 1; update →
ok:false). - Unknown JSON fields are ignored.
- Tier is locked by the first message. To switch tier, open a new connection.
- Filters can be updated live on the same tier: open another bidirectional stream, send another complete control object, read its ack.
token,full,vare ignored on updates. - A control message is a complete selection, not a patch: an update replaces
account_include,account_exclude,account_required,vote, andfields; anything omitted resets to its default (omitfields→ enrichment turns off). - Sending nothing is not a mode. No control stream at all → close code 4 (or 2 if auth is enforced). A stream opened but left empty → code 2 (auth) or 1. Malformed JSON → code 1.
4.1 The ack envelope
The server replies on the same bidirectional stream with one length-prefixed JSON object:typeis mandatory:"ack"or"error". Anything else is malformed.- The first message’s success ack carries
"v"; require it to be2. A successful first ack without"v"is malformed. On sig-first this ack is the only place the negotiated version is visible. - A later update’s success ack may omit
"v"; if present it must be2. ok: false→ not subscribed. On the first message the connection then closes; on an update the connection keeps streaming under the previous filter. Surfacereason.- The
errorenvelope has nookfield: defaultoktofalsewhen decoding, or you will discard the reason. - Bound the read to 10 s. End-of-stream (including a clean 0-byte FIN) before a complete envelope is an error, not “keep waiting”: a pre-v2 server FINs the control stream while data keeps flowing.
5. Filter semantics
Predicates are ANDed. A program id is just an account key.account_include: any listed key touched. Empty = unconstrained.account_exclude: any listed key touched → dropped.account_required: every listed key touched.vote: vote classification = a transaction with a single instruction invokingVote111111111111111111111111111111111111111(Yellowstoneis_simple_vote_transactionparity).trueselects votes only; omitted/falseselects non-votes only. There is no “both”; open two subscriptions to get both.- Matching runs against static account keys plus ALT-resolved loaded addresses when the server resolved them.
vote: truewith no account filter → vote transactions only, not “votes in addition”.account_include: ["Vote111…"]withvote: false→ empty set.- v0 ALT gap: when a transaction’s address-table resolution is incomplete (
alt_incomplete) and the subscription has anyaccount_exclude, the server drops that transaction conservatively.account_include/account_requiredare unaffected. - No
failed/success filter exists; never fabricate one. - An unfiltered feed (
account_includeandaccount_requiredboth empty) is rejected on any access with an account cap (close code 5).
6. Authentication, quotas, close codes
- Token: first control message
token. Missing, invalid, or revoked → close code 2. Deactivating a token also closes its live connections with code 2 within about 30 s. - Per-token quotas: concurrent live subscriptions, and total accounts across filters (
account_include+account_required;account_excludedoes not count). Read the values under Limits in the dashboard.
- Codes 1–5 fire on the first message; code 2 also fires mid-connection on revocation.
- Code 4 is the only close that first writes a JSON
errorenvelope on the control stream (only if a control stream was opened). Codes 1, 2, 3, 5 put a plain UTF-8 string in the QUIC CONNECTION_CLOSE reason, not JSON. - The close reason never contains the token.
- Unknown close code → stop and surface it; do not guess it is retryable.
7. seq and heartbeats
seqis a per-connection, per-subscriber counter over transactions only (heartbeats never consume one). First delivery on a connection isseq == 0; it restarts at 0 on reconnect.- Assigned before the droppable send, so a transaction shed under backpressure leaves a visible hole. There is no retransmit; treat gaps as a metric and reconcile independently when completeness matters.
- Sig-first can drop, reorder, or duplicate. A scalar high-watermark gap counter over-reports under reordering: a nonzero count means “loss or reordering happened”, not an exact loss count. Deduplicate by signature.
- Heartbeats: sig-first sends datagram type 2; full-tx sends frame
msg_type2. Cadence: after 10 s of stream idleness (timer resets on every real send), not a metronome. A heartbeat is itself a signal the stream was quiet. - Heartbeat
highest_seq= the highestseqassigned so far (0-indexed: after N deliveries it reads N-1). Sentinelu64::MAX(0xFFFFFFFFFFFFFFFF) = nothing assigned yet; ignore it for gap tracking, never adopt it as a baseline. If you have no baseline, adopt a real value without alleging a gap; if it exceeds your last receivedseq, the difference is trailing loss. - On full-tx there is no per-frame
seq;highest_seqis a raw signal to compare with your own frame count.
8. Sig-first datagrams
Every datagram starts with a 1-byte type tag. Integers little-endian.- Minimum, not exact: a known type at least that long parses; ignore trailing bytes. Shorter than the minimum → malformed, reject (no partial parse). Unknown type → skip silently.
- Fire-and-forget, unordered, may be lost or duplicated. Enrichment never reaches this tier.
9. Full-tx stream
After the first control message withfull: true is acked ok: true, the server opens one unidirectional stream. Frames enqueued on it are ordered and reliable at the transport layer, but a bounded server queue can shed transactions before enqueue: no end-to-end lossless, at-least-once, landed, or completeness guarantee.
9.1 Preamble (once, before any frame)
50 4C 53 32 02 00. Read and verify it before parsing any frame; a mismatch is a protocol error (fail loudly, do not skip). An incomplete preamble is its own error.
9.2 Frame envelope
msg_type:1= transaction,2= heartbeat (TLV trailer only),3= reserved, never emitted. Skip any unrecognizedmsg_typeusing the length prefix.flagsonmsg_type1: bit 0 =alt_incomplete; bits 1–7 reserved and must be 0 (reject if set). Onmsg_type2 every bit is reserved; nonzero → malformed.- Size limits: reject an outer length above 196,614 before allocating; for a transaction frame reject when body + trailer after
msg_type|flagsexceeds 65,536. - End of stream is clean only on a frame boundary. Any partial length prefix or partial body at end-of-stream is a truncated frame → error, not a normal end. If a QUIC application close arrives with it, keep both facts (framing error + close code/reason).
9.3 Positional transaction body (msg_type 1)
All integers little-endian. Bounds-check every count before allocating.9.4 TLV trailer
Order not significant. Unknown type → skip. Duplicate type → reject the frame.
alt_incomplete (flags bit 0) is present regardless of fields.
9.5 Derived fields (compute client-side; not on the wire)
The SDKs ship these as helpers (
thornode_pulse_wire::derive, derive.go, derive.py).
10. Official SDKs
All three negotiate
v: 2, read and check the ack, verify the full-tx preamble, expose the close code as a typed error, and validate certificate and hostname by default. Enrichment fields exists only on the full-tx APIs.
10.1 API map (use these names; do not invent others)
Rust:PulseClient::connect(endpoint: &str) -> Result<PulseClient>;PulseClient::connect_with_token(endpoint, token) -> Result<PulseClient>PulseClient::builder(endpoint).with_token(token).add_custom_ca_der(der).connect()for a private CA (public roots and hostname checks stay on)client.subscribe_sig_first(&Filter) -> Result<SigFirstSub>;sub.next().await? -> Option<SigFirstItem { slot, seq, signature }>;sub.gaps() -> u64,sub.dropped() -> u64;sub.update_filter(&Filter) -> Result<Ack>client.subscribe_full(&Filter, &[&str]) -> Result<FullSub>;sub.next().await? -> Option<Frame>(Frame::Tx(FullTxV2 { tx, alt_incomplete, loaded_writable, loaded_readonly });txhasslot,versioned,num_required_signatures,num_readonly_signed_accounts,num_readonly_unsigned_accounts,recent_blockhash,signatures,account_keys,instructions,address_table_lookups;Frame::Heartbeat { server_ts_ms, highest_seq };Frame::Unknown(u8));sub.heartbeat() -> Option<(server_ts_ms, highest_seq)>;sub.update_filter(&Filter, &[&str]) -> Result<Ack>Filter::all(),Filter::accounts([&str, …]),.with_vote(bool); public fieldsaccount_include,account_exclude,account_required: Vec<String>,vote: Option<bool>- Errors:
Error::ApplicationClosed(CloseInfo { code, reason })withclose.retry_class()/close.retryable();Error::ConnectTimeout,Error::AckTimeout,Error::FullStreamTimeout,Error::BadPreamble,Error::BadFrame,Error::BadFrameWithClose(CloseInfo); helperserror.close_info(),error.is_bad_frame(),error.is_bad_preamble()
pulseclient.Connect(ctx, addr string, options ...Option) (*Client, error)withpulseclient.WithToken(token),pulseclient.WithRootCAs(pool);client.Close()client.SubscribeSigFirst(ctx, filter) (*SigFirstSub, error);sub.Next(ctx) (SigFirstItem, error)(io.EOFat end);sub.Gaps() uint64,sub.Dropped() uint64,sub.Queued() intclient.SubscribeFull(ctx, filter, fields ...string) (*FullSub, error);sub.Next() (*FullTxV2, error)(io.EOFat end);sub.Heartbeat() (serverTsMs, highestSeq uint64, ok bool)pulseclient.AllTxs(),pulseclient.Accounts(keys ...string),filter.WithVote(bool) Filter; struct fieldsAccountInclude,AccountExclude,AccountRequired []string,Vote *bool- Errors:
*RejectedError,*VersionMismatchError,ErrBadPreamble,ErrBadFrame;pulseclient.CloseInfoFromError(err) (CloseInfo, bool)with.Code,.Reason,.Retry
connect_pulse(target, *, token=None, server_name=None, ca_file=None, certificate_sha256=None, insecure_local_development=False)→ async context manager yieldingPulseClientawait client.subscribe_sig_first(Filter)→ async iterable ofSigFirstItem(slot, seq, signature)await client.subscribe_full(Filter, fields=())→ async iterable of frames (frame.tx.slot,.signatures,.instructions, …)Filter.all(),Filter.accounts(*keys),.with_vote(bool); attributesaccount_include,account_exclude,account_required,vote- Exceptions:
PulseConnectionClosed(.close.code,.close.reason,.close.retry_disposition),AckTimeout,PreambleTimeout,FullStreamTimeout,FullQueueOverflow,BadPreamble,BadFrame(never swallowed)
10.2 Runnable sig-first programs
Rust:10.3 Runnable full-tx programs (with alt enrichment)
Rust:
10.4 Handling the close code
11. Client from scratch (no SDK)
12. Production checklist
- Copy target and token from the same dashboard location; keep TLS verification on; allow outbound UDP.
- Use an explicit account or program filter unless Limits shows the access allows an unfiltered feed.
- Keep the receive loop small: hand decoded items to a bounded queue; do heavy work in workers. Sig-first SDK queues expose drops (
dropped(),Dropped(), Pythonmetrics); Python full-tx raisesFullQueueOverflowon local overflow. - Reconnect loop: close the old client and workers → classify the error → stop on codes 1, 2 (until the credential is fixed), 4, 5 → back off with jitter on code 3 or network failure → reconnect with the same target and token → resend the complete filter and
fields→ reconcile the gap through Solana RPC if state matters. Never open parallel replacement connections. - After an accepted filter update, a short overlap of items matched by the previous filter is normal (updates are not atomic).
- Metrics: connection state, reconnect count, last close code, time since last transaction or heartbeat, sig-first gaps and local drops, full-tx processing latency and stream errors, reconciliation results.
- Deduplicate by signature; make side effects idempotent.
13. Mistakes to avoid
- Omitting ALPN
pulse, or using TCP/TLS instead of QUIC. - Not enabling QUIC datagrams (sig-first delivers nothing).
- Reading the ack or frame length prefix as little-endian (both are big-endian; everything else is little-endian).
- Omitting
"v": 2(close code 4). Sending no control message at all (code 4 or 2). - Checking
len == 81(or 72). Lengths are minimums; ignore trailing bytes. - Not switching on the datagram type tag at byte 0.
- Treating the first 6 stream bytes as a frame length (the preamble comes first).
- Erroring on unknown datagram types,
msg_types, or TLV types (skip them); accepting duplicate TLV types (reject the frame). - Fire-and-forget on the control ack, or waiting forever (10 s bound; EOF before envelope = error).
- Failing to decode
{"type":"error",...}becauseokis missing. - Using
u64::MAXhighest_seqas a real value; treating a nonzero gap count as exact. - Trying to change tier on the same connection; expecting history/backfill; adding a
failedfilter; assuming receipt means landed. - Sending an unfiltered feed on an access with an account cap (code 5, will not succeed unchanged).
- Treating
vote: trueas additive. - Relying on
account_excludefor ALT-loaded accounts. - Omitting
fieldson an update and expecting enrichment to stay on. - Disabling certificate/hostname verification for a public endpoint or when sending a token.
- Not bounds-checking lengths and counts before allocating.
14. Troubleshooting
15. References
- Wire specification: https://github.com/thorlabsDev/pulse-sdk/blob/main/docs/PROTOCOL.md
- Conformance vectors for testing a new decoder: https://github.com/thorlabsDev/pulse-sdk/blob/main/conformance/wire-v2/vectors.json
- SDKs: Rust
clients/rust, Pythonclients/pythonin https://github.com/thorlabsDev/pulse-sdk; Go https://github.com/thorlabsDev/pulse-go - ThorNode docs: Pulse overview, Quickstart and SDKs, Wire protocol v2, Production and errors, Usage and limits