From d2a89cab15956483de50bc27887250ff867409b0 Mon Sep 17 00:00:00 2001 From: giaki3003 Date: Tue, 28 Jul 2026 23:27:58 +0200 Subject: [PATCH 1/4] fix: Truthcoin buy slippage limit mismatch makes miner build invalid fee blocks Bug: w3-20260627-1017-kimiclaw-confirm-openclaw- (primary) Finding: findings/20260627-1017-kimiclaw-confirm-openclaw-truthcoin-dc-buy-limit-fee-mismatch.md Severity: R5-T2 Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit b72676b4d4581ee3d03ad1c6221ceb7dbcca5a69) --- lib/node/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/node/mod.rs b/lib/node/mod.rs index f67953a0..8a038f6b 100644 --- a/lib/node/mod.rs +++ b/lib/node/mod.rs @@ -900,6 +900,8 @@ where filled_tx: &Authorized, cumulative_states: &mut HashMap>, ) -> Result { + use crate::math::trading::TRADE_MINER_FEE_SATS; + let tx_data = match &filled_tx.transaction.transaction.data { Some(data) => data, None => return Ok(true), // Non-data txs always pass @@ -1038,10 +1040,19 @@ where limit_sats ); - if buy_cost.total_cost_sats > *limit_sats { + // Must mirror the connect-time check in + // `state::block::apply_trade` exactly, otherwise the miner + // credits TRADE_MINER_FEE_SATS for a tx that connect skips + // and self-rejects its own block with NotEnoughFees. + if buy_cost + .total_cost_sats + .saturating_add(TRADE_MINER_FEE_SATS) + > *limit_sats + { tracing::info!( - "Slippage exceeded for buy tx: cost {} sats > max {} sats", + "Slippage exceeded for buy tx: cost {} sats + miner fee {} sats > max {} sats", buy_cost.total_cost_sats, + TRADE_MINER_FEE_SATS, limit_sats ); return Ok(false); From 0805e330cc9a233b9c224217d6c12159942bd5fe Mon Sep 17 00:00:00 2001 From: giaki3003 Date: Tue, 28 Jul 2026 23:31:22 +0200 Subject: [PATCH 2/4] fix: SubmitVote period mismatch passes prevalidation Bug: w3-20260701-2209-kimiclaw-confirm-glmclaw-t (primary) Finding: findings/20260701-2209-kimiclaw-confirm-glmclaw-truthcoin-dc-submitvote-voting-period-prevalidate-gap.md Severity: R5-T2 Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 3d6fd24f4b6f6def65f33a86f759f3bed4a190d5) --- lib/validation/vote.rs | 82 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/lib/validation/vote.rs b/lib/validation/vote.rs index 063b99b3..39e31a0e 100644 --- a/lib/validation/vote.rs +++ b/lib/validation/vote.rs @@ -125,6 +125,28 @@ impl VoteValidator { Ok(()) } + /// Reject a transaction whose declared `voting_period` disagrees with the + /// period deterministically derived from the decision id. Mirrors the + /// connect-time check in `apply_submit_vote`. + fn validate_declared_vote_period( + decision_id: crate::state::decisions::DecisionId, + declared_period: u32, + ) -> Result<(), Error> { + let voting_period = decision_id.voting_period(); + if declared_period != voting_period { + return Err(Error::InvalidTransaction { + reason: format!( + "Vote period mismatch: decision {} was claimed in period {} and must be voted on in period {}, but transaction specifies period {}", + decision_id.to_hex(), + decision_id.period_index(), + voting_period, + declared_period + ), + }); + } + Ok(()) + } + fn validate_no_duplicate_vote( state: &crate::state::State, rotxn: &RoTxn, @@ -178,6 +200,13 @@ impl VoteValidator { Self::validate_voter_eligibility(state, rotxn, &voter_address)?; let decision_id = DecisionId::from_bytes(vote_data.decision_id_bytes)?; + + // Voting period is deterministically derived from decision: voting_period = period_index + 1 + Self::validate_declared_vote_period( + decision_id, + vote_data.voting_period, + )?; + let decision = Self::validate_decision_entry(state, rotxn, decision_id)?; @@ -185,7 +214,6 @@ impl VoteValidator { Self::validate_voting_period(state, rotxn, decision_id)?; - // Voting period is deterministically derived from decision: voting_period = period_index + 1 let period_id = VotingPeriodId::new(decision_id.voting_period()); Self::validate_no_duplicate_vote( state, @@ -307,12 +335,39 @@ impl VoteValidator { let mut seen_votes = std::collections::HashSet::<(VotingPeriodId, DecisionId)>::new(); + let mut expected_voting_period: Option = None; for (idx, vote_item) in ballot_data.votes.iter().enumerate() { let decision_id = DecisionId::from_bytes(vote_item.decision_id_bytes)?; // Voting period is deterministically derived from decision: voting_period = period_index + 1 - let period_id = VotingPeriodId::new(decision_id.voting_period()); + let voting_period = decision_id.voting_period(); + + if let Some(expected) = expected_voting_period { + if voting_period != expected { + return Err(Error::InvalidTransaction { + reason: format!( + "Ballot period mismatch: decision {} requires period {} but ballot expects period {}", + decision_id.to_hex(), + voting_period, + expected + ), + }); + } + } else { + expected_voting_period = Some(voting_period); + + if ballot_data.voting_period != voting_period { + return Err(Error::InvalidTransaction { + reason: format!( + "Ballot period mismatch: decisions require period {} but transaction specifies period {}", + voting_period, ballot_data.voting_period + ), + }); + } + } + + let period_id = VotingPeriodId::new(voting_period); if !seen_votes.insert((period_id, decision_id)) { return Err(Error::InvalidTransaction { @@ -542,6 +597,29 @@ mod tests { assert!(VoteValidator::validate_vote_value(&d, 1.5).is_err()); } + #[test] + fn declared_vote_period_matching_derived_is_accepted() { + use crate::state::decisions::DecisionId; + + let decision_id = DecisionId::new(true, 1, 0).unwrap(); + assert_eq!(decision_id.voting_period(), 2); + assert!( + VoteValidator::validate_declared_vote_period(decision_id, 2) + .is_ok() + ); + } + + #[test] + fn declared_vote_period_mismatch_is_rejected() { + use crate::state::decisions::DecisionId; + + let decision_id = DecisionId::new(true, 1, 0).unwrap(); + assert!( + VoteValidator::validate_declared_vote_period(decision_id, 999) + .is_err() + ); + } + #[test] fn transfer_amount_at_precision_is_accepted() { use crate::math::voting::constants::round_reputation; From 2f6f1eb9eb6866ba34703db232ac3178c7703950 Mon Sep 17 00:00:00 2001 From: giaki3003 Date: Tue, 28 Jul 2026 23:34:36 +0200 Subject: [PATCH 3/4] fix: truthcoin-dc CreateMarket duplicate dimensions pass validation but fail connect Bug: w3-20260705-0214-kimiclaw-confirm-openclaw- (primary) Finding: findings/20260705-0214-kimiclaw-confirm-openclaw-truthcoin-dc-createmarket-duplicate-dim-prevalidate-gap.md Severity: R5-T2 Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 341f1110e2ff4adb22ca6d101990edb95ff24c95) --- lib/validation/market.rs | 60 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/validation/market.rs b/lib/validation/market.rs index 8a4a780f..b35e0bd2 100644 --- a/lib/validation/market.rs +++ b/lib/validation/market.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::state::Error; use crate::state::decisions::{DecisionId, DecisionType}; @@ -32,6 +32,30 @@ impl MarketValidator { Ok(market_maker_address) } + /// Reject dimension specs that reference the same decision more than + /// once. Mirrors the connect-time check in `generate_state_combos`. + fn validate_no_duplicate_dimensions( + dimension_specs: &[DimensionSpec], + ) -> Result<(), Error> { + let mut seen_decisions = HashSet::new(); + for spec in dimension_specs { + let decision_id = match spec { + DimensionSpec::Single(id) | DimensionSpec::Categorical(id) => { + *id + } + }; + if !seen_decisions.insert(decision_id) { + return Err(Error::InvalidTransaction { + reason: format!( + "Duplicate decision in market dimensions: \ + {decision_id:?}" + ), + }); + } + } + Ok(()) + } + pub fn validate_market_creation( state: &crate::state::State, rotxn: &RoTxn, @@ -108,6 +132,8 @@ impl MarketValidator { }); } + Self::validate_no_duplicate_dimensions(dimension_specs)?; + for spec in dimension_specs { let decision_id = match spec { DimensionSpec::Single(id) | DimensionSpec::Categorical(id) => { @@ -627,6 +653,38 @@ mod tests { assert!(MarketValidator::validate_market_shares(&shares).is_err()); } + #[test] + fn duplicate_dimensions_rejected() { + let a = DecisionId::new(true, 1, 0).unwrap(); + let b = DecisionId::new(true, 1, 1).unwrap(); + + assert!( + MarketValidator::validate_no_duplicate_dimensions(&[ + DimensionSpec::Single(a), + DimensionSpec::Single(b), + ]) + .is_ok() + ); + + assert!( + MarketValidator::validate_no_duplicate_dimensions(&[ + DimensionSpec::Single(a), + DimensionSpec::Single(a), + ]) + .is_err() + ); + + // Same decision referenced through different spec kinds is still a + // duplicate, matching `generate_state_combos`. + assert!( + MarketValidator::validate_no_duplicate_dimensions(&[ + DimensionSpec::Single(a), + DimensionSpec::Categorical(a), + ]) + .is_err() + ); + } + #[test] fn state_transition_valid() { assert!( From 32de98665534291449ff59b89c3aed814e8fdadd Mon Sep 17 00:00:00 2001 From: giaki3003 Date: Tue, 28 Jul 2026 23:40:19 +0200 Subject: [PATCH 4/4] fix: truthcoin-dc `AmplifyBeta` can make a same-block sell skip at connect after producer acceptance Bug: w3-20260628-1746-openclaw-confirm-kimiclaw- (primary) Finding: findings/20260628-1746-openclaw-confirm-kimiclaw-truthcoin-dc-amplifybeta-sell-slippage-divergence.md Severity: R5-INFO Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 511c401b3b9942f63a4184c79c826f706c392535) --- lib/node/mod.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/node/mod.rs b/lib/node/mod.rs index 8a038f6b..baa0c5be 100644 --- a/lib/node/mod.rs +++ b/lib/node/mod.rs @@ -800,6 +800,7 @@ where let mut spent_utxos = HashSet::new(); let mut cumulative_market_states: HashMap> = HashMap::new(); + let mut cumulative_amplify: HashMap = HashMap::new(); for transaction in combined_txs { let txid = transaction.transaction.txid(); @@ -835,6 +836,7 @@ where &rwtxn, &filled_transaction, &mut cumulative_market_states, + &mut cumulative_amplify, ) { Ok(true) => {} Ok(false) => { @@ -894,11 +896,15 @@ where /// For Trade transactions, this checks the cost/proceeds against /// the cumulative market state (accounting for prior txs in this block) and updates /// the cumulative state if the check passes. + /// + /// `cumulative_amplify` tracks the `AmplifyBeta` deposits already selected + /// for this block, per market, so that beta matches connect time. fn check_trade_slippage( &self, rotxn: &sneed::RoTxn, filled_tx: &Authorized, cumulative_states: &mut HashMap>, + cumulative_amplify: &mut HashMap, ) -> Result { use crate::math::trading::TRADE_MINER_FEE_SATS; @@ -940,7 +946,18 @@ where } }; - let beta = self.derive_market_beta(&market)?; + // Must mirror `state::block::running_market_state`, which folds + // the `AmplifyBeta` deposits already applied in this block into + // the liquidity base before deriving beta. Pricing against the + // confirmed-only base here would let the miner credit + // TRADE_MINER_FEE_SATS for a tx that connect then skips, and + // self-reject its own block with NotEnoughFees. + let pending_amplify = + cumulative_amplify.get(market_id).copied().unwrap_or(0); + let beta = trading::derive_beta_from_liquidity( + market.liquidity_base_sats.saturating_add(pending_amplify), + market.shares().len(), + ); tracing::debug!( "check_trade_slippage: found market with {} outcomes, beta={}", market.shares().len(), @@ -1115,12 +1132,20 @@ where tracing::debug!("check_trade_slippage: Trade passed"); Ok(true) } + TxData::AmplifyBeta { + market_id, amount, .. + } => { + // Track the deposit so later trades on this market in the same + // block are priced at the amplified beta connect will use. + let pending = cumulative_amplify.entry(*market_id).or_insert(0); + *pending = pending.saturating_add(*amount); + Ok(true) + } TxData::ClaimDecision(_) | TxData::CreateMarket { .. } | TxData::SubmitVote { .. } | TxData::SubmitBallot { .. } - | TxData::TransferReputation { .. } - | TxData::AmplifyBeta { .. } => Ok(true), + | TxData::TransferReputation { .. } => Ok(true), } }