Skip to main content

Node data streams

info

Status. stable shapes. Every stream is off by default and is enabled one flag at a time. Most streams de-anonymize order flow or account value. A node in the validator set logs a loud warning for those and records anyway. Only the two book-diff streams are refused outright. Run the streams on a non-validating node.

TL;DR

A MetaFlux node can write its committed blocks to disk as newline-delimited JSON (NDJSON). One line is one envelope. One envelope holds one committed block. Each envelope holds zero or more records.

The node does not serve these files. It only writes them. You read them with your own indexer, archiver, or analytics job.

Fourteen streams exist. Ten carry block events. Two sample on a timer. Two carry order-book state.

StreamOn-disk rootContent
node_fills<data_dir>/node_fills/One record per filled party (taker and maker)
node_trades<data_dir>/node_trades/One record per print, no counterparty
node_order_statuses<data_dir>/node_order_statuses/Order lifecycle transitions
node_funding<data_dir>/node_funding/Realized funding payments
node_ledger<data_dir>/node_ledger/Signed non-funding balance deltas
node_gov<data_dir>/node_gov/Governance vote casts and enactments
node_bridge_outbox<data_dir>/node_bridge_outbox/Bridge withdrawal outbox: admissions, status moves, deployment rows
node_equity_snapshots<data_dir>/node_equity_snapshots/Hourly account-value samples
node_asset_ctxs<data_dir>/node_asset_ctxs/Per-market mark and oracle price samples, every 5 s
node_actions<data_dir>/node_actions/One record per action in a block payload, applied and rejected
node_blocks<data_dir>/node_blocks/One block head per committed block, including an empty one
replica_cmds<data_dir>/replica_cmds/One block envelope per block, header plus events
l4_book_diffs<data_dir>/l4_book_diffs.jsonlPer-order book diffs, with owner
l2_book_diffs<data_dir>/l2_book_diffs.jsonlPer-price-level book diffs, anonymous
warning

Read Number planes before you read any number. The node_* streams write prices and sizes as raw integer strings. replica_cmds writes the same quantities as whole-unit decimal strings. The two look alike and are off by a large power of ten.

Joining the validator set

Anyone can run a MetaFlux node and read its streams. Membership of the validator set is separate, and it is controlled: staking MTF does not by itself make a node a validator.

To apply, write to [email protected].

Operations

Enable a stream

Each stream has its own flag in the [persistence] table of the node config. Every flag defaults to false.

StreamFlagDefault
node_fillswrite_fillsfalse
node_tradeswrite_tradesfalse
node_order_statuseswrite_order_statusesfalse
node_fundingwrite_fundingfalse
node_ledgerwrite_ledgerfalse
node_govwrite_govfalse
node_bridge_outboxwrite_bridge_outboxfalse
node_equity_snapshotswrite_equity_snapshotsfalse
node_asset_ctxswrite_asset_ctxsfalse
node_actionswrite_actionsfalse
node_blockswrite_blocksfalse
replica_cmdswrite_replica_cmdsfalse
l4_book_diffsrecord_l4false
l2_book_diffsrecord_l2false
[node]
data_dir = "/var/lib/mtf-node"

[persistence]
write_fills = true
write_trades = true
write_order_statuses = true

A disabled stream creates no directory and no file.

Recording on a validator

Most streams de-anonymize order flow, account value, or a user withdrawal. A node in the validator set that enables one publishes the flow of every account that trades on it.

  • An enabled write_* stream always records. A validator that sets one gets a loud start-up warning, never a silent refusal. There is no config override, because there is no gate to lift. Enable these on a validator only when the flow is your own. The warned set is write_fills, write_trades, write_order_statuses, write_funding, write_ledger, write_equity_snapshots, write_bridge_outbox, write_actions, and write_replica_cmds.
  • Three streams carry nothing to de-anonymize and raise no warning: write_gov names a validator, not a trader; write_asset_ctxs and write_blocks carry no address and no account value. node_gov is meant to run on a validator — that is where the votes happen.
  • record_l4 and record_l2 are refused on a validator, with no override. They also walk the whole book once per block, which is work a validator must not do on the commit path.

The intended place to run a de-anonymizing stream is a non-validating node that follows the chain and serves nobody. Point your indexer at that node.

Snapshot-style streams sample a window

Most streams are event tapes. They record what a block did. Two streams are sample tapes. They record state at a point in time, so they sample instead of writing every block.

StreamWindowSampled block
node_equity_snapshotsOne UTC hour of consensus block time (3,600,000 ms)The first committed block of each window
node_asset_ctxs5,000 ms of consensus block timeThe first committed block of each window
l4_book_diffs / l2_book_diffspersistence.snapshot_interval blocks (default 1024)The block that closes the interval, plus one bootstrap snapshot on the first non-empty book

node_equity_snapshots writes exactly one sample per hourly file. It does not sample during start-up replay, because a replayed block would stamp current state onto an old block time.

The book-diff streams write a diff line on every block whose book changed, and a full snapshot line on the interval.

On-disk layout

Hourly files

Eleven streams rotate hourly:

<data_dir>/node_fills/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_trades/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_order_statuses/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_funding/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_ledger/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_gov/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_bridge_outbox/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_equity_snapshots/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_asset_ctxs/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_actions/hourly/{YYYYMMDD}/{HH}
<data_dir>/node_blocks/hourly/{YYYYMMDD}/{HH}

replica_cmds rotates hourly too, but without the hourly/ segment:

<data_dir>/replica_cmds/{YYYYMMDD}/{HH}

{YYYYMMDD} is the UTC date. {HH} is the UTC hour, 00 to 23, zero-padded. Both come from the consensus block time, never from the recording node's clock. The file a record lands in is therefore a function of the block alone. Two nodes that record the same blocks produce the same file names and the same bytes.

Files have no extension. They are append-only NDJSON. Within one file, envelopes are in ascending block order. Across files, lexical order of {YYYYMMDD}/{HH} is chronological order.

The two book-diff streams do not rotate. Each is a single append-only file:

<data_dir>/l4_book_diffs.jsonl
<data_dir>/l2_book_diffs.jsonl

Empty blocks and archive holes

A block with no events for a stream writes no line. Absence of a line means "that block was empty for this stream".

An archive hole is different. A hole is a block range the node did not record, because the recorder was down and the range could not be replayed. The node marks a hole with one line:

{"gap":{"from":941006632,"to":941006699}}

from and to are inclusive block numbers. Every hourly stream and replica_cmds can carry gap lines. The book-diff streams cannot.

