[UTXO-BUG] Mint box_id and state root inherit the settling node's wall clock (#2819 Merkle divergence)#8004
Conversation
…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.
RTC RewardThis merged PR earned 5 RTC — sent to |
FlintLeng
left a comment
There was a problem hiding this comment.
🔍 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_identity → tx_id → 每个 output 的 box_id → Merkle 叶节点。但 epoch reward settlement (挖矿奖励) 的生产路径不传递 timestamp 参数,导致:
- 两个诚实节点处理同一个 epoch(时间相差几秒)会产生不同的
box_id compute_state_root()对相同输入产生不同结果- 节点重同步时无法重建之前记录的 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 覆盖关键场景:
test_mint_box_id_is_independent_of_node_clock— 验证同一 epoch + 相同 outputs 在不同节点时钟下产生相同box_idtest_state_root_is_reproducible_across_nodes— 验证相同 UTXO 集产生相同 Merkle roottest_mint_is_replayable_from_epoch_data— 验证重同步节点能重建相同 UTXO 集test_explicit_timestamp_still_binds_transfer_identity— 控制测试,确保修复不影响转账交易的 timestamp 绑定
测试直接使用生产路径的 mint body 构造(而非注入 timestamp 的 fixture),精准暴露了原有测试的盲区。
代码质量
- ✅ 注释详尽,清晰说明了问题根源和修复理由
- ✅ 测试使用真实生产路径,非 mock 注入
- ✅ 修复最小化,仅影响 minting 交易
- ✅ 控制测试确保无副作用
✅ 结论:APPROVE
这是一个高质量的安全修复:
- 定位准确:找到了导致共识分歧的根本原因
- 修复最小化:仅针对 minting 交易,保留 transfer 路径语义
- 测试完整:覆盖了跨节点一致性、重同步、边界控制
- 文档充分:注释和测试说明清晰
推荐合并。
💰 Bounty 认领
钱包地址: RTC019e78d600fb3131c29d7ba80aba8fe644be426e
预估奖励: 2 RTC
|
🎉 Paid — 40 RTC just sent to your wallet (tx |
Summary
apply_transaction()defaults a missingtimestampto the settling node's wall clock, and that value flows into every output'sbox_idand 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 differentcompute_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
node/utxo_db.py:714—ts = tx.get('timestamp', int(time.time())), a silent wall-clock default.:882—tsgoes intotx_identity→tx_id_hex = sha256(tx_seed).:892-895—compute_box_id(..., tx_id_hex, idx), so thebox_idinherits the clock.:1022-1028— the state-root leaf SELECT includesbox_idandtransaction_id, so the root inherits it too.node/rustchain_v2_integrated_v2.2.1_rip200.py:4160-4166:auto_epoch_settler.pyis a per-node daemon against a localDB_PATH, so every node settles the same epoch independently, seconds apart.The design intent is unambiguous:
created_at/spent_atare alsoint(time.time())(:910,:933,:364) and are deliberately excluded from the leaf SELECT.tsis the one wall-clock value that leaks into the root. The transfer path is fine —utxo_endpoints.py:707passes 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):
/utxo/state_root,/utxo/statsandutxo_genesis_migration.py:291's cross-node comparison disagree between honest nodes.box_idfrom node A (/utxo/boxes/<addr>) and submits the spend to node B is rejected.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():468hashes accountbalances, notUtxoDB. So this is not a chain halt right now, and the mint caller is gated onUTXO_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
timestampfrom the mint identity only. This cannot collide: the guard at:767-774allows at most one mint perblock_height, soblock_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.pyand preserves existing behaviour.Why the suite never caught it
node/test_utxo_db.py:38-47— the_apply_coinbasefixture always injects'timestamp': int(time.time()), so the default-timestamp path that production actually uses is never exercised.test_state_root_deterministic:560-565only callscompute_state_root()twice on the same DB, which cannot detect cross-node divergence.tests/test_utxo_security_audit.py:598-641uses raw-SQL genesis inserts, bypassingapply_transactionentirely.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.py— 3 of 4 fail on clean main, 4/4 pass with the fix:test_mint_box_id_is_independent_of_node_clock— fails on maintest_state_root_is_reproducible_across_nodes— fails on maintest_mint_is_replayable_from_epoch_data— fails on maintest_explicit_timestamp_still_binds_transfer_identity— control: passes on main and with the fix, pinning that the fix does not flatten distinct transfers into one identityClocks 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.py→ 134 passed, 31 subtests passed. The 4 collection errors undernode/ tests/ -k "utxo or genesis or mempool or state_root"(naclmissing,test_c11_bridge_confirm_unbounded_poc.py) are pre-existing on clean main — verified in a separategit worktree, not caused by this change.Bounty claim: #2819. Wallet:
RTCd1554f0f35576faf01d386a6be1c947f560dd0b7