xOneC imports a block through one function, Chain::insert, and commits it in one libmdbx transaction. A validator seals a block in a second path: build, record, sign, import, then broadcast. This post follows both paths in the code. The xOneC repository is private, so its links need access. Line numbers point at commit f0f5ffc of main.

For the build and node checks, read how to run xOneC. For the datadir files, read the storage layout.

How does a block enter xOneC?

The follower thread fetches headers and bodies, recovers transaction senders on a worker pool, and calls Chain::insert once per block. Execution never runs on the io thread (main.cpp:515).

  • The follower gets bodies, calls c.prepare(bs), then inserts each block (follow.cpp:56-62).
  • prepare decodes the transactions and recovers every sender on the pool. execute later takes the senders from that cache (xdc.cpp:204-231).
  • Block 21,970 has 5,032 senders. They recover on 18 worker threads in 125-166 ms. The longest gap of a 1 ms timer was 480 ms with execution on the io thread, and 4.8 ms on a worker (performance.md:13-14).

What does the node check before it runs a block?

Chain::insert runs the cheap checks first and executes last (chain.cpp:289-316).

  1. A known block returns known. An unknown parent or wrong number is an error.
  2. header_check verifies the header with the epoch context. It remembers the hash, so execution does not verify it again (xdc.cpp:272-286, 307).
  3. body_check runs before execution.
  4. Fork choice decides: side block, reorg, or extend.
  5. commit executes the block and commits the state. If the root differs from the header, it drops the writes and refuses the block (chain.cpp:233-243).

What happens inside the libmdbx commit?

The block opens one libmdbx write transaction. Db::commit_run fills it in the order S1-S7 of the ADR (commit.cpp:482-683).

REFERENCE TABLEScroll to explore all columns →
Step Lines Action
S1 522-547 Journal: write, fsync, rename, fsync the directory.
S2 548-592 State rows, change sets and trie nodes go into the open transaction. The change set of block n-4,096 is deleted.
S3-S4 593-625 Stage three frames. Fsync the segment files. Fsync a directory only where a chunk file was created.
S5 627-652 SegIdx, canonical index, generation and manifest, in the same transaction.
S6 665 Commit.
S7 668-679 Publish the manifest. Delete the journal.

The environment opens with MDBX_SYNC_DURABLE and no MDBX_WRITEMAP (store.cpp:47-51). A plain append costs 7 sync calls, measured with strace -f -c over 2,000 blocks (ADR-005 addendum:97).

The full order took p50 142.766 ms and p99 186.727 ms per block on a shared host with load 40-43. The libmdbx commit alone took p50 24.521 ms (ADR-005 addendum:104-113). The ADR reopens the RocksDB choice if p99 passes 200 ms. Its status is proposed.

A failed sync stops the process. fail_stop writes the FSYNC_FAILED marker with the boot id and exits with code 70 (commit.cpp:383-403).

Why does a follower commit differently from a validator?

A follower batches commits. A node with a key commits every block durably. sync_every is 128 without a keystore and 1 with one (config.cpp:61).

  • A batched commit is weak: no journal, no fsync, and a libmdbx transaction without sync (commit.cpp:520-523).
  • Db::sync() is the durable point. It fsyncs the segment files and directories, then forces a libmdbx sync (commit.cpp:466-475).
  • The follower calls it after one second or at the tip (main.cpp:610-616).
  • A kill loses nothing. A power loss loses the open batch. reconcile() cuts the extra frames (commit.hpp:183-190).
  • On loopback, net2040 blocks 12,999 to 20,000 went from 8.2 to 59.4 blocks/s with batching and other changes (performance.md:41-57).

How does a sealed block get from the leader turn to the network?