A consumer must detect a gap line before it parses an envelope, must skip it, and should record the hole so archive completeness stays auditable. A gap line always starts with {"gap". An envelope never does.

node_blocks and replica_cmds each write one envelope for every committed block, including an empty one, so their heights form a contiguous sequence between gaps. Every other stream skips an empty block.

Control files

Each stream root holds small node-internal files beside its date directories:

FileContent
<stream root>/cursorLast recorded block number, as decimal text
<data_dir>/node_equity_snapshots/snapshot_bucketLast sampled window index
<data_dir>/node_asset_ctxs/snapshot_bucketLast sampled window index

These are not archive data. Do not parse them as envelopes. A walker that enumerates only directories under the stream root never sees them.

Torn lines

A node that stops uncleanly can leave a partial last line. The node truncates that line when it next opens the file, so a stored archive never wedges a reader.

A consumer that tails a live file must still handle the writer mid-write: accept only newline-terminated lines, never advance its read offset past a fragment, and retry that fragment on the next pass.

Number planes

MetaFlux carries prices and sizes on two integer planes, plus one decimal plane for money. Mixing them is the classic integration bug. See two price planes for the same split on the API surface.

PlaneOn the wireConvert to human units
Raw price (1e8 fixed-point)Integer string, e.g. "6250000000000"Divide by 10000000062500.00 USDC
Raw size (lots)Integer string, e.g. "50000"Divide by the row's own sz_decimals0.5 whole units
Whole unitsDecimal string, e.g. "-25.5"Already human. Parse as an arbitrary-precision decimal, never as a float

sz_decimals is the market's size precision. It is at most 6. See contract specifications.

warning

Divide by the plane the ROW states, never by the market's current precision.

NOT LIVE YET. The sz_decimals field ships with the next node release — see next release. A node running today writes none of these rows with it, so every row reads as "not recorded" and the fallback below is the whole rule until the swap.

node_fills, node_trades and node_order_statuses each carry a sz_decimals field. It is the plane that row was written on.

A market's precision can rise by a governance vote. The vote multiplies every stored lot count, so the real quantities do not move — but a row written before the vote keeps the smaller lot count AND the older plane. A reader that divides every row by the market's current precision reports each of those older rows 10^Δ too small.

Rows written before this field shipped carry no sz_decimals. Fall back to the market's current precision for those only.

Which plane a stream uses:

StreamPricesSizesMoney
node_fillsRaw priceRaw sizeWhole USDC
node_tradesRaw priceRaw size
node_order_statusesRaw priceRaw size
node_fundingWhole units (szi)Whole USDC
node_ledgerWhole tokens
node_govWhole stake units
node_bridge_outboxRaw token base units
node_equity_snapshotsWhole USDC
node_asset_ctxsRaw price
node_actionsRaw priceRaw size
node_blocks
replica_cmdsWhole USDCMixed — see replica_cmdsWhole USDC
l4_book_diffs / l2_book_diffsRaw priceRaw size

Two number kinds sit outside the price / size / money split above.

  • node_gov carries stake as a whole-integer string. It is a count of stake units. It is never divided.
  • node_bridge_outbox carries a bridge amount as raw base units of the bridged token. Divide it by that token's own on-chain decimals, never by a market's sz_decimals. The two raw planes look alike and take different divisors.
  • node_actions embeds the submitted action body under payload. That body follows the request planes of POST /exchange, so its prices and sizes are bare JSON numbers on the raw planes, not strings. The result block on the same record keeps the string convention.

Every price, size, and money value is a JSON string. Block numbers, timestamps, order ids, trade ids, and enum codes are bare JSON numbers.

node_fills

One record per filled party. A single match produces two records: the taker leg first, then the maker leg. Both legs of one match share the same tid.

A fill that no signed action produced — a forced close, a TWAP slice, a trigger fire, a spot-margin forced close — is recorded on the block it executed in, with an empty hash. No user signed it, so there is no hash to record.

Some order lanes produce a fill this stream does NOT carry. An order placed by modify or batch_modify, an order placed by CoreWriter LimitOrder, any order inside a multi_sig envelope, and every clearing of a frequent batch auction each settle with no record — see unrecorded fills. Both legs are missing, so the maker loses its record as well. An archive folded from this stream inherits the gap, and a volume total from it reads low.

Envelope:

{
"block_number": 941006631,
"block_time": 1735689599852,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", { /* taker leg, shape below */ }],
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", { /* maker leg, shape below */ }]
]
}
FieldTypeMeaning
block_numberuint64Committed block height
block_timeuint64Consensus block timestamp, ms
eventsarray[address, fill] pairs. address is 0x-hex, lowercase, 20 bytes

One fill record, taker leg:

{
"market": 0,
"px": "6250000000000",
"sz": "50000",
"side": "B",
"oid": 366158135200,
"cloid": "0x00000000000000000000000000001234",
"tid": 1086003134703173,
"crossed": true,
"ts": 1735689599852,
"hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11",
"fee": "0.0251",
"feeToken": "USDC",
"closedPnl": "0.3135",
"startPosition": "-8025000",
"dir": "Close Short",
"builderFee": "0",
"liquidation": false,
"feeTrialEscrow": "0",
"builder": null,
"twapId": null,
"deployerFee": "0",
"liquidatedUser": null,
"markPx": "0"
}
FieldTypeUnitsMeaning
marketuint32idCanonical asset id of the market. The same numeric key the API accepts as coin
pxu128 stringraw priceExecution price
szu128 stringraw sizeExecuted size, always positive
sz_decimalsuint8The size plane sz and startPosition ride. Divide by 10^sz_decimals. This is the plane the print was MATCHED on, which is not always the market's current one
sidestringSide of this party: "B" buy, "A" sell
oiduint64idThis party's order id
cloidstring | absentClient order id, 0x plus 32 hex digits. Present on the taker leg only, and only when the order carried one
tiduint64idPrint id. Identical on both legs of one match. A NUMBER here, deliberately — this tape is byte-pinned input for the archive and the indexer, not a public API, so it keeps the numeric form the REST and WS surfaces gave up. It exceeds 2⁵³: parse it with a 64-bit reader
crossedbooltrue on the taker leg, false on the maker leg
tsuint64msFill timestamp. Equals block_time
hashstringTrace hash of the originating taker action: lowercase hex, no 0x. Empty string on the maker leg, and empty for system-injected actions
feedecimal string | absentwhole USDCFee this party paid. Negative means a rebate
feeTokenstring | absentFee asset. "USDC" today
closedPnldecimal string | absentwhole USDCRealized PnL on the closed part. "0" on a pure open
startPositioni128 string | absentraw sizeSigned leg size before this fill
dirstring | absentOne of "Open Long", "Close Long", "Open Short", "Close Short", "Long > Short", "Short > Long"
builderFeedecimal stringwhole USDCBroker carve charged on this fill. Taker leg only — the maker did not route the order, so its leg reads "0", as does any fill no broker routed
liquidationbooltrue on both legs of a forced-close print, else false. The absorbing maker learns it took a liquidation; that is the point of flagging both legs
feeTrialEscrowdecimal stringReserved. Always "0"
builderstring | nullBroker address that routed the order, 0x-hex. Taker leg only, null otherwise
twapIduint64 | nullidParent TWAP order of a slice. Taker leg only, null otherwise
deployerFeedecimal stringReserved. Always "0"
liquidatedUserstring | nullThe account whose position was closed, 0x-hex. Present on both legs of a forced-close print, null otherwise
markPxdecimal stringwhole USDCThe mark the liquidation ladder priced from when it classified the leg — not the fill price, and not a later mark. Present with liquidatedUser, else "0"
warning

Three traps on one record.

  1. fee, closedPnl, builderFee and markPx are whole USDC. px and startPosition on the same record are raw. Divide px by 10^8 and startPosition by 10^sz_decimals; divide neither of the other four. markPx is the trap inside the trap: it sits beside px and takes no divisor.
  2. The six settlement fields (fee, feeToken, closedPnl, startPosition, dir) are absent on a fill with no perp settlement leg, such as a spot fill. Treat absent as "no settlement data", not as zero.
  3. Two fields are reserved and carry constants: feeTrialEscrow and deployerFee. Do not read them as data. The other six in that group are real — read the rows above.

node_trades

The public trade tape. One record per match, not per party. This stream carries no counterparty address by design.

{
"block_number": 941006631,
"block_time": 1735689599852,
"trades": [
{
"market": 0,
"px": "6250000000000",
"sz": "50000",
"side": "B",
"tid": 1086003134703173,
"taker_oid": 366158135200,
"maker_oid": 366158130011,
"ts": 1735689599852,
"hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11"
}
]
}
FieldTypeUnitsMeaning
marketuint32idCanonical asset id
pxu128 stringraw pricePrint price
szu128 stringraw sizePrint size
sz_decimalsuint8The size plane sz rides — see node_fills
sidestringAggressor side: "B" the taker bought, "A" the taker sold
tiduint64idPrint id. Matches the tid on both node_fills records of this print
taker_oiduint64idAggressing order id
maker_oiduint64idResting order id
tsuint64msPrint timestamp. Equals block_time
hashstringTrace hash of the taker action: lowercase hex, no 0x. Empty for a system-injected action

Join node_trades to node_fills on tid when you need the parties.

This tape carries no unrecorded fill — no print from a modify, from a CoreWriter LimitOrder, from inside a multi_sig envelope, or from a frequent batch auction clearing. A volume total built from this tape reads low by them.

node_order_statuses

One record per order-status transition, keyed by the order owner.

