ZeroFlux consensus v2 (private testnet)
Status: frozen for the private testnet. v2 is a hard fork of v0.1 (consensus_version: 2): it adds state_root to the header, coinbase maturity, transaction memos, the native bridge module and the RunningMax difficulty rule. The PoW function and its golden vectors are unchanged. Not frozen for any value-bearing network (MAINNET_READY = false, see RELEASE_READINESS.md).
Every rule here is implemented in crates/zfx-verify (consensus library, no GPU runtime) and crates/zfx-node/src/chain.rs (state rules). Where this document and the code disagree, the code plus tests/vectors/fp8c_v0.1.json and tests/vectors/consensus_v2.json win, and that disagreement is a bug to report.
Notation: || is byte concatenation. u32le/u64le are little-endian unsigned integers. uint256_be reads 32 bytes as a big-endian unsigned integer. SHA256 is FIPS 180-4. SHAKE256(m, n) is the first n output bytes of FIPS 202 SHAKE256. Domain strings are ASCII bytes without a terminator or length prefix. Every input that follows a domain string has a fixed length, so concatenation is unambiguous.
1. Versions and profiles
The header version field selects the PoW profile.
| id | name | d_in | H (hidden) | hidden layers | d_out | shift_in | shift_hidden | MACs per attempt |
|---|---|---|---|---|---|---|---|---|
| 1 | zfx-fp8c-v0.1 |
64 | 4096 | 3 | 256 | 0 | 2 | 51,642,368 |
| 2 | zfx-fp8c-v0.1-dev |
64 | 256 | 3 | 64 | 0 | 0 | 229,376 |
Profile 2 exists for unit tests and fast local devnets only. A chain uses exactly one profile, fixed by its genesis config. A header whose version differs from the chain profile is invalid.
2. Block header
156 bytes without the nonce, 164 bytes with it (v2).
| offset | size | field | encoding |
|---|---|---|---|
| 0 | 4 | magic | ASCII ZFX2 |
| 4 | 4 | version | u32le (profile id) |
| 8 | 4 | chain_id | u32le |
| 12 | 8 | height | u64le |
| 20 | 32 | parent | block hash of parent |
| 52 | 32 | tx_root | section 5.4 |
| 84 | 32 | state_root | section 5.6, state after applying this block |
| 116 | 8 | timestamp | u64le, Unix seconds |
| 124 | 32 | target | uint256 big-endian |
| 156 | 8 | nonce | u64le |
header_without_nonce is bytes 0..156. block_hash = SHA256("ZFX-block-v0.1" || header[0..164]). The v0.1 layout (magic ZFXH, 124 + 8 bytes, no state root) is kept only to reproduce the v0.1 PoW golden vectors; it is invalid on v2 chains.
3. Proof of work: ZFX-FP8C
3.1 Operand domain
D = {-16, ..., -1, 1, ..., 16}, 32 values with no zero. A 5-bit index u maps to val(u) = u + 1 for u < 16 and val(u) = 15 - u for u >= 16. A byte b maps to val(b & 31). That map is unbiased because 256 = 8 x 32.
Every value in D is exactly representable in OCP FP8 E4M3 (bias 7), AMD FP8 E4M3FNUZ (bias 8), int8, FP16, BF16 and FP32.
Exactness bound: every layer has inner dimension K <= 4096, so |sum| <= K x 16 x 16 <= 2^20 < 2^24. Each product and every partial sum is an integer of magnitude below 2^24. FP32 therefore represents every intermediate exactly, whatever the reduction order, blocking or split-K. Int32 is exact trivially. Consensus is defined on these integers. The storage format (int8, E4M3, E4M3FNUZ, float32) is an implementation choice.
3.2 Requantization
requant(y, s) = val(SBOX[((y >> s) & 0xFF)] >> 3)
Here y is a two's-complement int32, >> is an arithmetic shift, and SBOX is the AES S-box (FIPS-197 Figure 7). The S-box is used only as a fixed nonlinear byte permutation. Because it is a permutation, each of the 32 outputs comes from exactly 8 input bytes.
3.3 Inputs
challenge = SHA256("ZFX-FP8C-v0.1" || header_without_nonce)
attempt_id = SHA256("ZFX-attempt-v0.1" || challenge || u64le(nonce))
x0[i] = val(SHAKE256("ZFX-FP8C-X0-v0.1" || attempt_id, d_in)[i] & 31) i < d_in
3.4 Epoch weights
For a block at height h >= 1, anchor_height = floor((h - 1) / 32) * 32, and anchor_hash is the hash of that height's ancestor on the block's own branch. Heights 1..32 use the genesis hash.
seed = SHA256("ZFX-FP8C-epoch-v0.1" || anchor_hash || u32le(profile_id))
W[l][j][k] = val(SHAKE256("ZFX-FP8C-W-v0.1" || seed || u32le(l) || u32le(j), K_l)[k] & 31)
Layer l has shape N_l x K_l (output-major, one independent XOF stream per row j):
| l | K_l | N_l | requant shift |
|---|---|---|---|
| 0 | d_in | H | shift_in |
| 1 .. n_hidden | H | H | shift_hidden |
| n_hidden + 1 | H | d_out | none (output layer) |
3.5 Attempt
x = x0
for each layer l:
y[j] = sum_k x[k] * W[l][j][k] (exact integer, |y| < 2^24)
if l is not the output layer: x[j] = requant(y[j], shift_l)
out = y (d_out int32 values)
digest = SHA256("ZFX-result-v0.1" || challenge || u64le(nonce) || out[0] as i32le || ... || out[d_out-1] as i32le)
valid iff uint256_be(digest) < uint256_be(header.target)
The miner submits only (work_id, nonce) to its node. Nodes and peers receive the full header and recompute the digest. No miner-supplied digest is ever trusted.
3.6 Reference vectors
tests/vectors/fp8c_v0.1.json (regenerate with zfx-research vectors):
| profile | epoch_seed | challenge | digest(nonce=0) |
|---|---|---|---|
| dev | 793855bd...70f43538 |
79753124...01a3bdaf |
ed243470...2e89fdc1 |
| v01 | 3d3fefe9...2f2cb73c |
4e4bca37...28dbe66f |
66286df2...0b1119ca |
Edge case: dot([16]*4096, [16]*4096) = 1048576, whose FP32 bits are 0x49800000. Three independent implementations (Rust, C++, Python) and the Apple GPU backends reproduce every vector.
4. Difficulty and time
- Target is uint256, and the work of one block is
floor(2^256 / (target + 1)). Fork choice picks the valid chain with the most cumulative work. On a tie, the first-seen tip wins. - Retarget is LWMA-1 with N = 30, integer-only:
next = (sum_i target_i / N) * (sum_{i=1..N} i * clamp(t_i - t_{i-1}, 1, 6T)) / (N(N+1)/2 * T), clamped to[1, pow_limit]. The genesis target applies while the parent height is below 31. A header's target must equal the computed value exactly. - Solvetime rule (v2,
RunningMax):t_i - t_{i-1}above is replaced byt_i - max(t_0..t_{i-1}), clamped to[1, 6T]. With the v0.1 rule a miner holding 30% of work could forge timestamps and pull block times to 24.4 s against a 60 s target; with RunningMax the same attack yields 61.0 s (bench/results/fork_resilience.json). - The genesis config may set
fixed_difficulty = true, for test environments only. - Timestamps:
timestamp > median(last 11 timestamps)andtimestamp <= local_time + max_future_secs(120 s on the testnet). - No mainnet block time, reward schedule or supply cap is finalized. A node refuses a genesis config with
network: "mainnet"until an owner-approvedsubsidy_scheduleis set (section 5.5).
5. Transactions
5.1 Signatures
- Scheme 1: SLH-DSA-SHA2-128s (FIPS 205, NIST category 1). Public key 32 B, signature 7,856 B.
- Scheme 2: SLH-DSA-SHA2-192s (category 3). Public key 48 B, signature 16,224 B.
- Scheme 3: XMSS (SP 800-208). Reserved, rejected in v0.1.
Messages are signed with FIPS 205 pure SLH-DSA (hedged), with context string "ZFX-v0.1". There is no elliptic-curve or RSA primitive anywhere in consensus.
5.2 P2MR addresses
leaf_hash = SHA256("ZFX-p2mr-leaf-v0.1" || u8(scheme) || u16le(len(pk)) || pk)
node(a, b) = SHA256("ZFX-p2mr-node-v0.1" || min(a,b) || max(a,b)) (sorted pair)
root = binary Merkle root over leaf hashes, odd node promoted
address = SHA256("ZFX-p2mr-addr-v0.1" || root) (32 bytes)
text = "zfx1" || hex(address) || hex(SHA256("ZFX-addrck" || address)[0..4])
Spending reveals one leaf, its Merkle path (depth <= 8) and a signature. There is no key-path spend. Wallets default to two leaves: index 0 is 128s for daily use and index 1 is 192s as a backup.
5.3 Encoding and identifiers
core = u32le(chain_id) || from(32) || to(32) || u64le(amount) || u64le(fee) || u64le(nonce)
[|| 0x01 || u8(len memo) || memo] (v2, only when a memo is set; UTF-8, <= 64 bytes)
message = "ZFX-tx-v0.1" || core
witness = u8(scheme) || u16le(len pk) || pk || u8(path len) || path(32 each) || u16le(len sig) || sig
txid = SHA256("ZFX-txid-v0.1" || core) (witness excluded: non-malleable)
wtxid = SHA256("ZFX-wtxid-v0.1" || core || witness)
coinbase = u64le(height) || to(32) || u64le(amount); id = SHA256("ZFX-coinbase-v0.1" || coinbase)
5.4 Block body
tx_root = SHA256("ZFX-txroot-v0.1" || u32le(count) || merkle(leaves)), where the leaves are [coinbase id, wtxid_1, ..., wtxid_n, op_id_1, ..., op_id_m] (v2 appends the bridge op ids, section 7). Merkle nodes are SHA256("ZFX-merkle-v0.1" || l || r), and an odd node is promoted without duplication (avoiding CVE-2012-2459). Limits are 1,000 transactions and 4 MiB of canonical bytes per block.
5.5 State rules (account model)
- A transaction is valid iff its authorization verifies,
nonceequals the sender's next nonce,amount + feefits u64 and does not exceed the sender's balance, andfrom != to. - The coinbase amount must equal
subsidy(height) + sum(fees)exactly.subsidycomes from the genesissubsidy_schedule(Constant,HalvingorSmoothDecay, each with an optional tail;crates/zfx-verify/src/monetary.rs); without one,block_subsidyapplies at every height. The testnet subsidy is 50 x 10^8 units per block and carries no monetary value. - Coinbase maturity (v2): a coinbase output becomes spendable
coinbase_maturityblocks after its block (testnet 20). Until then it is held in the immature set. - Genesis allocations come from the genesis config. The genesis block commits to the config's fixed-layout encoding through
tx_root = SHA256("ZFX-genesis-v0.1" || SHA256(config_bytes)).
5.6 State root (v2)
state_root = SHA256("ZFX-state-v2" || body), where body is: u64le(n) and, for each of the n live accounts (balance or nonce nonzero) in ascending address order, address(32) || u64le(balance) || u64le(nonce); then "|immature" and each immature entry u64le(height) || address || u64le(amount) in key order; then "|supply" || u64le(supply); then, if the bridge is enabled, "|bridge" || u32le(epoch) || members(32 each) || pending rotation (0x00, or 0x01 || keys || u64le(activate_height)) || u8(paused) || u64le(admin_nonce) || u128le(locked_total) || u128le(released_total) || u64le(deposits) || u64le(releases) || used withdrawal ids (32 each, ascending). Code: State::root in crates/zfx-verify/src/state.rs.
6. Node-to-miner API (v1)
GET /v1/work?address=zfx1... returns {api_version, work_id, chain_id, version, height, header_without_nonce, challenge, target, epoch_seed, profile, expiry}.
POST /v1/submit {work_id, nonce} returns {accepted, block_hash | error}.
Work expires when the tip changes. The node recomputes the PoW for every submission and rate-limits to 50 submissions per second per IP. Request bodies are capped at 64 KiB and P2P lines at 16 MiB. GET /v1/status returns height, target, peers and best hash. The miner checks that challenge == SHA256("ZFX-FP8C-v0.1" || header_without_nonce) before mining.
7. Native bridge module (v2)
Enabled by a bridge section in the genesis config: committee (3 SLH-DSA-SHA2-128s public keys), threshold 2, rotation_timelock_blocks, min_lock, activation_height, and the NEAR contract id.
- Lockbox:
lockbox = SHA256("ZFX-bridge-lockbox-v1" || u32le(chain_id)). No key exists for it; only consensus-validated releases move funds out. - Deposit: an ordinary transaction to the lockbox with amount >=
min_lockand a memo holding a valid NEAR account id.deposit_id = txid. Rejected while paused. - Release op:
{epoch, withdrawal_id, near_burn_ref, recipient, amount, expiry_height, approvals}. Valid iff not paused,epochis current, height <=expiry_height, the withdrawal id was never released,amount<= lockbox balance, and >= 2 distinct members signedrelease_digest(docs/CONTRACT_API.md). - Admin op:
{epoch, op_nonce, action, approvals}withop_nonce == admin_nonce. Pause needs 1 approval; unpause and rotate need 2. Rotate carries 3 new keys andactivate_height >= height + rotation_timelock_blocks; at that height the committee is replaced andepochincrements. - Approvals are SLH-DSA-SHA2-128s signatures with FIPS 205 context
ZFX-bridge-v1. At most 64 bridge ops per block. op_id = SHA256("ZFX-bridgeop-v1" || canonical op encoding)(encode_opinbridge.rs); ops are tx_root leaves after the transactions.- Audit: lockbox balance must equal
locked_total - released_totalafter every block.
Golden vectors for all v2 encodings and digests: tests/vectors/consensus_v2.json (regenerate only with zfx-research vectors-v2 --force).