Protocol
Venue reports, books and funding
The signed VenueReport, BookUpdate and FundingUpdate payloads, how books travel as snapshots and deltas, and the golden vectors that freeze every layout.
A price update says what a node concluded. A node also signs three more payloads:
- A venue report says what the node saw for each source of one feed: the book, the perp data, and how the source counted in the price.
- A book update carries one venue instrument's local book, so that a client can keep its own copy.
- A funding update carries one perpetual's funding, as its venue states it.
All use the same key, digest and signature format as a price update. See Signing and the price payload. A node builds venue reports and book deltas only for clients that ask. See Subscriptions.
Common rules
- All integers are big-endian. The
versionbyte is1. - Each payload is canonical. A decoder refuses any other byte form, so each report or update has exactly one encoding. Trailing bytes are an error.
- Each frame is
frame_type ‖ signer address[20] ‖ payload ‖ signature[65]. The signer address is an unauthenticated routing hint, as in a price frame.
| Frame type | Payload | Kind |
|---|---|---|
| 1 | PriceUpdate |
0x01 |
| 2 | VenueReport |
0x03 |
| 3 | BookUpdate |
0x04 |
| 4 | FundingUpdate |
0x05 |
Venue codes are the same as the bits of the source bitmap:
| Code | Venue |
|---|---|
| 0 | Binance |
| 1 | Bybit |
| 2 | OKX |
| 3 | Coinbase |
| 4 | Kraken |
| 5 | Deribit |
Market codes:
| Code | Market |
|---|---|
| 0 | spot |
| 1 | linear perpetual |
| 2 | inverse perpetual |
A decoder refuses an unknown venue or market code.
VenueReport v1
Kind 0x03. The payload is a 55-byte header and n entries of 95 bytes each.
Header
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 1 | version |
u8 | 1 |
| 1 | 1 | kind |
u8 | 0x03 |
| 2 | 32 | feed_id |
bytes32 | The feed. |
| 34 | 8 | publish_time_ns |
u64 | When the node signed the report, unix nanoseconds. |
| 42 | 8 | seq |
u64 | For each (node, feed) pair. It is a separate counter from the price seq. |
| 50 | 4 | expo |
i32 | The feed's exponent, for every price in the entries. |
| 54 | 1 | n |
u8 | The number of entries. One entry for each configured source, in config order. |
Entry
Each entry is 96 + L bytes, where L is the length of its symbol. Offsets are from the start of the entry.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 1 | venue |
u8 | Venue code. |
| 1 | 1 | market |
u8 | Market code. |
| 2 | 1 | L |
u8 | Symbol length, 1–32. |
| 3 | L | symbol |
ASCII | The venue's own symbol for the instrument, such as BTCUSDT. |
| 3 + L | 1 | status |
u8 | How the source counted. See Source status codes. |
| 4 + L | 2 | present |
u16 | Presence bits. Bit i is set when slot i holds a value. |
| 6 + L | 2 | weight_bps |
u16 | The source's share of the total weight of the price, 0–10,000. It is 0 unless the source was used. |
| 8 + L | 80 | slots | 10 × 8 bytes | Ten optional values. An absent value is all zeros. |
| 88 + L | 8 | local_recv_ns |
u64 | The node's receipt time of the newest observation, or 0 with no data. |
The ten slots, in order:
| Slot | Presence bit | Value | Type | Unit |
|---|---|---|---|---|
| 0 | 0x0001 |
best bid | i64 | price at expo |
| 1 | 0x0002 |
best ask | i64 | price at expo |
| 2 | 0x0004 |
bid size | u64 | base units at exponent −8 |
| 3 | 0x0008 |
ask size | u64 | base units at exponent −8 |
| 4 | 0x0010 |
impact bid | i64 | price at expo |
| 5 | 0x0020 |
impact ask | i64 | price at expo |
| 6 | 0x0040 |
mark | i64 | price at expo |
| 7 | 0x0080 |
index | i64 | price at expo |
| 8 | 0x0100 |
funding | i64 | the venue's own rate over its own interval, at exponent −9 |
| 9 | 0x0200 |
venue timestamp | u64 | unix nanoseconds, venue clock |
Bits 10–15 of present must be 0.
Source status codes
| Code | Status | Meaning |
|---|---|---|
| 0 | used | The source is in the price, with a weight. |
| 1 | outlier | Outlier rejection dropped the source. |
| 2 | stale | The newest data of the source is older than the staleness limit of the feed. |
| 3 | crossed | The book of the source is crossed. |
| 4 | no data | No data yet, or none since its connection dropped. |
| 5 | invalid | The data cannot be priced, for example a book price of zero. |
| 6 | feed halted | The source had a usable quote, but the feed halted. |
| 7 | peg unavailable | The source quotes in the feed's peg asset, and the peg has no usable price, so it dropped out. |
Report rules
- Prices before conversion. The prices are the venue's own, before any peg conversion. The status and the weight refer to the converted price.
- Impact prices. The impact prices are for the feed's
impact_notional, from the node's local book. A book that runs out of levels gives no impact price, because a partial fill shows a tighter market than the book has. - Status. The status comes from the last evaluation of the feed. See Aggregation.
- Symbol. The symbol names the instrument, so a stored report joins the venue's instrument data without the node's config. It is 1–32 bytes of printable ASCII without spaces.
- Canonical form. A decoder refuses an unknown venue, market or status code, a bad symbol, an unknown
presentbit, a non-zero absent slot, and a weight above 10,000. - Cadence. One report after new input, at most one a second, and one at each heartbeat. The node builds and signs a report only for a feed that some client subscribed to. The first report goes out at the next evaluation after a client subscribes.
- Sequence. The node seeds the report
seqfrom its clock in milliseconds at startup, as for prices.
Report size bounds
| Item | Minimum | Maximum |
|---|---|---|
Entries n |
0 | 32 |
Entry, 96 + L bytes |
97 | 128 |
| Payload | 55 | 4,151 |
| Frame, payload + 86 bytes | 141 | 4,237 |
A feed with more than 32 sources is a config error.
Verify a report
A consumer verifies a report like a price update: the signature, the registry, the clock and its own seq for each (node, feed) pair. The coherence check is different. Each entry's local_recv_ns must not be later than publish_time_ns. Each node reports what it saw, so a consumer does not combine reports across nodes.
BookUpdate v1
Kind 0x04. A book update is a snapshot of the top 50 levels on each side, or a delta: the levels that changed since the previous frame.
The symbol has a variable length L, so the offsets after it depend on L.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 1 | version |
u8 | 1 |
| 1 | 1 | kind |
u8 | 0x04 |
| 2 | 1 | venue |
u8 | Venue code. |
| 3 | 1 | market |
u8 | Market code. |
| 4 | 1 | L |
u8 | Symbol length, 1–32. |
| 5 | L | symbol |
ASCII | The venue's own symbol, such as BTCUSDT. |
| 5 + L | 8 | publish_time_ns |
u64 | When the node signed the update, unix nanoseconds. |
| 13 + L | 8 | seq |
u64 | For each (node, book) pair. A delta applies to the frame with seq − 1. |
| 21 + L | 1 | flags |
u8 | Bit 0: a snapshot. Bit 1: venue_seq holds a value. Other bits are 0. |
| 22 + L | 4 | price_expo |
i32 | Always −8 in v1. |
| 26 + L | 4 | size_expo |
i32 | Always −8 in v1. |
| 30 + L | 8 | exchange_time_ns |
u64 | The venue's timestamp on the newest book data, or 0 when the venue sends none. |
| 38 + L | 8 | local_recv_ns |
u64 | The node's receipt time of the newest book data. |
| 46 + L | 8 | venue_seq |
u64 | The venue's update id of the newest book data, or 0 when bit 1 is clear. |
| 54 + L | 1 | bid count | u8 | At most 128. |
| 55 + L | 1 | ask count | u8 | At most 128. |
| 56 + L | 16 each | levels | price i64, size u64 | The bids from the highest price, then the asks from the lowest price. |
The payload length is 56 + L + 16 × (bids + asks) bytes.
Book rules
- Symbol. 1–32 bytes of printable ASCII without spaces (
0x21–0x7E). - Exponents. A decoder refuses a price or size exponent other than −8.
- Order. Prices are strictly ordered on each side: bids descend, asks ascend. No price repeats.
- Snapshot. A snapshot has no level with size 0.
- Delta. A delta lists each level whose size changed, with the new size. It lists each level that left the top 50 with size 0. Size 0 removes the level.
- Counts. A delta of a 50-level window changes at most 100 levels on a side. The limit of 128 covers that.
- Venue seq. It names the exact venue book state that the node signed: Binance and Bybit
u, OKXseqId, Deribitchange_id. Two nodes' books of one instrument thus fall on one venue timeline. Coinbase and Kraken send no update id. A zero id is real on some venues, so bit 1, not a zero, marks an absent id. An empty snapshot after a drop has none.
Book size bounds
| Item | Minimum | Maximum |
|---|---|---|
Symbol length L |
1 | 32 |
| Levels on each side | 0 | 128 |
| Payload | 57 | 4,184 |
| Frame, payload + 86 bytes | 143 | 4,270 |
How books travel
The node keeps a local book for each venue instrument that reads depth. That is a source of a feed with impact_notional set and book_top in its kinds. The node builds the book from a venue snapshot and the changes after it, and it proves that each change follows the last one. A failed proof is a resync: the node drops the connection and starts from a fresh snapshot. See Aggregation.
The node then sends the book to clients in its own format:
| Rule | Value |
|---|---|
| Depth | The top 50 levels on each side. |
| Snapshot | Every second for each book, whether or not a client subscribed. The next subscriber thus gets a recent snapshot at once. |
| Snapshot after a disconnect | At once after the venue connection drops. This snapshot is empty until new data arrives. |
| Delta | The levels that changed since the node's last frame for that book. The node computes it from its own last frame, not from the venue's messages. Thus the format is the same for every venue. |
| Rate cap | At most one frame for each book every 50 ms. |
| Delta demand | The node builds a delta only for a book that some client subscribed to. A skipped delta takes no seq. |
| Sequence | One counter for each (node, book) pair, shared by snapshots and deltas. The node seeds it from its clock in milliseconds at startup. |
A client that applies a snapshot, and then each delta in seq order, holds exactly the node's top 50 levels.
Keep a book on the client
Do these steps for each (node, book) pair:
- Verify the frame: signature, registry, clock, and a
seqgreater than the last one. - If the frame is a snapshot, replace the book with its levels.
- If the frame is a delta, apply it only when its
seqis the lastseqplus 1. For each level, set the new size. Remove the level when the size is 0. - If a
seqis missing, mark the book invalid. Ignore deltas until the next snapshot. - An empty snapshot means that the venue connection dropped or has no data yet. Do not use that book.
Keep one book for each node. Never mix the levels of two nodes in one book. The Rust SDK keeps one book for each node and returns the freshest valid one. See Rust SDK.
A client that subscribes gets the latest snapshot and the deltas after it at once. See Fanout and backpressure.
FundingUpdate v1
Kind 0x05. A funding update carries one perpetual's funding. Nothing is converted: the rate is a fraction over the venue's own basis, and open interest is in the venue's own unit.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 1 | version |
u8 | 1 |
| 1 | 1 | kind |
u8 | 0x05 |
| 2 | 1 | venue |
u8 | Venue code. |
| 3 | 1 | market |
u8 | Market code. |
| 4 | 1 | L |
u8 | Symbol length, 1–32. |
| 5 | L | symbol |
ASCII | The venue's own symbol, such as BTC-USDT-SWAP. |
| 5 + L | 8 | publish_time_ns |
u64 | When the node signed the update, unix nanoseconds. |
| 13 + L | 8 | seq |
u64 | For each (node, perpetual) pair. |
| 21 + L | 1 | flags |
u8 | A field holds a value: bit 0 predicted rate, bit 1 interval, bit 2 next funding time, bit 3 settled rate, bit 4 open interest. |
| 22 + L | 1 | basis |
u8 | 0: the fraction paid at each funding time. 1: the fraction over 8 hours (Deribit funding_8h). |
| 23 + L | 1 | open_interest_unit |
u8 | 0 base asset, 1 contracts, 2 quote currency. 0 when bit 4 is clear. |
| 24 + L | 4 | rate_expo |
i32 | Always −12 in v1. |
| 28 + L | 4 | open_interest_expo |
i32 | Always −8 in v1. |
| 32 + L | 8 | rate |
i64 | The current rate. |
| 40 + L | 8 | predicted_rate |
i64 | The venue's prediction of the next rate. |
| 48 + L | 8 | interval_ns |
u64 | The length of one funding period. |
| 56 + L | 8 | next_funding_time_ns |
u64 | When the current rate is paid. |
| 64 + L | 8 | settled_rate |
i64 | The last rate that the venue settled. |
| 72 + L | 8 | settled_time_ns |
u64 | When the settled rate was paid. |
| 80 + L | 8 | open_interest |
u64 | |
| 88 + L | 8 | exchange_time_ns |
u64 | The venue's timestamp on the newest funding data, or 0 when the venue sends none. |
| 96 + L | 8 | local_recv_ns |
u64 | The node's receipt time of the newest funding data. |
The payload length is 104 + L bytes: 105 to 136. A frame is 191 to 222 bytes.
Funding fields by venue
The node takes the fields from messages that it already reads.
| Venue, channel | Rate | Predicted | Interval | Next time | Settled | Open interest |
|---|---|---|---|---|---|---|
Binance perp, markPrice |
r |
– | – | T |
– | – |
Bybit perp, tickers |
fundingRate |
– | fundingIntervalHour |
nextFundingTime |
– | openInterest, base |
OKX swap, funding-rate |
fundingRate |
nextFundingRate |
nextFundingTime − fundingTime |
fundingTime |
settFundingRate at prevFundingTime, once settState is settled |
– |
Kraken Futures, ticker |
relative_funding_rate |
relative_funding_rate_prediction |
1 hour | next_funding_rate_time |
– | openInterest, contracts |
Deribit perp, ticker |
funding_8h, basis 1 |
– | – | – | – | open_interest, USD for an inverse contract |
Funding rules
- Flags. A field whose flag is clear is 0, and so is the unit without open interest. A decoder refuses anything else.
- Whole state. Each frame carries every field that the venue sent, so a client needs only the latest frame. Bybit sends only the changed fields; the node keeps the last value of each until the connection drops.
- Cadence. A frame goes out on a change, at most once a second for each perpetual, and at least once a minute. The node signs frames whether or not a client subscribed. After a venue connection drops, it signs nothing for that venue's perpetuals until new data arrives.
- Age. A new subscriber gets the latest frame at once, and that frame can be a minute old. A client accepts a funding frame up to two minutes old.
- Precision. A rate keeps 12 decimal places. OKX sends 16; the last 4 round away.
- Binance interval. The
markPricestream does not state the interval, so a Binance frame has nointerval_ns. Binance'sfundingInfoREST endpoint gives it.
Golden vectors
The oracle-wire crate holds golden.json, in its tests/vectors folder. The file freezes the protocol: payload bytes, digests, signatures, frames, feed ids, the registry encoding, venue reports, book updates and funding updates. A change in this file means that the wire format changed and that old signatures no longer verify.
| Section | Contents |
|---|---|
domain |
name, version, chain_id and the separator. |
feed_ids |
9 symbols, each with its expo and feed_id. |
price_updates |
5 vectors: btc_usd_healthy, btc_usd_degraded_synthetic, btc_usd_halted, funding_rate_negative, boundary_extremes. |
venue_reports |
3 vectors: btc_usdt_three_venues, boundary_extremes, no_entries. |
book_updates |
3 vectors: binance_snapshot, okx_swap_delta, boundary_extremes. |
funding_updates |
3 vectors: okx_swap_settled, deribit_eight_hours, boundary_extremes. |
registry |
A signed registry document, its signing bytes and its digest. |
Each payload vector has these fields:
| Field | Meaning |
|---|---|
name |
The case. |
signer |
The address that signed. |
update or report |
The decoded fields, as JSON. |
payload |
The canonical payload, 0x-hex. |
digest |
The domain digest of the payload. |
signature |
r ‖ s ‖ v, 0x-hex. |
frame |
The full frame, 0x-hex. |
Each vector also carries the fixed test key that signed it. That key is a public fixture. Never use it for anything real.
In the decoded fields, each 64-bit value is a decimal string. Each byte string is 0x-hex. Venues, markets and statuses are names, such as binance, linear_perp and used. An absent optional value is null.
Check an independent encoder
Use the vectors to test an encoder in another language:
- Compute the domain separator from its definition. Compare it with
domain.separator. - For each entry in
feed_ids, computekeccak256of the symbol. Compare it withfeed_id. - For each vector, encode the decoded fields with your encoder. Compare the bytes with
payload. - Compute the digest of
payload. Compare it withdigest. - Build
frame_type ‖ signer ‖ payload ‖ signature. Compare it withframe. - Recover the signer from
signatureanddigest. Compare it withsigner. - Decode
payloadwith your decoder. Compare the fields, then encode them again and compare the bytes.
The signatures are deterministic. cast wallet sign --no-hash with the vector's test key gives the same bytes.
The project checks the vectors with a second implementation. check_layout.py, next to golden.json, packs each payload with Python struct from the layout tables and hashes it with cast keccak. It shares no code with the Rust encoder. Other independent checks:
- published Keccak-256 test vectors, which tell Keccak-256 from SHA3-256;
- the canonical Ethereum address of secret key
1; - the domain separator, recomputed by
foundryfrom the EIP-712 definition alone; - full signatures, compared with
cast wallet sign --no-hash.
The foundry and Python checks skip themselves when cast or python3 is not on PATH.
To regenerate the vectors after a deliberate format change, run this command and read the diff:
REGENERATE_GOLDEN=1 cargo test -p oracle-wire --test goldenThe two secp256k1 backends must agree byte for byte. This command runs the vectors under k256:
cargo test -p oracle-wire --features pure-rust