{
"block_number": 941006640,
"block_time": 1735689600102,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", {
"market": 0,
"oid": 366158135210,
"cloid": "0x00000000000000000000000000001234",
"status": "filled",
"side": "B",
"limit_px": "6250000000000",
"sz": "40000",
"orig_sz": "60000",
"tif": "Gtc",
"reduce_only": false,
"avg_px": "6249800000000",
"total_sz": "40000",
"ts": 1735689600102,
"hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11"
}]
]
}
FieldTypeUnitsMeaning
marketuint32idCanonical asset id
oiduint64idOrder id. 0 on an error or noop record — the order never got an id
cloidstring | absentClient order id, 0x plus 32 hex digits. Absent on a maker execution record even when the order carried one
statusstringExactly one of "resting", "filled", "error", "noop", "parked". A "parked" record is an accepted trigger leg held off the book: it carries a real oid, and sz is the whole leg because it has never matched (not live yet: the token ships with the next node release). One filled record per (block, maker oid) — see maker execution records
sidestring"B" buy, "A" sell
limit_pxi128 stringraw priceLimit price of the order. Always present
szu128 stringraw sizeOn filled, the filled size. On resting, error and noop, the request size
orig_szu128 stringraw sizeRequest size at placement. "0" on a maker execution record
sz_decimalsuint8The size plane sz, orig_sz and total_sz ride — see node_fills
tifstring | absentTime in force: "Gtc", "Ioc", "Alo". Absent on a maker execution record
reduce_onlyboolReduce-only flag of the order. false on a maker execution record, whatever the order carried
avg_pxi128 string | absentraw priceAverage fill price. Present on filled only
total_szu128 string | absentraw sizeTotal filled size. Present on filled only
errorstring | absentFree text. On error it is the rejection reason. On noop it is why the order had no effect — a noop is a success, so do not read a present error as a rejection. Absent on resting and filled. There is no reason key
tsuint64msTransition timestamp. Equals block_time
hashstringTrace hash of the action that caused the transition: lowercase hex, no 0x. Always present. Empty when no signed action owns it, such as a forced close. On a maker execution record it is the TAKER's action hash
warning

sz changes meaning with status. On a partially filled order, sz is the filled part and orig_sz is the request. Use orig_sz when you want the size the trader asked for.

Maker execution records

Every record above comes from an order the account submitted. A resting order that is HIT submits nothing in that block, so the node derives its record from the block's fills instead. That record is a maker execution record. Every order lane records the maker's fill. Node 0.9.5 records the modify and multi_sig lanes. Node 0.9.6 records all four lanes: a CoreWriter LimitOrder that crosses on placement and a batch-auction clearing also derive the maker record. See every order lane records its fill.

A fill describes the fill, not the order. tif and cloid are absent, reduce_only is false and orig_sz is "0", whatever the order carried. Join to that order's own resting record on the same oid for the real values.

A resting record exists only for an order that a signed order, batch_order, scale_order, spot_order or chase_order placed, so for two groups of order the join has no target.

The first group is the two the node rests by itself: a chase leg after a reprice — a reprice cancels the leg and rests a new oid — and a TP/SL trigger leg that fired as a limit order. A chase's FIRST leg is not in this group: chase_order is a signed action and its opening leg does get a resting record. Only the legs a reprice rests are missing one. orig_sz and reduce_only are not recoverable for these two orders. The live-book read (open_orders) serves null for orig_sz, and no action ever submitted a request size for the leg. reduce_only on that read is a constant false on every book row, so it repeats the same wrong value. tif and cloid ARE real there: take them while the order still rests. Afterwards use the order kind: a chase leg is always "Alo" and never reduce-only; a fired trigger leg is always "Gtc" and always reduce-only, so reduce_only: false is wrong on exactly that record.

The second group is any order that an unrecorded-fill lane rested — a CoreWriter LimitOrder that rested before node 0.9.6. It rested an order with no resting record. That order is an ordinary resting order after that, so an ordinary taker DOES give it a maker execution record later — and that record has nothing to join to. All four fields stay missing for its whole life.

Some order lanes produce no maker execution record, because they produce no record at all. An order inside a multi_sig envelope, an order placed by modify or batch_modify, an order placed by CoreWriter LimitOrder, and a frequent batch auction clearing each write nothing to these streams: no node_fills print, no node_trades print, no status record of their own, and no maker execution record for the resting order they hit. The chain still matches the order and moves the money — see unrecorded fills. So a resting order with no filled record was not necessarily left alone.

The node sums every match against one oid inside one block into ONE record. So sz and total_sz are the size executed in that block, not the lifetime total, and avg_px equals limit_px: a resting order executes at its own price. A maker hit in three blocks gets three records, and the order can still rest after all three.

A forced close, a TWAP slice and a trigger order produce these records too. None of them sends an action, and the maker each one hits still needs its record.

The REST read built from this stream serves an absent key as null — see historical_orders. Absent here and null there are the same record.

{
"block_number": 941006641,
"block_time": 1735689600202,
"events": [
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", {
"market": 0,
"oid": 366158130011,
"status": "filled",
"side": "A",
"limit_px": "6250000000000",
"sz": "40000",
"orig_sz": "0",
"reduce_only": false,
"avg_px": "6250000000000",
"total_sz": "40000",
"ts": 1735689600202,
"hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11"
}]
]
}

No tif key, no cloid key, no error key. The hash is the taker's.

node_funding

One record per realized funding payment, per account, per market.

{
"block_number": 941006650,
"block_time": 1735689601000,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", {
"coin": 0,
"usdc": "-1.5",
"time": 1735689601000,
"szi": "-12.5",
"fundingRate": "0.0000125"
}]
]
}
FieldTypeUnitsMeaning
coinuint32idMarket asset id the funding settled on
usdcdecimal stringwhole USDCSigned payment. + received, paid
timeuint64msSettlement timestamp. Equals block_time
szidecimal stringwhole unitsSigned position size at settlement. Already human, do not divide
fundingRatedecimal stringfraction per hourThe rate applied at this settlement. "0.0000125" is 0.00125 % per hour

szi and fundingRate are the values stamped at the settlement site. Do not re-derive them from later state.

node_ledger

One record per account whose balance a committed action moved, excluding funding. A peer transfer emits two records. Their delta values net to zero.

{
"block_number": 941006660,
"block_time": 1735689602000,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", {
"kind": "transfer",
"delta": "-25.5",
"coin": 0,
"time": 1735689602000,
"counterparty": "0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567"
}],
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", {
"kind": "transfer",
"delta": "25.5",
"coin": 0,
"time": 1735689602000,
"counterparty": "0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345"
}]
]
}
FieldTypeUnitsMeaning
kindstringCoarse class, such as "transfer", "withdraw", "deposit" or "liquidation". Treat an unknown value as data
deltadecimal stringwhole tokensSigned balance change. outflow, + inflow. At most 8 decimal places
coinuint32idToken asset id. 0 is USDC
timeuint64msTimestamp. Equals block_time
counterpartystring | absentThe other party's 0x address on a peer transfer. Absent on a single-sided move
marketuint32 | absentidMarket asset id the position closed on. "liquidation" only
mark_pxdecimal string | absentwhole USDCPrice the leg closed at. "liquidation" only. Absent when the market had no usable mark

A liquidation record is a forced close or a delist settlement. It has no cause field, so the two look the same here. The WS ledger_updates feed carries the cause. NOT LIVE YET: a live node settles no position at a delist.

The event order inside events is deterministic on replay, so the index of an event within its block is a stable per-block discriminator.

warning

coin is a token id here, not a market id. node_funding also has a field named coin, and there it is a market asset id. The two id spaces are different. Resolve node_ledger.coin against the token registry and node_funding.coin against the market universe.

warning

This stream is not a complete balance history. Fills, fees and funding are not recorded here. Do not reconstruct an account balance from node_ledger alone.

node_gov

One record per governance vote cast, plus one record per enactment. This is the only durable record of who voted and what an enactment changed. The live tally is transient: a quorum drains it and a timeout prunes it.

{
"block_number": 941006670,
"block_time": 1735689603000,
"events": [
{
"type": "vote_cast",
"round": 2000007,
"category": "dynamic_risk",
"sub_id": 7,
"action": "setDynamicRiskParam",
"asset": 0,
"coin": "BTC",
"validator": "0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345",
"stake": "4000000",
"total_stake": "10000000",
"quorum_met": true,
"payload": "0x01ab",
"time": 1735689603000
},
{
"type": "vote_enacted",
"round": 2000007,
"action": "setDynamicRiskParam",
"asset": 0,
"coin": "BTC",
"changes": [
{ "field": "max_leverage", "prior": "20", "new": "25" }
],
"agreeing_stake": "7000000",
"total_stake": "10000000",
"time": 1735689603000
}
]
}
FieldTypeMeaning
block_numberuint64Committed block height
block_timeuint64Consensus block timestamp, ms
eventsarrayCasts and enactments in emission order

Casts and enactments share one list, so their relative order inside a block is preserved. That order is the per-block discriminator. Do not sort events.

Each event is one of two shapes. Read type to tell them apart.

vote_cast

FieldTypeUnitsMeaning
rounduint64idSynthetic vote round this cast belongs to
categorystringThe round's vote family: "dynamic_risk", "vote_global", "mb_configure_chain", "oracle_weights", "circle_promotion_attest", "option_listing", "option_auto_list", "spot_margin_params", or "proposal"
sub_iduint64Offset of round inside its category band. Under "proposal" it equals round
actionstringWire action name the vote targets, such as "setDynamicRiskParam"
assetuint32 | absentidMarket asset id the vote targets. Absent on a chain-global vote
coinstring | absentMarket symbol for asset. Absent when the vote is global, or the market carries no listing spec
validatorstringCasting validator's 0x address, lowercase, 20 bytes
stakedecimal stringwhole stake unitsThis validator's own weight at the cast
total_stakedecimal stringwhole stake unitsQuorum denominator: total active, non-excluded stake at the cast
quorum_metbooltrue when this cast carried the payload to two thirds of total_stake
payloadstringRaw vote bytes, 0x-hex, undecoded. Two validators agree when these bytes are identical. Decode it per action
timeuint64msCast timestamp. Equals block_time

vote_enacted

FieldTypeUnitsMeaning
rounduint64idThe round that reached quorum
actionstringWire action name
assetuint32 | absentidMarket asset id. Absent on a chain-global change
coinstring | absentMarket symbol for asset
changesarrayThe fields the enactment moved, in a fixed order
agreeing_stakedecimal stringwhole stake unitsWeight that agreed on the enacted payload
total_stakedecimal stringwhole stake unitsQuorum denominator at enactment
timeuint64msEnactment timestamp. Equals block_time

One entry of changes:

FieldTypeMeaning
fieldstringName of the parameter the enactment moved
priorstring | nullThe effective value just before the write, resolved through the same ladder a read uses
newstringValue after the write

Values in changes stay strings. One enactment can move several fields of one struct, and those fields are not one numeric type.

warning

Five traps on this stream.

  1. Every cast is recorded, quorum or not. A vote short of quorum ages out of the live tally, but its vote_cast records stay in the archive. There is no "rejected" record: this governance model has a stake threshold and a timeout, no reject vote. The only sign a vote never passed is the absence of a vote_enacted on the same round.
  2. stake is the caster's own weight, not a running total. Use total_stake as the denominator, and agreeing_stake on vote_enacted as the numerator.
  3. agreeing_stake and total_stake on vote_enacted can read "0". They are joined from the quorum-carrying vote_cast in the same block. An enactment that fires from another trigger has no such cast in its block, so both read "0". Look up the earlier vote_cast with quorum_met: true on the same round.
  4. sub_id changes meaning with category. In a named category it is an offset inside that category's round band. Under "proposal" the round is the proposal id itself, and sub_id repeats it.
  5. prior: null does not mean "first ever value". It means the read path could not resolve an effective prior at all. Never read null as zero, and never as "previously unset".

A quorum_met: true cast does not guarantee a vote_enacted follows. The node emits the enactment only after the state write it describes succeeds. Join on round; do not assume a match exists.

node_bridge_outbox

The bridge withdrawal outbox, as one envelope per block that moved it. The node diffs the committed outbox against the last envelope it wrote, and writes nothing when nothing moved.

An entry's status is the same value, from the same derivation, that the bridge /info reads serve. This stream copies it. It never recomputes it.

Four record kinds, told apart by type:

typeWhen it appears
admissionThe recorder's first sight of this economic_id. The only kind that carries msg
transitionThe derived half moved: co-signature count, status, or release time
rebindA deployment change re-derived this entry. Emitted for every open entry on that block
removedThe entry left the outbox, through release or the retention prune. Terminal

A withdrawal is admitted:

{
"block_number": 941006680,
"block_time": 1735689604000,
"events": [
{
"type": "admission",
"economic_id": "0x7e1fbb3c5a2d9104e6f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e",
"message_id": "0x2c9d40a1b7e35f8206c4d1e9f0a3b5c7d8e9f0a1b2c3d4e5f60718293a4b5c6d",
"status": "awaiting_cosignatures",
"pending_cosigner_count": 0,
"released_at_ms": null,
"msg": {
"chain": 1,
"user": "0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345",
"asset": 0,
"token": "USDC",
"amount_units": "25000000",
"dst_addr": "0x0000000000000000000000008a1b2c3d4e5f60718293a4b5c6d7e8f901234567",
"nonce": 41,
"ts_ms": 1735689604000
}
}
]
}

Co-signatures reach quorum in a later block:

{"block_number":941006740,"block_time":1735689610000,"events":[{"type":"transition","economic_id":"0x7e1fbb3c…","message_id":"0x2c9d40a1…","status":"ready_to_release","pending_cosigner_count":0,"released_at_ms":null}]}

The entry is released and leaves the outbox:

{"block_number":941009000,"block_time":1735689840000,"events":[{"type":"removed","economic_id":"0x7e1fbb3c…","message_id":"0x2c9d40a1…","status":"released","pending_cosigner_count":0,"released_at_ms":1735689840000}]}

Envelope

FieldTypeMeaning
block_numberuint64Committed block height
block_timeuint64Consensus block timestamp, ms
eventsarrayOutbox records for this block. Can be empty
configsarray | absentThe full current per-chain deployment set. Present only on a block where it differs from the last envelope
withdrawals_haltedbool | absentChain-wide refusal of new withdrawals. Present on exactly the blocks configs is
warning

configs and withdrawals_halted mean "replace the stored set", not "a rotation happened". They ride any block whose committed rows differ from the last emitted view. That includes the first envelope after a restart, because the node's memory of the rows starts empty. Reading their presence as a rotation marker gives a false rotation on every restart. Compare the rows to decide.

One event

FieldTypeUnitsMeaning
typestring"admission", "transition", "rebind", or "removed"
economic_idstring0x-hex, 32 bytes. The upsert key. Rotation-invariant: it names the same withdrawal before and after a rotation
message_idstring0x-hex, 32 bytes. The current signing digest. It moves on a rotation
statusstring"awaiting_cosignatures", "ready_to_release", "stranded_on_retired_domain", or "released". Derived by the node
pending_cosigner_countuintCo-signatures held against message_id that are short of quorum. 0 once quorum is reached
released_at_msuint64 | nullmsConsensus timestamp of the release. null until the entry is released
msgobject | absentThe immutable half of the withdrawal. Present on "admission" only

msg:

FieldTypeUnitsMeaning
chainuint8idDestination chain: 1 Base, 2 Arbitrum
userstringThe account that opened the withdrawal, 0x-hex, 20 bytes
assetuint32idToken asset id, the same id space as node_ledger.coin. Not a market id
tokenstringSymbol for asset, resolved once at admission. A later rename does not rewrite it
amount_unitsu128 stringraw token base unitsDivide by the token's own on-chain decimals. Do not divide by sz_decimals
dst_addrstringDestination address on chain, 0x-hex, 32 bytes, left-padded
nonceuint64Per-chain anti-replay nonce
ts_msuint64msWhen the withdrawal entered the outbox
warning

Four rules a consumer gets wrong.

  1. economic_id is the upsert key. message_id is not. The message id is the signing digest under the live deployment row, so a rotation moves it. Fold on the message id and one withdrawal counts twice across a rotation.
  2. admission is not "first ever". The recorder's memory of the outbox is node-local and is never persisted, so a restart re-emits every open entry as an admission, at whatever status it holds right then. Always UPSERT on economic_id. Never read an admission as an arrival.
  3. removed is terminal, and its status is not always "released". It reads "released" when the release is confirmed. Otherwise it carries the entry's last known status, because the entry left through the retention prune. Either way the economic_id never returns.
  4. status is derived. Do not recompute it. It folds the live deployment row through the node's own derivation. "stranded_on_retired_domain" is reachable only from that side: a consumer that recomputes status from configs and co-signature counts never sees a stranded entry.

configs[]

