Skip to content

[UTXO-BUG] Mint box_id and state root inherit the settling node's wall clock (#2819 Merkle divergence)#8004

Merged
Scottcjn merged 1 commit into
Scottcjn:mainfrom
Vyacheslav-Tomashevskiy:utxo-mint-clock-state-root
Jul 18, 2026
Merged

[UTXO-BUG] Mint box_id and state root inherit the settling node's wall clock (#2819 Merkle divergence)#8004
Scottcjn merged 1 commit into
Scottcjn:mainfrom
Vyacheslav-Tomashevskiy:utxo-mint-clock-state-root

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown
Contributor

Summary

apply_transaction() defaults a missing timestamp to the settling node's wall clock, and that value flows into every output's box_id and into the Merkle state root. The production mint path passes no timestamp, so two honest nodes settling the same epoch seconds apart derive different box IDs and a different compute_state_root() from identical state.

This is the #2819 "Merkle state root manipulation / state root diverges between nodes" class. No attacker required — honest nodes with correct clocks disagree.

Mechanism

  1. node/utxo_db.py:714ts = tx.get('timestamp', int(time.time())), a silent wall-clock default.
  2. :882ts goes into tx_identitytx_id_hex = sha256(tx_seed).
  3. :892-895compute_box_id(..., tx_id_hex, idx), so the box_id inherits the clock.
  4. :1022-1028 — the state-root leaf SELECT includes box_id and transaction_id, so the root inherits it too.
  5. The production mint passes no timestampnode/rustchain_v2_integrated_v2.2.1_rip200.py:4160-4166:
utxo_tx = {
    "tx_type": "mining_reward",
    "inputs": [],
    "outputs": outputs,
    "_allow_minting": True,          # <-- no 'timestamp' key
}
utxo_ok = UtxoDB(DB_PATH).apply_transaction(utxo_tx, epoch * EPOCH_SLOTS + batch_index, conn=conn)

auto_epoch_settler.py is a per-node daemon against a local DB_PATH, so every node settles the same epoch independently, seconds apart.

The design intent is unambiguous: created_at/spent_at are also int(time.time()) (:910, :933, :364) and are deliberately excluded from the leaf SELECT. ts is the one wall-clock value that leaks into the root. The transfer path is fineutxo_endpoints.py:707 passes an explicit timestamp. This affects mints only.

Contradicted contracts, in the file's own words:

  • :15"Deterministic Merkle state root for cross-node consensus"
  • :1008-1009"All nodes with the same UTXO set produce the same root."

Impact

Observed with two nodes settling the same epoch 1s apart (identical balances, both 50 RTC):

root A: 7ce09ccf...23dae2
root B: f590be04...4ade9c        ROOT EQUAL: False
spend A's box_id on node B  ->  False   # aborts at :820-821 "input box not found"
  • /utxo/state_root, /utxo/stats and utxo_genesis_migration.py:291's cross-node comparison disagree between honest nodes.
  • A wallet that reads box_id from node A (/utxo/boxes/<addr>) and submits the spend to node B is rejected.
  • The UTXO set is not rebuildable: a resync replaying the same epoch derives different box IDs than the node that originally settled it.

Severity — Medium, and I want to be straight about why not higher

The UTXO root is not in the block header today: rustchain_block_producer.get_state_root():468 hashes account balances, not UtxoDB. So this is not a chain halt right now, and the mint caller is gated on UTXO_DUAL_WRITE, which is off by default. I'm claiming Medium (Merkle state-root manipulation), not Critical.

What makes it worth fixing now rather than later is the timing: #2819 says "We want adversarial eyes on this before enabling dual-write on production." This bug fires exactly when dual-write is switched on, and it becomes Critical the moment the UTXO root is promoted into the header. Tier is yours to set.

The fix

Drop timestamp from the mint identity only. This cannot collide: the guard at :767-774 allows at most one mint per block_height, so block_height + outputs is already a unique mint identity. Spend paths keep binding their explicit timestamp, so two otherwise-identical transfers stay distinguishable.

I chose this over "require an explicit timestamp for mints" deliberately: that alternative rejects the current production mint body, turning a silent divergence into a hard settlement failure until the monolith is changed too. This fix is self-contained in utxo_db.py and preserves existing behaviour.

Why the suite never caught it

node/test_utxo_db.py:38-47 — the _apply_coinbase fixture always injects 'timestamp': int(time.time()), so the default-timestamp path that production actually uses is never exercised. test_state_root_deterministic:560-565 only calls compute_state_root() twice on the same DB, which cannot detect cross-node divergence. tests/test_utxo_security_audit.py:598-641 uses raw-SQL genesis inserts, bypassing apply_transaction entirely.

So the new tests build the mint body exactly as the production caller does, which is why they see what the fixture-based tests could not.

Verification

tests/test_utxo_mint_state_root_determinism.py3 of 4 fail on clean main, 4/4 pass with the fix:

  • test_mint_box_id_is_independent_of_node_clock — fails on main
  • test_state_root_is_reproducible_across_nodes — fails on main
  • test_mint_is_replayable_from_epoch_data — fails on main
  • test_explicit_timestamp_still_binds_transfer_identitycontrol: passes on main and with the fix, pinning that the fix does not flatten distinct transfers into one identity

Clocks are monkeypatched to fixed values, so the tests are deterministic and sleep-free.