The block loop checks that it leads the round, builds the block, records the intent, signs, imports the block into its own chain, and only then broadcasts it.

  1. The loop wakes on a round change or a new head, or every 250 ms (block_loop.cpp:73-84, main.cpp:560, 574-579).
  2. It needs mining on, a peer, and no sync in progress. Above the V2 switch block it calls v2_tick (block_loop.cpp:102-107).
  3. The parent must be the highest QC block and the head. The leader-side sibling proposal is not built (block_loop.cpp:331-339).
  4. v2_turn skips a round at or below the highest signed propose round, and a round led by another node (producer.cpp:154-163).
  5. build selects transactions from the pool, executes on the head state, and refills the header roots. A trial commit finds the state root and unwinds it (block_loop.cpp:158-202, chain.cpp:277-287). If a transaction fails, it builds again without the pool.
  6. pending.rlp is written durably (block_loop.cpp:206).
  7. The safety unit checks the fence and durability, writes the record with pwrite and fdatasync, and then signs (unit.cpp:95-118, safety_store.cpp:143-158).
  8. chain_.insert imports the sealed block through the same path as a followed block (block_loop.cpp:228). Because the key sets sync_every to 1, the block is durable first.
  9. NewBlock goes to every connected peer. Then pending.rlp is deleted (block_loop.cpp:235-239, main.cpp:523-530).

xOneC refuses --mine on chain 50 (main.cpp:325-327).

What if the node dies between the seal and the send?

On start, the loop reads pending.rlp. If its parent is the head and mining is on, it seals the block again. Otherwise it drops the file (block_loop.cpp:242-270). A retry of a recorded intent writes nothing and signs the same bytes (RFC 6979) (unit.hpp:5-9).

The ADR names fsync and lists fdatasync as an open owner question, because fsync p99 passed the 50 ms limit in the spike (ADR-010 addendum:65, 102). The code uses fdatasync.

What does the Gate D record show?

The Gate D rehearsal is a loopback devnet of 3 geth-XDC validators, 1 xOneC validator and 1 xOneC follower (gate-d.md:3).

cmake --build build/dev --target xonec xonec-gated
ops/devnet/gate-d.sh up <workdir> build/dev/xonec <geth-xdc-binary>
ops/devnet/gate-d.sh killmatrix <workdir> 24
REFERENCE TABLEScroll to explore all columns →
Check Result
Eligible turns 140 of 140 sealed, 0 missed
Kill matrix 24 kill -9, each restart opened with no help and sealed again, 0 FAIL
Soak 2 h, 7 planned restarts, 841 of 841 turns sealed
Safety record 0 falls in 7 fields; 176 seals at 176 heights on devnet B

Source: gate-d.md:96-102, 121-129. Open gaps, from gate-d.md:165-174:

  • The power-loss tests run on xonec-fault db-run, not on a producing node.
  • Stale-backup restore, signer handoff and a fence across hosts are not exercised.
  • xOneC skips the sign transaction for a block that arrives in a catch-up batch. The TS and Rust clients sign it.

The v0.1.0 release was not re-gated on its own commit (CHANGELOG.md:58).

How do other clients describe their storage engine?

REFERENCE TABLEScroll to explore all columns →
Client What its documentation says xOneC
reth Storage V2: MDBX holds hashed state only, changesets are in static files and indices are in RocksDB, as stated in reth storage and Releasing reth 2.0, 2026-04-08. libmdbx v0.13.12 holds 17 named tables (store.hpp:44). Segment files hold headers, bodies and receipts.

The rows describe different designs. They are not a speed comparison. See also sync modes and the storage documentation.

Frequently asked questions

Does xOneC use MDBX?

Yes. xOneC uses libmdbx v0.13.12 through its C API for state, indexes and the manifest. Headers, bodies and receipts go to its own segment files.

Is a block durable when xOneC sends it?

Yes, for a node with a keystore. sync_every is 1, so the commit is durable before NewBlock goes out.

Can a restart make xOneC sign two blocks at one height?

The code writes the safety record before it signs. A kill after pending.rlp leads to a re-seal of the same block. The Gate D kill matrix shows 0 heights sealed twice.

Sources