One entry per configured chain, in ascending chain id. Each entry is the committed deployment row.

FieldTypeMeaning
chainuint81 Base, 2 Arbitrum
contract_addressstringBridge contract identity, 0x-hex, 32 bytes, left-padded
validator_quorum_threshold_bpsdecimal stringCo-signature threshold in basis points
replay_nonceuint64Current outbound replay nonce for this chain
pausedbooltrue when this chain's lane is paused
evm_chain_iduint64The destination chain's own EVM chain id
evm_contract_addressstringBridge contract address on that chain, 0x-hex, 20 bytes
validator_set_epochuint64Validator-set epoch the row binds to. A rotation moves it
release_retention_msuint64Configured retention window. 0 is the unset sentinel, not "no retention"
effective_release_retention_msuint64The window actually in force. Read this one
scan_policyobjectDeposit-scan settings, below

scan_policy:

FieldTypeMeaning
confirmations_onlyboolCredit on confirmations alone
confirmationsuint64Configured confirmation depth. 0 is the unset sentinel
effective_confirmationsuint64The depth actually in force. Read this one
confirmations_only_depthuint64Depth used when confirmations_only is set
usdc_tokenstringUSDC token address on that chain, 0x-hex, 20 bytes
raw_transfer_creditbooltrue when a plain token transfer to the contract is credited

The raw and effective_* pairs both ship because release_retention_ms and confirmations are 0-as-unset sentinels. A raw 0 alone tells you nothing about the window in force.

Deriving the rotation verdict

A deployment rotation strands every entry that is "ready_to_release" when it fires. Those entries already hold a release-ready co-signature quorum under the domain the rotation retires. The outbound replay guard keys on the economic id, so re-finalization under the new domain is suppressed and no releasable multisig can ever appear again. The funds are debited and unreleasable.

The verdict is therefore a fold of this stream: upsert every event on economic_id, drop the entries whose last event is removed, then count the survivors whose status is ready_to_release. A rotation is safe only when that count is zero.

Positive control

A fold over the wrong path returns zero, and zero reads exactly like the all-clear. Check the reading before you trust it.

What you observeWhat it means
The stream root or its hourly files do not existThe stream was never enabled here, or the data directory is wrong. Not "no withdrawals"
The stream root exists but holds no cursor fileNo block has been recorded since the stream was turned on. Not "no withdrawals"
cursor is far behind the chain's committed heightThe view is stale and incomplete. A zero count here proves nothing
The fold finds no entries at all, everSuspect the path. A live chain that has served any withdrawal has admissions in the archive
cursor is at the committed height, entries exist, and none reads ready_to_releaseThe real all-clear

The control is the fourth row. Confirm your fold sees entries in some state before you trust it seeing none in one state.

What this stream answers

It answers: does any withdrawal sit at ready_to_release right now, is any withdrawal stranded, how old is the oldest pending entry, and what deployment row is committed per chain.

It does not carry inbound deposits, which are a separate flow. It reports a count of co-signatures, never which validators signed. A released entry means the chain released it; confirming the payout landed needs a read of the destination chain, not this stream.

warning

The diff runs on the tip block only. The resume cursor advances through every block, so a catch-up replay reports no hole — but the node compares state only on the block that owns it. A withdrawal that moved through several statuses entirely inside a replayed range surfaces as one admission at the status it holds when the node catches up. The intermediate moves are not recorded. This happens across a restart, never in normal live operation.

node_equity_snapshots

A sample tape, not an event tape. One line per sample. One sample per UTC hour of consensus block time, taken on the first committed block of that hour. Each hourly file therefore holds exactly one line.

One line carries every account that has committed state, in ascending account-address order.

{
"block_number": 941006700,
"block_time": 1735689600000,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", { "equity": "300", "ts": 1735689600000 }],
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", { "equity": "50.5", "ts": 1735689600000 }]
]
}
FieldTypeUnitsMeaning
equitydecimal stringwhole USDCMark-aware account value: collateral plus unrealized PnL. The same number the /info account read serves
tsuint64msSample timestamp. Equals block_time

Use this stream to draw a portfolio-value curve. A curve rebuilt from flows alone misses bridge credits and can go negative.

The sample costs one walk over every account and every market, so it stays off the validator path. The node does not sample during start-up replay.

node_asset_ctxs

A sample tape, not an event tape. One line per sample, one sample every 5 seconds of consensus block time. Each line carries every market in the committed universe — perps first, then tradable spot pairs, each group in ascending market id.

{
"block_number": 941006700,
"block_time": 1735689600000,
"ctxs": [
{ "market": 0, "mark_px": "5000000000000", "oracle_px": "4999500000000" },
{ "market": 3, "mark_px": "125000000", "oracle_px": "0" }
]
}
FieldTypeUnitsMeaning
marketuint32Market id. Perps come first, then spot pairs
mark_pxdecimal stringraw 1e8The market's committed mark price
oracle_pxdecimal stringraw 1e8The committed oracle price
warning

"0" means NO COMMITTED PRICE, not a price of zero. Every spot pair reads "0" for oracle_px, because a spot pair has no oracle. A perp also reads "0" before its first oracle push. Treat "0" as absent — a consumer that averages it in will drag every derived number toward zero.

Both prices are on the raw 1e8 plane, like the rest of the node_* family. Divide by 100000000 before you display them.

Both prices are also snapped to the market's tick before the node records them, on the same grid /info serves. Sub-tick precision never reaches the archive. A later tick-size change does not re-grid the samples already written, so an old sample keeps the grid it was recorded on.

Use this stream to build mark and oracle candles. The 5-second cadence gives the smallest (1-minute) candle twelve samples. It is a price series, not a trade series: a bar exists in every window the samples cover, whether or not anything traded.

node_actions

One record per action in a committed block payload — every action, the ones the chain applied and the ones it dropped. This is the per-action tape: it answers "what did this account send, and what did the chain do with it".

Enable it with write_actions. It is off by default, like every stream. Each record names a sender and carries the submitted action body, so this stream de-anonymizes order flow in full. Run it on a non-validating node.

A block whose payload carried no action writes no line.

Envelope:

{
"block_number": 941006631,
"block_time": 1735689599852,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", { /* record, shape below */ }],
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", { /* record, shape below */ }]
]
}
FieldTypeMeaning
block_numberuint64Committed block height. The same height node_blocks writes
block_timeuint64Consensus block timestamp, ms
eventsarray[sender, record] pairs, in action_index order. sender is 0x-hex, lowercase, 20 bytes

Do not sort events. The list is already in payload order, and that order is the cursor. See action_index.

Three records, one applied and two rejected:

{
"block_number": 941006631,
"block_time": 1735689599852,
"events": [
["0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345", {
"action_index": 0,
"action_type": "Order",
"action_hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11",
"signer": "0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345",
"nonce": 1735689599801,
"expires_after": 0,
"status": "success",
"error_code": null,
"payload": {
"type": "submit_order",
"order": {
"owner": "0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345",
"market": 0,
"side": "bid",
"kind": "limit",
"size": 50000,
"limit_px": 6250000000000,
"tif": "gtc",
"reduce_only": false
}
},
"result": {
"statuses": [
{
"market": 0,
"oid": 366158135200,
"status": "filled",
"side": "B",
"limit_px": "6250000000000",
"sz": "50000",
"orig_sz": "50000",
"tif": "Gtc",
"reduce_only": false,
"avg_px": "6249800000000",
"total_sz": "50000",
"ts": 1735689599852,
"hash": "9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11"
}
]
}
}],
["0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567", {
"action_index": 1,
"action_type": "Cancel",
"action_hash": "4d1f0b7a2e93c56480a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcde",
"signer": null,
"nonce": 1735689599802,
"expires_after": 0,
"status": "failure",
"error_code": "DROPPED_INVALID_SIGNATURE",
"payload": { "type": "cancel_order", "cancel": { "market": 0, "oid": 366158130011 } },
"result": null
}],
["0x5c4d3e2f1a0b9988776655443322110099aabbcc", {
"action_index": 2,
"action_type": "Order",
"action_hash": "e30bb4c7715f2a9d0c8e1f3b5d7a9c0e2f4b6d8a0c2e4f6b8d0a2c4e6f8b0d21",
"signer": "0xaabbccddeeff00112233445566778899aabbccdd",
"nonce": 1735689599803,
"expires_after": 1735689659000,
"status": "failure",
"error_code": "MARGIN_INSUFFICIENT",
"payload": {
"type": "submit_order",
"order": {
"owner": "0x5c4d3e2f1a0b9988776655443322110099aabbcc",
"market": 0,
"side": "ask",
"kind": "limit",
"size": 900000000,
"limit_px": 6260000000000,
"tif": "gtc",
"reduce_only": false
}
},
"result": {
"statuses": [
{
"market": 0,
"oid": 0,
"status": "error",
"side": "A",
"limit_px": "6260000000000",
"sz": "900000000",
"orig_sz": "900000000",
"tif": "Gtc",
"reduce_only": false,
"error": "precondition failed: insufficient margin: required 56340.00, available 812.55",
"ts": 1735689599852,
"hash": "e30bb4c7715f2a9d0c8e1f3b5d7a9c0e2f4b6d8a0c2e4f6b8d0a2c4e6f8b0d21"
}
]
}
}]
]
}
FieldTypeNullableMeaning
action_indexuint32noPosition of this action in the block payload, from 0. Dense over every action in the block, rejected ones included. See the cursor rule
action_typestringnoThe action kind, from a closed set. Not the type string in payload — see the vocabulary
action_hashstringnoCorrelation hash: lowercase hex, no 0x. Empty string on an injected or system action, and on a pre-fork block. See the hash rule
signerstring | nullyesThe address whose EIP-712 signature authorized this action, 0x-hex. It differs from the sender on an agent-signed action. See the signer rule
nonceuint64noThe action's nonce, as submitted
expires_afteruint64noSigned expiry, ms. 0 means the action never expires — that is the common value
statusstringno"success" or "failure". Nothing else
error_codestring | nullyesWhy it failed. null on success. See rejections
payloadobject | nullyesThe action body as submitted. null when the action carries no signed body. See the payload rule
resultobject | nullyesWhat the action produced. null for every action that is not order-shaped. See result

action_index is half the seek cursor

action_index is the action's position in the committed block payload. Every node derives it from the same committed bytes, so (block_number, action_index) is a total and stable order over the whole chain. Page a detail view on (block_number DESC, action_index DESC) and rows never repeat and never skip.

The index counts EVERY action in the payload, applied and rejected alike. That is what keeps it stable. An index that counted only applied actions would shift the moment a rejection rule changed, and every stored cursor would then point at a different row.

Two consequences:

  • The indices in one envelope are contiguous from 0. A missing index means your reader dropped a record, not that the chain skipped one.
  • Re-reading a file re-derives the same pair. Key your rows on (block_number, action_index) and a re-read inserts nothing new.

action_hash is the detail-route key, not the cursor. It is stable across replay too, but it is empty on injected and system actions, so it does not order a block on its own.

action_hash is the hash the exchange returned

action_hash is the same value POST /exchange returned to the submitter in its admission response. A submitter that logged its action_hash can find its action on this tape with a string match, and no other join.

One difference in form: the tape writes it without the 0x prefix. The /exchange response writes it with one. Strip or add the prefix at the join — this is the same rule the hash field on node_fills and node_trades already follows, and those hashes are the same value again for an action that produced a fill.

An empty action_hash is not an error. An injected or system action carries no signed body, so there is no hash to compute. Those rows are joined by (block_number, action_index) alone.

A rejected action is on the tape, with its reason

Branch on status. "success" means the chain applied the action. "failure" means it did not. There is no third value, and error_code is null if and only if status is "success".

A failure is one of two kinds, and error_code tells them apart.

A DROPPED_* code means the action never dispatched. The commit loop refused it before it ran. It consumed no nonce and changed no state.

error_codeCause
DROPPED_MALFORMED_SENDERThe payload's sender field is not 20 bytes. Only a faulty proposer produces this row, and sender reads as the zero address
DROPPED_NOT_PROPOSER_BOUNDAn injected validator or system action arrived under a sender that is not the block proposer
DROPPED_EXPIREDexpires_after is at or before block_time, or timed expiry is not armed on this chain
DROPPED_RETIREDThe action kind is retired and no longer dispatches
DROPPED_PAYLOAD_KEYS_INACTIVEA CoreEvmTransfer carried payload keys that are not active yet
DROPPED_INVALID_SIGNATURENo authorized signature recovered from the action
DROPPED_NONCE_REPLAYThis (sender, nonce) pair is already used

Any other code means the action dispatched and a state rule refused it. Those codes are the same catalog /exchange answers with — read them in error codes. PRECONDITION_FAILED is the documented catch-all.

The two sets do not overlap, so error_code.startsWith("DROPPED_") is a safe test for "the chain never ran it".

warning

A batch is a success when ANY leg landed. A BatchOrder reads status: "success" and error_code: null when at least one leg was accepted, even if the other legs were rejected. The per-leg verdicts are in result.statuses[].status. A consumer that wants "did every leg land" reads result, never status. A batch of one therefore reads exactly like a single Order, which is the point.

signer says who authorized the row

signer is the address whose EIP-712 signature the chain recovered and accepted. sender is the account the action acts for. They differ on an agent-signed action: the sender is the master account, the signer is the agent wallet. Keep them in two columns.

signer: null does not mean "unauthorized". It means no per-action wallet signature authorized this row. There are four cases:

  • an injected or system action, which the proposer binding authorizes instead;
  • a multi_sig envelope, whose authority is a roster, not one address;
  • a block below the signature-verification fork height;
  • a rejection, where no signature ever verified.
danger

On a DROPPED_* row, trust sender only when signer is non-null. A drop that precedes signature recovery carries whatever address the payload claimed. Attributing a DROPPED_INVALID_SIGNATURE or DROPPED_NOT_PROPOSER_BOUND row to that account lets anyone put a rejected action on anyone's page. Rows the chain applied are always authorized, so status: "success" needs no such test.

payload is the bytes the submitter signed

payload is the action body as posted, not a re-rendering of it. It is the preimage of action_hash:

action_hash = keccak256( payload_bytes ‖ sender_20 ‖ nonce_be8 [ ‖ expires_after_be8 ] )

The trailing 8 bytes are appended only when expires_after is non-zero. Recompute the hash to prove the tape did not alter the body.

Three rules go with it:

  • payload: null USUALLY means there was no signed body. An injected or system action, and a pre-fork block, carry none. action_hash is "" on those rows, and there is nothing to verify.

    Do not read a null payload as proof of that. It is also null when the body was not parseable JSON, and there action_hash IS set — a 64-hex string over the bytes the block carried. So the pair to test is payload AND action_hash, never payload alone. Only a faulty proposer produces such a row: /exchange strict-parses before it signs, so no ordinary client can.

  • payload uses the request's own number planes. It is an /exchange body, so its prices and sizes are bare JSON numbers on the raw planes, not the strings the rest of these streams use. The result block on the same record does use strings. Read Number planes before you divide anything.

  • A body posted with line breaks is re-serialized compact. A raw newline inside a record would break NDJSON framing, so the node writes the same JSON value in compact form. The value is identical; the bytes are not, so the hash does not recompute from that row. Every SDK client posts a compact body, so this is rare, and the tape does not flag which rows it touched. Treat a failed recompute as this case, and take action_hash as the authority.

result carries the order legs

result is null on every action that is not order-shaped, success included. An Order, a BatchOrder and a ChaseOrder fill it in:

{ "statuses": [ /* one record per placed leg */ ] }
  • Order — one element.
  • BatchOrder — one element per placed leg, in leg order. Parked TP/SL protective legs never rest, so they are not in the list, and a batch that placed nothing has an empty list.
  • ChaseOrder — one element, plus "chase_oid": <uint64> beside statuses. That handle is the cancel_chase key, not the leg's oid.

Each element is the same record node_order_statuses writes, with the same fields and the same planes. Its hash repeats the row's own action_hash, and its ts repeats block_time.

result is the only per-leg surface. The record has no per-leg column of its own.

action_type vocabulary

action_type is the protocol's own name for the action kind. It is a closed set, it is append-only, and a name is never reused for another kind.

warning

action_type is not the type string inside payload. The wire body uses snake_case ("submit_order"); the tape uses the protocol name ("Order"). The two do not always share a word: the body of a market seed is "seed_market" and its action_type is "Listing". Map the pair explicitly. Never derive one from the other.