Neighbours green: node/test_utxo_db.py + tests/test_utxo_security_audit.py + tests/test_coin_select_cap_6830.py134 passed, 31 subtests passed. The 4 collection errors under node/ tests/ -k "utxo or genesis or mempool or state_root" (nacl missing, test_c11_bridge_confirm_unbounded_poc.py) are pre-existing on clean main — verified in a separate git worktree, not caused by this change.

Bounty claim: #2819. Wallet: RTCd1554f0f35576faf01d386a6be1c947f560dd0b7

…l clock

apply_transaction() defaults a missing timestamp to int(time.time()) and
mixes it into tx_identity -> tx_id -> every output box_id -> the Merkle
leaf. The production mint path (epoch reward settlement,
rustchain_v2_integrated_v2.2.1_rip200.py:4160-4166) passes no timestamp,
so two honest nodes settling the same epoch seconds apart derive
different box IDs and a different compute_state_root() from identical
state -- contradicting the method's own contract at :1008-1009.

Mints are made clock-independent by dropping timestamp from the mint
identity. This cannot collide: at most one mint may exist per
block_height (guard at :767-774), so block_height + outputs is already
a unique mint identity. Spend paths keep binding their explicit
timestamp, so distinct transfers stay distinguishable.

Regression tests build the mint body exactly as the production caller
does; 3 of 4 fail on clean main.
@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related tests Test suite changes labels Jul 17, 2026
@github-actions github-actions Bot added the size/M PR: 51-200 lines label Jul 17, 2026
@Scottcjn
Scottcjn merged commit f7431bc into Scottcjn:main Jul 18, 2026
12 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

RTC Reward

This merged PR earned 5 RTC — sent to RTCd1554f0f35576faf01d386a6be1c947f560dd0b7.

RustChain Bounty Program

@FlintLeng FlintLeng left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 RustChain Bounty PR Review — #8004

PR: #8004
Title: [UTXO-BUG] Mint box_id and state root inherit the settling node's wall clock (#2819 Merkle divergence)
Author: @Vyacheslav-Tomashevskiy
Merged: 2026-07-18
Changes: +167/-0 (2 files)


📝 改动摘要

File + -
node/utxo_db.py 13 0
tests/test_utxo_mint_state_root_determinism.py 154 0

⚙️ 技术评估

问题背景 (Bounty #2819)

apply_transaction() 默认使用 time.time() 作为时间戳,该值被混入 tx_identitytx_id → 每个 output 的 box_id → Merkle 叶节点。但 epoch reward settlement (挖矿奖励) 的生产路径不传递 timestamp 参数,导致:

  1. 两个诚实节点处理同一个 epoch(时间相差几秒)会产生不同的 box_id
  2. compute_state_root() 对相同输入产生不同结果
  3. 节点重同步时无法重建之前记录的 UTXO 集

这是一个严重的共识分歧漏洞:钱包从一个节点读取 box_id 后无法在另一个节点花费它。

修复方案

核心改动 (node/utxo_db.py:885-895):

if tx_type in MINTING_TX_TYPES:
    del tx_identity['timestamp']

修复逻辑正确:

  • Mint 交易已通过 block_height 唯一标识(一个区块最多一个 mint)
  • 移除 timestamp 不会引入碰撞风险
  • Spend 路径保留显式 timestamp 绑定,不影响转账交易区分

测试覆盖

新增测试文件 test_utxo_mint_state_root_determinism.py 覆盖关键场景:

  1. test_mint_box_id_is_independent_of_node_clock — 验证同一 epoch + 相同 outputs 在不同节点时钟下产生相同 box_id
  2. test_state_root_is_reproducible_across_nodes — 验证相同 UTXO 集产生相同 Merkle root
  3. test_mint_is_replayable_from_epoch_data — 验证重同步节点能重建相同 UTXO 集
  4. test_explicit_timestamp_still_binds_transfer_identity — 控制测试,确保修复不影响转账交易的 timestamp 绑定

测试直接使用生产路径的 mint body 构造(而非注入 timestamp 的 fixture),精准暴露了原有测试的盲区。

代码质量

  • ✅ 注释详尽,清晰说明了问题根源和修复理由
  • ✅ 测试使用真实生产路径,非 mock 注入
  • ✅ 修复最小化,仅影响 minting 交易
  • ✅ 控制测试确保无副作用

✅ 结论:APPROVE

这是一个高质量的安全修复:

  • 定位准确:找到了导致共识分歧的根本原因
  • 修复最小化:仅针对 minting 交易,保留 transfer 路径语义
  • 测试完整:覆盖了跨节点一致性、重同步、边界控制
  • 文档充分:注释和测试说明清晰

推荐合并。


💰 Bounty 认领
钱包地址: RTC019e78d600fb3131c29d7ba80aba8fe644be426e
预估奖励: 2 RTC

@Scottcjn

Copy link
Copy Markdown
Owner

🎉 Paid — 40 RTC just sent to your wallet (tx 832b7c1eef52b685669b1ca7edae8ef4, pending ~24h to confirm). Thank you for the work, it's genuinely appreciated — this covers the wall-clock Merkle divergence fix here plus the run of already-merged PRs from the Jul 10-13 gap. Your UTXO digging keeps finding the real ones. Sorry it took a beat to reach you. — Sophia, Elyan Labs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) BCOS-L2 Beacon Certified Open Source tier BCOS-L2 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines tests Test suite changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants