Skip to content

Commit da34fa3

Browse files
pos mempool: clean pivot-decision sets by committed height, not envelope hash
Pivot-decision transactions are signed over only the PivotBlockDecision payload, not the whole RawTransaction, so a validator can produce many valid, distinct-hash transactions for one vote by varying the unsigned chain_id. Deduplicating to one vote per (decision, sender) stops those duplicates from crashing proposal construction, but it makes commit cleanup leak: the aggregated pivot decision a proposer commits may wrap a different chain_id than the copy a node stored, so removing the committed set by its envelope hash could miss and leave the whole set (and every voter's per-sender slot, cap 128) until the 600s system TTL. A resident lower or conflicting same-height decision, which can never commit, was likewise never cleaned. Clean by committed height instead: the commit notification carries the committed pivot decision's height, and on commit the store sweeps every pivot-decision set at or below it. Being hash-agnostic, this clears the committed decision regardless of which chain_id envelope was aggregated, and reclaims the obsolete lower / conflicting same-height sets in the same pass. Key each decision's votes by sender (a map) so per-sender uniqueness is structural, which lets the manual admission dedup scan and the aggregation-time seen_signers guard go away. Mempool-local: no change to signed bytes, hashing, or on-chain validation, so no consensus or fork risk. Whether chain_id should be brought into the pivot-decision signature is tracked separately. Covered by a red-green regression test: committing a decision via a chain_id wrapper never stored locally still sweeps its set plus obsolete lower and conflicting same-height sets, while a higher decision survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f330e4 commit da34fa3

5 files changed

Lines changed: 159 additions & 66 deletions

File tree

crates/cfxcore/core/src/pos/consensus/state_computer.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use consensus_types::block::Block;
1212
use diem_crypto::HashValue;
1313
use diem_logger::prelude::*;
1414
use diem_types::{
15-
ledger_info::LedgerInfoWithSignatures, transaction::Transaction,
15+
ledger_info::LedgerInfoWithSignatures,
16+
transaction::{Transaction, TransactionPayload},
1617
};
1718
use executor_types::{
1819
BlockExecutor, Error as ExecutionError, StateComputeResult,
@@ -45,18 +46,22 @@ impl ExecutionProxy {
4546

4647
/// Notify mempool of committed transactions so it can prune them.
4748
async fn notify_mempool(&self, committed_txns: Vec<Transaction>) {
48-
let user_txns: Vec<CommittedTransaction> = committed_txns
49-
.iter()
50-
.filter_map(|txn| match txn {
51-
Transaction::UserTransaction(signed_txn) => {
52-
Some(CommittedTransaction {
53-
sender: signed_txn.sender(),
54-
hash: signed_txn.hash(),
55-
})
49+
let mut user_txns = Vec::new();
50+
let mut committed_pivot_height: Option<u64> = None;
51+
for txn in &committed_txns {
52+
if let Transaction::UserTransaction(signed_txn) = txn {
53+
user_txns.push(CommittedTransaction {
54+
sender: signed_txn.sender(),
55+
hash: signed_txn.hash(),
56+
});
57+
if let TransactionPayload::PivotDecision(d) =
58+
signed_txn.payload()
59+
{
60+
committed_pivot_height =
61+
committed_pivot_height.max(Some(d.height));
5662
}
57-
_ => None,
58-
})
59-
.collect();
63+
}
64+
}
6065

6166
if user_txns.is_empty() {
6267
return;
@@ -65,6 +70,7 @@ impl ExecutionProxy {
6570
let (callback, cb_receiver) = oneshot::channel();
6671
let notification = CommitNotification {
6772
transactions: user_txns,
73+
committed_pivot_height,
6874
callback,
6975
};
7076

crates/cfxcore/core/src/pos/mempool/core_mempool/mempool.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ impl Mempool {
5353
self.transactions.commit_transaction(hash);
5454
}
5555

56+
/// Cleans up pivot-decision votes for a committed decision at `height`.
57+
pub(crate) fn commit_pivot_height(&mut self, height: u64) {
58+
self.transactions.commit_pivot_height(height);
59+
}
60+
5661
/// Used to add a transaction to the Mempool.
5762
/// Performs basic validation: checks account's sequence number.
5863
pub(crate) fn add_txn(
@@ -123,7 +128,7 @@ impl Mempool {
123128
// Admission accepts node_map (non-committee) voters, and
124129
// `check_voting_power` errors `UnknownAuthor` on them; filter to
125130
// the committee so one such vote can't fail a valid quorum.
126-
let committee_votes: Vec<&(AccountAddress, HashValue)> =
131+
let committee_votes: Vec<(AccountAddress, HashValue)> =
127132
pivot_decision_set
128133
.iter()
129134
.filter(|(addr, _)| {
@@ -167,20 +172,17 @@ impl Mempool {
167172
self.transactions.get_pivot_decisions(&pivot_decision_hash);
168173
let senders: Vec<AccountAddress> =
169174
validators.get_ordered_account_addresses_iter().collect();
175+
// Votes are keyed by sender, so signer indices are already
176+
// distinct.
170177
let mut signatures = vec![];
171-
let mut seen_signers = HashSet::new();
172178
for hash in &txn_hashes {
173179
if let Some(txn) = self.transactions.get(hash) {
174180
match txn.authenticator() {
175181
TransactionAuthenticator::BLS { signature, .. } => {
176182
if let Ok(index) =
177183
senders.binary_search(&txn.sender())
178184
{
179-
// One slot per validator: guard the index so a
180-
// repeated signer can't abort aggregation.
181-
if seen_signers.insert(index) {
182-
signatures.push((signature, index));
183-
}
185+
signatures.push((signature, index));
184186
}
185187
}
186188
_ => unreachable!(),

crates/cfxcore/core/src/pos/mempool/core_mempool/transaction_store.rs

Lines changed: 123 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,38 @@ use diem_types::{
2424
transaction::{SignedTransaction, TransactionPayload},
2525
};
2626
use std::{
27-
collections::{hash_map::Values, HashMap, HashSet},
27+
collections::{hash_map::Values, HashMap},
2828
time::Duration,
2929
};
3030

31+
/// Votes for one pivot decision, keyed by sender (one per sender), tagged with
32+
/// the decision `height` for the commit-time sweep.
33+
pub(crate) struct PivotDecisionVotes {
34+
height: u64,
35+
votes: HashMap<AccountAddress, HashValue>,
36+
}
37+
38+
impl PivotDecisionVotes {
39+
fn new(height: u64) -> Self {
40+
Self {
41+
height,
42+
votes: HashMap::new(),
43+
}
44+
}
45+
46+
pub(crate) fn iter(
47+
&self,
48+
) -> impl Iterator<Item = (AccountAddress, HashValue)> + '_ {
49+
self.votes.iter().map(|(&addr, &hash)| (addr, hash))
50+
}
51+
}
52+
3153
/// TransactionStore is in-memory storage for all transactions in mempool.
3254
pub struct TransactionStore {
3355
// normal transactions
3456
transactions: AccountTransactions,
3557
// pivot decision helper structure
36-
pivot_decisions: HashMap<HashValue, HashSet<(AccountAddress, HashValue)>>,
58+
pivot_decisions: HashMap<HashValue, PivotDecisionVotes>,
3759

3860
// Evicts txns after `system_transaction_timeout` so stalled commit
3961
// callbacks cannot clog the mempool indefinitely.
@@ -46,8 +68,7 @@ pub struct TransactionStore {
4668
capacity_per_sender: usize,
4769
}
4870

49-
pub type PivotDecisionIter<'a> =
50-
Values<'a, HashValue, HashSet<(AccountAddress, HashValue)>>;
71+
pub type PivotDecisionIter<'a> = Values<'a, HashValue, PivotDecisionVotes>;
5172

5273
impl TransactionStore {
5374
pub(crate) fn new(config: &MempoolConfig) -> Self {
@@ -84,10 +105,7 @@ impl TransactionStore {
84105
&self, hash: &HashValue,
85106
) -> Vec<HashValue> {
86107
if let Some(decisions) = self.pivot_decisions.get(hash) {
87-
decisions
88-
.iter()
89-
.map(|(_, tx_hash)| tx_hash.clone())
90-
.collect::<_>()
108+
decisions.votes.values().copied().collect()
91109
} else {
92110
vec![]
93111
}
@@ -106,17 +124,16 @@ impl TransactionStore {
106124
return MempoolStatus::new(MempoolStatusCode::Accepted);
107125
}
108126

109-
// Only the payload is signed, so a validator could mint distinct-hash
110-
// duplicates of one vote by varying `chain_id`; keep one per sender.
111127
if let TransactionPayload::PivotDecision(pivot_decision) =
112128
txn.txn.payload()
113129
{
130+
// Only the payload is signed, so a validator could mint
131+
// distinct-hash duplicates of one vote by varying `chain_id`; keep
132+
// one per sender.
114133
let already_voted = self
115134
.pivot_decisions
116135
.get(&pivot_decision.hash())
117-
.is_some_and(|set| {
118-
set.iter().any(|(addr, _)| *addr == address)
119-
});
136+
.is_some_and(|set| set.votes.contains_key(&address));
120137
if already_voted {
121138
return MempoolStatus::new(MempoolStatusCode::Accepted);
122139
}
@@ -150,15 +167,14 @@ impl TransactionStore {
150167
txn.txn.payload()
151168
{
152169
let pivot_decision_hash = pivot_decision.hash();
153-
self.pivot_decisions
170+
let entry = self
171+
.pivot_decisions
154172
.entry(pivot_decision_hash)
155-
.or_insert_with(HashSet::new);
156-
if let Some(account_decision) =
157-
self.pivot_decisions.get_mut(&pivot_decision_hash)
158-
{
159-
diem_debug!("txpool::insert pivot {:?}", hash);
160-
account_decision.insert((address, hash));
161-
}
173+
.or_insert_with(|| {
174+
PivotDecisionVotes::new(pivot_decision.height)
175+
});
176+
diem_debug!("txpool::insert pivot {:?}", hash);
177+
entry.votes.insert(address, hash);
162178
self.transactions.insert(hash, txn, true);
163179
} else {
164180
self.transactions.insert(hash, txn, false);
@@ -173,26 +189,42 @@ impl TransactionStore {
173189
MempoolStatus::new(MempoolStatusCode::Accepted)
174190
}
175191

176-
/// Handles transaction commit: deletes the transaction and cleans up
177-
/// its entries in the timeline and TTL indexes.
192+
/// Removes a stored transaction, routing it through the log and index
193+
/// accounting — the invariant every `self.transactions` removal must
194+
/// uphold. Returns the removed txn for callers that need it (e.g. GC).
195+
fn remove_logged(
196+
&mut self, hash: &HashValue, log: &mut TxnsLog,
197+
) -> Option<MempoolTransaction> {
198+
let txn = self.transactions.remove(hash)?;
199+
log.add(txn.get_sender(), txn.get_hash());
200+
self.index_remove(&txn);
201+
Some(txn)
202+
}
203+
204+
/// Removes a committed transaction by hash.
178205
pub(crate) fn commit_transaction(&mut self, hash: HashValue) {
179206
let mut txns_log = TxnsLog::new();
180-
if let Some(transaction) = self.transactions.remove(&hash) {
181-
txns_log.add(transaction.get_sender(), transaction.get_hash());
182-
self.index_remove(&transaction);
183-
// handle pivot decision
184-
let payload = transaction.txn.into_raw_transaction().into_payload();
185-
if let TransactionPayload::PivotDecision(pivot_decision) = payload {
186-
let pivot_decision_hash = pivot_decision.hash();
187-
if let Some(indices) =
188-
self.pivot_decisions.remove(&pivot_decision_hash)
189-
{
190-
for (_, hash) in indices {
191-
if let Some(txn) = self.transactions.remove(&hash) {
192-
txns_log.add(txn.get_sender(), txn.get_hash());
193-
self.index_remove(&txn);
194-
}
195-
}
207+
self.remove_logged(&hash, &mut txns_log);
208+
diem_debug!(LogSchema::new(LogEntry::CleanCommittedTxn).txns(txns_log));
209+
}
210+
211+
/// Cleans up pivot-decision votes after a decision at `height` commits, by
212+
/// sweeping every set at or below it — keyed on height, not `hash`, so it
213+
/// survives a `chain_id` wrapper this node never stored, and it also
214+
/// reclaims obsolete lower / conflicting same-height sets that can no
215+
/// longer commit.
216+
pub(crate) fn commit_pivot_height(&mut self, height: u64) {
217+
let mut txns_log = TxnsLog::new();
218+
let obsolete: Vec<HashValue> = self
219+
.pivot_decisions
220+
.iter()
221+
.filter(|(_, v)| v.height <= height)
222+
.map(|(k, _)| *k)
223+
.collect();
224+
for key in obsolete {
225+
if let Some(entry) = self.pivot_decisions.remove(&key) {
226+
for (_, tx_hash) in entry.votes {
227+
self.remove_logged(&tx_hash, &mut txns_log);
196228
}
197229
}
198230
}
@@ -258,22 +290,19 @@ impl TransactionStore {
258290

259291
let mut gc_txns_log = TxnsLog::new();
260292
for key in gc_txns.iter() {
261-
if let Some(txn) = self.transactions.remove(&key.hash) {
293+
if let Some(txn) = self.remove_logged(&key.hash, &mut gc_txns_log) {
262294
let sender = txn.get_sender();
263-
let tx_hash = txn.get_hash();
264-
gc_txns_log.add(sender, tx_hash);
265-
self.index_remove(&txn);
266295
if let TransactionPayload::PivotDecision(pivot_decision) =
267296
txn.txn.into_raw_transaction().into_payload()
268297
{
269298
// Drop only this entry: other validators' live votes
270299
// share this set.
271300
let pivot_decision_hash = pivot_decision.hash();
272-
if let Some(account_decision) =
301+
if let Some(entry) =
273302
self.pivot_decisions.get_mut(&pivot_decision_hash)
274303
{
275-
account_decision.remove(&(sender, tx_hash));
276-
if account_decision.is_empty() {
304+
entry.votes.remove(&sender);
305+
if entry.votes.is_empty() {
277306
self.pivot_decisions.remove(&pivot_decision_hash);
278307
}
279308
}
@@ -343,6 +372,13 @@ mod tests {
343372
)
344373
}
345374

375+
fn pivot(height: u64, block_hash_byte: u8) -> PivotBlockDecision {
376+
PivotBlockDecision {
377+
block_hash: H256::from([block_hash_byte; 32]),
378+
height,
379+
}
380+
}
381+
346382
fn mk_pivot_txn(
347383
sk: &BLSPrivateKey, sender: AccountAddress,
348384
decision: &PivotBlockDecision, chain_id: u64,
@@ -435,6 +471,47 @@ mod tests {
435471
assert_eq!(store.per_sender_count[&sender_b], 1);
436472
}
437473

474+
/// Cleanup keys on committed height, not the envelope hash: committing d1
475+
/// via a `chain_id` wrapper never stored here still clears d1's set, plus
476+
/// every resident obsolete set (lower d0, conflicting same-height d2); a
477+
/// higher d_hi survives, and a repeat sweep is a no-op.
478+
#[test]
479+
fn cleanup_by_committed_height() {
480+
let mut store = store_with_cap(8);
481+
let (sk0, _, s0) = new_sender();
482+
let (sk1, _, s1) = new_sender();
483+
let (sk2, _, s2) = new_sender();
484+
let (sk_hi, _, s_hi) = new_sender();
485+
let d0 = pivot(100, 10);
486+
let d1 = pivot(120, 11);
487+
let d2 = pivot(120, 12); // same height as d1, conflicting branch
488+
let d_hi = pivot(121, 13);
489+
for (sk, s, d) in [(&sk0, s0, &d0), (&sk1, s1, &d1), (&sk2, s2, &d2)] {
490+
assert_eq!(
491+
store.insert(mk_pivot_txn(sk, s, d, 1)).code,
492+
MempoolStatusCode::Accepted
493+
);
494+
}
495+
assert_eq!(
496+
store.insert(mk_pivot_txn(&sk_hi, s_hi, &d_hi, 1)).code,
497+
MempoolStatusCode::Accepted
498+
);
499+
500+
// The network commits d1 via a chain_id=2 wrapper never stored here.
501+
let committed_d1 = mk_pivot_txn(&sk1, s1, &d1, 2).get_hash();
502+
assert!(store.get(&committed_d1).is_none());
503+
store.commit_transaction(committed_d1);
504+
store.commit_pivot_height(d1.height);
505+
store.commit_pivot_height(d1.height); // repeat = no-op
506+
507+
for (d, s) in [(&d0, &s0), (&d1, &s1), (&d2, &s2)] {
508+
assert!(store.get_pivot_decisions(&d.hash()).is_empty());
509+
assert_eq!(store.per_sender_count.get(s), None);
510+
}
511+
assert_eq!(store.get_pivot_decisions(&d_hi.hash()).len(), 1);
512+
assert_eq!(store.per_sender_count[&s_hi], 1);
513+
}
514+
438515
#[test]
439516
fn per_sender_count_insert_and_commit_lifecycle() {
440517
let mut store = store_with_cap(3);

crates/cfxcore/core/src/pos/mempool/shared_mempool/tasks.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ pub(crate) async fn process_committed_transactions(
240240
LogEvent::Received
241241
)
242242
.state_sync_msg(&req));
243-
commit_txns(&mempool, req.transactions).await;
243+
commit_txns(&mempool, req.transactions, req.committed_pivot_height).await;
244244
if req.callback.send(Ok(CommitResponse::success())).is_err() {
245245
diem_error!(LogSchema::event_log(
246246
LogEntry::StateSyncCommit,
@@ -295,10 +295,14 @@ pub(crate) async fn process_consensus_request(
295295

296296
async fn commit_txns(
297297
mempool: &Mutex<CoreMempool>, transactions: Vec<CommittedTransaction>,
298+
committed_pivot_height: Option<u64>,
298299
) {
299300
let mut pool = mempool.lock();
300301

301302
for transaction in transactions {
302303
pool.remove_transaction(transaction.hash);
303304
}
305+
if let Some(height) = committed_pivot_height {
306+
pool.commit_pivot_height(height);
307+
}
304308
}

crates/cfxcore/core/src/pos/mempool/shared_mempool/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ pub struct ConsensusResponse {
153153
/// This notifies mempool to remove committed txns.
154154
pub struct CommitNotification {
155155
pub transactions: Vec<CommittedTransaction>,
156+
/// Height of the committed pivot decision (max, if the batch spans
157+
/// several); the mempool sweeps every pivot-decision set at or below it.
158+
/// Kept per notification rather than per txn: it is a whole-commit fact.
159+
pub committed_pivot_height: Option<u64>,
156160
pub callback: oneshot::Sender<Result<CommitResponse>>,
157161
}
158162

0 commit comments

Comments
 (0)