Treat an unknown value as data, not as an error. A new action appends a new name, and a reader that rejects unknown names breaks on the next release. Store the string.

The list below names the kinds a caller can act on. Node-operations kinds also appear on the tape and are not listed here.

  • TradingOrder, Cancel, CancelByCloid, Modify, BatchModify, ScheduleCancel, TwapOrder, TwapCancel, Liquidate, BatchOrder, BatchCancel, CancelAllOrders, ChaseOrder, CancelChase, ScaleOrder, CancelScale, SubmitEncryptedOrder, SubmitDecryptionShare, RfqRequest, RfqQuote, RfqAccept, FbaSubmit
  • Spot and EarnSpotOrder, SpotCancel, SpotSend, SpotMarginDeposit, SpotMarginWithdraw, SpotMarginOpen, SpotMarginClose, EarnDeposit, EarnWithdraw, SpotGenesis
  • MarginUpdateLeverage, UpdateIsolatedMargin, TopUpIsolatedOnlyMargin, UserPortfolioMargin, SetPositionMode
  • TransfersUsdSend, SendAsset, Withdraw3, SendToEvmWithData, UsdClassTransfer, CoreEvmTransfer
  • Sub-accountsCreateSubAccount, SubAccountTransfer, SubAccountSpotTransfer
  • VaultsCreateVault, VaultTransfer, VaultDistribute, VaultModify, NetChildVaultPositions, VaultWithdraw, SetMetaliquiditySet, RegisterMetaliquidityOperator
  • AccountApproveAgent, SetDisplayName, SetReferrer, ApproveBuilderFee, ConvertToMultiSigUser, MultiSig, Noop, UserSetAbstraction, AgentSetAbstraction, PriorityBid, ClaimBuilderRewards, ClaimReferralRewards
  • StakingTokenDelegate, ClaimRewards, LinkStakingUser, RegisterValidator, ExtendLongTermStaking, StakingDeposit, StakingWithdraw, BorrowLend
  • Governance and validatorGovPropose, GovVote, VoteGlobal, CValidator, CSigner, ValidatorL1Vote, ValidatorL1Stream, VoteAppHash, ForceIncreaseEpoch, ApproveUpgrade, ArmFeatures, SubmitSlashingEvidence, SetDynamicRiskParam, SetOracleWeights, SetDisabledVenues, SetFundingFormula, SetFeeSchedule, SetPrimeAccount, SetPmShockGrid, SetPopulationTarget, SetSpotMarginParams, SetMarketTick, SetMarkMode, SetSpotMinNotional, SetPerpMaxOpenInterest, SetThresholdEpochKey, GovAdjustSpotValue, GovAdjustSpotBalance, MintTreasury, BurnTreasury, CreateEarnPool, ConfigTreasuryBackstop, TreasuryBackstopDraw, DisableDex, QuarantineUser, ReactivateUser, ForceClosePosition, RegisterSpot, Listing, Delisting, OptionListing, OptionAutoList, FbaConfigure
  • Market deploymentPerpDeploy, SpotDeploy, SetGlobal, SubmitGasAuctionBid, Mip3SetOraclePx
  • BridgeBridgeAttest, BridgeWithdraw, BridgeEmergencyPause, BridgeConfigureChain, RegisterBridgeCosigner, BridgeWithdrawReleased, ValidatorSignWithdrawal, VoteEthFinalizedWithdrawal, VoteEthFinalizedValidatorSetUpdate, SignValidatorSetUpdate, ValidatorBridgePause, CirclePromotionSchedule, CirclePromotionCustodyAttest, CirclePromotionAdvance, CirclePromotionPruneCosig
  • EVMEvmRawTx, EvmUserModify, FinalizeEvmContract
  • SystemSystemBole, SystemSpotSend, CWithdraw, CUserModify, SystemUserModify, OracleSubmit

Limits

What this tape does not carry:

  • No effects. The record says what the action was and whether it landed, not what it moved. Money is node_ledger, executions are node_fills, funding is node_funding. Join on block_number and, where a hash exists, on action_hash.
  • No signature bytes. signer reports that a signature authorized the row. The signature itself is not written.
  • No multi-sig roster. A multi_sig envelope is ONE row with signer: null. The inner actions are not unpacked onto the tape, and the co-signers are not listed.
  • No EVM transactions. The tape covers the Core actions in the block payload. An EVM transaction in the same block gets no row, though node_blocks counts it in tx_count.
  • Nothing that never committed. An action that no proposer put in a payload has no row — a rate-limited submission, an eviction from the mempool, and an admission that timed out all leave nothing. An accepted response is not evidence of a row.
  • No receive time. block_time is the only clock on the record. The tape does not say when the node first saw the action.
  • No line for a block with no actions. Absence is not an archive hole. Use node_blocks when you need a row for every height, and test for a gap line to find a real hole.

node_blocks

One line per committed block, carrying the block head only. This is the one stream that writes on an empty block, so its heights form a contiguous sequence between gaps. Use it to rebuild a block tape that has a row for a block which carried no action.

The record is flat. It has no events array and no owner address.

{
"block_number": 941006700,
"block_time": 1735689600000,
"round": 941006700,
"epoch": 9410,
"proposer": 2,
"hash": "0x9c22cbcd0ee34b90987b76f92544e0e64d8f4a0e2b2f7bc1d3f0c8ffb61d0a11",
"tx_count": 3,
"evm_block_number": 235251
}
FieldTypeUnitsMeaning
block_numberuint64Committed block height
block_timeuint64msConsensus block timestamp
rounduint64Consensus round of this block. It equals block_number under the current two-chain rule, and ships apart because both are on the wire
epochuint64Consensus epoch of round
proposeruint64indexValidator-set index of the leader that proposed this block. Not an address
hashstringBlock hash, lowercase hex with the 0x prefix. The same value the block_info read serves
tx_countuint64Core actions plus EVM transactions in the block payload
evm_block_numberuint64 or nullThe EVM block this Core round minted, or null if it minted none

evm_block_number is the join between a Core round and an EVM block. The EVM mints a block only when its own period elapses, so the ratio to Core rounds is not fixed — it moves with the chain's cadence. Read it this way:

  • null means this round minted no EVM block. It is not 0 and it is not a missing key. Do not default a missing key to 0; that number names a real block.
  • A number means this round minted that EVM block, empty blocks included. An EVM block with no transactions still gets a number and still appears here.
  • The value is this round's own EVM block, not the running EVM tip. Most rounds carry null; only the round that closes an EVM period carries a number, and it is always that block, never a later one.
warning

Three traps on this record.

  1. hash carries 0x. The hash on node_fills and node_trades does not. They are different fields with the same name. Do not carry one parsing rule across.
  2. tx_count: 0 is ambiguous. A genuinely empty block reads 0. A payload the node could not decode also reads 0. This stream cannot tell the two apart. Cross-check against replica_cmds when the difference matters.
  3. evm_block_number: 0 never appears. The EVM numbers its blocks from 1, so a round with no EVM block reads null, never 0. If you see 0, your decoder defaulted a missing field — fix the decoder, not the data.

node_blocks against replica_cmds

Both write one line per committed block, including an empty one. That is not the difference.

Questionnode_blocksreplica_cmds
Who proposed it, at what round and epoch?YesNo — it carries none of the three
Transaction total, core actions and EVM transactions?Yes, tx_countNo — action_count counts core actions only
State hash after the block?NoYes, app_hash
What the block did: fills, orders, positions, funding?No — head onlyYes, the full body
Hash form0x-hex stringarray of byte numbers
Path shape.../node_blocks/hourly/{date}/{hour}.../replica_cmds/{date}/{hour}

Take node_blocks for a light, always-present block tape with the consensus routing fields. Take replica_cmds to drive a full indexer from one file — but its action_count undercounts a block that carried EVM transactions, so do not read it as a transaction total.

replica_cmds

A single envelope per committed block, carrying the block header plus that block's fills, order events, position read-throughs, and funding rates. It is the densest stream and the one to use when you want one file to drive a full indexer.

Two things make it different from every node_* stream:

  • Addresses and hashes are arrays of byte numbers, not hex strings.
  • Prices and money are whole units, not the raw planes. Sizes are mixed. See the warning below.

The JSON key order is fixed, and the path has no hourly/ segment.

{
"height": 1234567,
"ts_ms": 1735689599852,
"action_count": 3,
"block_hash": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],
"app_hash": [160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191],
"fills": [
{
"fill_seq": 0,
"market_id": 5,
"taker_addr": [17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17],
"maker_addr": [34,34,34,34,34,34,34,34,34,34,34,34,34,34,34,34,34,34,34,34],
"side": 0,
"size": "0.5",
"price": "100.55",
"fee_bps": 2
}
],
"order_events": [
{
"oid": 366158135200,
"owner": [17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17],
"market_id": 5,
"side": 0,
"kind": 0,
"original_size": "100000",
"remaining_size": "50000",
"limit_px": "100.50",
"status": 1,
"created_ts_ms": 1735689599852,
"updated_ts_ms": 1735689599852
}
],
"position_deltas": [
{
"owner": [17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17],
"market_id": 5,
"size": "0.5",
"entry_px": "100.55",
"unrealized_pnl": "0",
"updated_block_height": 1234567
}
],
"funding_events": [
{ "market_id": 5, "rate_num": 20, "rate_denom": "10000" }
]
}

Header

FieldTypeMeaning
heightuint64Committed block height
ts_msuint64Consensus block timestamp, ms
action_countuint32Number of actions in the block payload
block_hasharray of 32 uint8Block hash, byte array. Not hex
app_hasharray of 32 uint8Application state hash, byte array. Not hex

fills[]

FieldTypeUnitsMeaning
fill_sequint64indexPosition within this block, from 0. Not a global sequence
market_iduint32idCanonical asset id
taker_addrarray of 20 uint8Aggressor address
maker_addrarray of 20 uint8Resting counterparty address
sideuint8codeTaker side: 0 bid, 1 ask
sizedecimal stringwhole unitsExecuted size
pricedecimal stringwhole USDCExecution price
fee_bpsuint32bpsTaker fee in whole basis points, truncated. Falls back to the market's configured taker rate when the fill has no perp settlement leg

order_events[]

FieldTypeUnitsMeaning
oiduint64idOrder id. 0 when status is 2
ownerarray of 20 uint8Order owner
market_iduint32idCanonical asset id
sideuint8code0 bid, 1 ask
kinduint8codeOrder kind. Always 0 (limit) today
original_sizedecimal stringraw sizeRequest size in lots
remaining_sizedecimal stringraw sizeUnfilled size in lots
limit_pxdecimal string | nullwhole USDCLimit price
statusuint8code0 resting, 1 filled, 2 error
created_ts_msuint64msEquals the block timestamp
updated_ts_msuint64msEquals the block timestamp

created_ts_ms and updated_ts_ms are always equal. The node records one transition per block and carries no separate placement time.

position_deltas[]

One entry per distinct (market, owner) that filled in this block, read from post-fill state.

FieldTypeUnitsMeaning
ownerarray of 20 uint8Position owner
market_iduint32idCanonical asset id
sizedecimal stringwhole unitsSigned size, netted across the long and short legs
entry_pxdecimal stringwhole USDCAbsolute entry notional divided by absolute size. "0" when flat
unrealized_pnldecimal stringReserved. Always "0"
updated_block_heightuint64The height of this envelope

Under hedge mode an account can hold a long leg and a short leg on one market. size is the net of the two, not a per-leg figure.

funding_events[]

One entry per market per block, not one per payment. The per-user payments live in node_funding.

FieldTypeUnitsMeaning
market_iduint32idCanonical asset id
rate_numint64Rate numerator
rate_denomstringRate denominator, a power of ten as a decimal string

The funding rate is rate_num / rate_denom per hour. The example above is 20 / 10000 = 0.002. The pair is exact, so compute it as a rational; do not convert through a float.

warning

Sizes are mixed inside one envelope. fills[].size and position_deltas[].size are whole units. order_events[].original_size and order_events[].remaining_size are raw lots. Divide the order-event sizes by 10^sz_decimals; do not divide the fill and position sizes.

l4_book_diffs

Per-order book changes, with the resting order's owner. Perp books only. Written to one append-only file, <data_dir>/l4_book_diffs.jsonl.

Each line is tagged by kind. A snapshot line lets a downstream book server bootstrap. Diff lines then apply on top.

{"kind":"snapshot","block_number":1234567,"block_time":1735689599852,"orders":[{"coin":0,"oid":366158130011,"side":"ask","px":"6250100000000","sz":"25000","owner":"0x8a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}]}
{"kind":"diff","block_number":1234568,"block_time":1735689600852,"events":[{"coin":0,"oid":366158135200,"side":"bid","px":"6249900000000","sz":"50000","owner":"0x3f2a9c4b8d1e5f60718293a4b5c6d7e8f9012345"},{"coin":0,"oid":366158130011,"remove":true}]}
FieldTypeUnitsMeaning
kindstring"snapshot" or "diff"
block_numberuint64Committed block height. The LAST round this line covers
from_blockuint64The FIRST round this line covers. Equals block_number on an ordinary one-round commit
block_timeuint64msConsensus block timestamp of block_number
ordersarrayFull resting set. Present on "snapshot"
eventsarrayChanged orders only. Present on "diff"

One line can cover several rounds, and from_block is how you tell. The node writes these from committed state, which a state-sync batch has already advanced to the batch TIP — the intermediate states no longer exist, so the line is one aggregate rather than one line per round. An order that rested and vanished inside the batch never appears at all.

A consumer that counts lines to count blocks is wrong on exactly the path that produces large batches: a catching-up node. Advance your own height to block_number, and read block_number - from_block + 1 as the number of rounds folded into that line.

One order or event:

FieldTypeUnitsMeaning
coinuint32idCanonical asset id
oiduint64idResting order id
removeboolPresent and true only on a removal. A removal carries no other field
sidestring"bid" or "ask"
pxi128 stringraw priceResting limit price
szu128 stringraw sizeSize still resting
ownerstring0x-hex owner address

An upsert carries side, px, sz, and owner, and omits remove. A removal carries coin, oid, and remove: true only.

A block whose book did not change writes no line.

l2_book_diffs

The anonymous sibling of l4_book_diffs. Resting orders are aggregated into (coin, side, price) levels. There is no order id and no owner. Perp books only. Written to <data_dir>/l2_book_diffs.jsonl.

{"kind":"snapshot","block_number":1234567,"block_time":1735689599852,"levels":[{"coin":0,"side":"ask","px":"6250100000000","sz":"25000"}]}
{"kind":"diff","block_number":1234568,"block_time":1735689600852,"events":[{"coin":0,"side":"bid","px":"6249900000000","sz":"75000"},{"coin":0,"side":"ask","px":"6250100000000","remove":true}]}
FieldTypeUnitsMeaning
kindstring"snapshot" or "diff"
block_numberuint64Committed block height. The LAST round this line covers
from_blockuint64The FIRST round this line covers. Equals block_number on an ordinary one-round commit
block_timeuint64msConsensus block timestamp of block_number
levelsarrayFull level set. Present on "snapshot"
eventsarrayChanged levels only. Present on "diff"

One level or event:

FieldTypeUnitsMeaning
coinuint32idCanonical asset id
sidestring"bid" or "ask"
pxi128 stringraw priceLevel price
szu128 stringraw sizeTotal resting size at that level
removeboolPresent and true when the level vanished. The level carries no sz

A level event is an absolute set, not an increment. Replace the level's size with sz; do not add to it.

Consumer checklist

  1. Walk {YYYYMMDD}/{HH} in lexical order. That is block order.
  2. Test each line for {"gap" before you parse it as an envelope.
  3. Accept only newline-terminated lines. Retry a fragment on the next pass.
  4. Key your rows so a re-read inserts nothing new. Re-running over the same files must be a no-op. On node_bridge_outbox the key is economic_id, never message_id. On node_actions it is (block_number, action_index).
  5. Parse every price, size, and money value as an arbitrary-precision decimal. Never as a float.
  6. Divide by the right plane. node_* prices need / 1e8. node_* sizes need / 10^sz_decimals. replica_cmds prices and fill sizes need neither.
  7. Resolve market / market_id / coin against the market universe, except node_ledger.coin, which is a token id.
  8. Convert replica_cmds byte arrays to hex yourself if you join them against node_* addresses.