Skip to content

Commit a9022e4

Browse files
authored
Merge pull request #6202 from hstove/feat/tx-replay-unmineable-only
fix: allow replay set to be cleared when there are only unmineable transactions
2 parents 0695b41 + fafac7c commit a9022e4

File tree

5 files changed

+445
-112
lines changed

5 files changed

+445
-112
lines changed

libsigner/src/events.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ impl<T: SignerEventTrait> EventReceiver<T> for SignerEventReceiver<T> {
427427
event_receiver.stop_signal.store(true, Ordering::SeqCst);
428428
Err(EventError::Terminated)
429429
} else if request.url() == "/new_block" {
430-
process_event::<T, BlockEvent>(request)
430+
process_event::<T, StacksBlockEvent>(request)
431431
} else {
432432
let url = request.url().to_string();
433433
debug!(
@@ -655,7 +655,8 @@ fn deserialize_raw_tx_hex<'de, D: serde::Deserializer<'de>>(
655655
}
656656

657657
#[derive(Debug, Deserialize)]
658-
struct BlockEvent {
658+
/// Payload received from the event dispatcher for a new Stacks block
659+
pub struct StacksBlockEvent {
659660
#[serde(with = "prefix_hex")]
660661
index_block_hash: StacksBlockId,
661662
#[serde(with = "prefix_opt_hex")]
@@ -666,14 +667,15 @@ struct BlockEvent {
666667
#[serde(with = "prefix_hex")]
667668
block_hash: BlockHeaderHash,
668669
block_height: u64,
670+
/// The transactions included in the block
669671
#[serde(deserialize_with = "deserialize_raw_tx_hex")]
670-
transactions: Vec<StacksTransaction>,
672+
pub transactions: Vec<StacksTransaction>,
671673
}
672674

673-
impl<T: SignerEventTrait> TryFrom<BlockEvent> for SignerEvent<T> {
675+
impl<T: SignerEventTrait> TryFrom<StacksBlockEvent> for SignerEvent<T> {
674676
type Error = EventError;
675677

676-
fn try_from(block_event: BlockEvent) -> Result<Self, Self::Error> {
678+
fn try_from(block_event: StacksBlockEvent) -> Result<Self, Self::Error> {
677679
Ok(SignerEvent::NewBlock {
678680
signer_sighash: block_event.signer_signature_hash,
679681
block_id: block_event.index_block_hash,

libsigner/src/libsigner.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ use stacks_common::versions::STACKS_SIGNER_VERSION;
5858
pub use crate::error::{EventError, RPCError};
5959
pub use crate::events::{
6060
BlockProposal, BlockProposalData, BurnBlockEvent, EventReceiver, EventStopSignaler,
61-
SignerEvent, SignerEventReceiver, SignerEventTrait, SignerStopSignaler,
61+
SignerEvent, SignerEventReceiver, SignerEventTrait, SignerStopSignaler, StacksBlockEvent,
6262
};
6363
pub use crate::runloop::{RunningSigner, Signer, SignerRunLoop};
6464
pub use crate::session::{SignerSession, StackerDBSession};

stackslib/src/net/api/postblock_proposal.rs

Lines changed: 198 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@ use stacks_common::util::tests::TestFlag;
3838
use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleConn};
3939
use crate::chainstate::nakamoto::miner::NakamotoBlockBuilder;
4040
use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState, NAKAMOTO_BLOCK_VERSION};
41-
use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState};
41+
use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState, StacksHeaderInfo};
4242
use crate::chainstate::stacks::miner::{
4343
BlockBuilder, BlockLimitFunction, TransactionError, TransactionProblematic, TransactionResult,
4444
TransactionSkipped,
4545
};
46-
use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload};
46+
use crate::chainstate::stacks::{
47+
Error as ChainError, StacksTransaction, TenureChangeCause, TransactionPayload,
48+
};
4749
use crate::clarity_vm::clarity::Error as ClarityError;
4850
use crate::core::mempool::ProposalCallbackReceiver;
4951
use crate::net::http::{
@@ -536,6 +538,17 @@ impl NakamotoBlockProposal {
536538
_ => None,
537539
});
538540

541+
let replay_tx_exhausted = self.validate_replay(
542+
&parent_stacks_header,
543+
tenure_change,
544+
coinbase,
545+
tenure_cause,
546+
chainstate.mainnet,
547+
chainstate.chain_id,
548+
&chainstate.root_path.clone(),
549+
&burn_dbconn,
550+
)?;
551+
539552
let mut builder = NakamotoBlockBuilder::new(
540553
&parent_stacks_header,
541554
&self.block.header.consensus_hash,
@@ -550,109 +563,9 @@ impl NakamotoBlockProposal {
550563
builder.load_tenure_info(chainstate, &burn_dbconn, tenure_cause)?;
551564
let mut tenure_tx = builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info)?;
552565

553-
let mut replay_txs_maybe: Option<VecDeque<StacksTransaction>> =
554-
self.replay_txs.clone().map(|txs| txs.into());
555-
556-
let mut replay_tx_exhausted = false;
557-
558566
for (i, tx) in self.block.txs.iter().enumerate() {
559567
let tx_len = tx.tx_len();
560568

561-
// If a list of replay transactions is set, this transaction must be the next
562-
// mineable transaction from this list.
563-
if let Some(ref mut replay_txs) = replay_txs_maybe {
564-
loop {
565-
if matches!(
566-
tx.payload,
567-
TransactionPayload::TenureChange(..) | TransactionPayload::Coinbase(..)
568-
) {
569-
// Allow this to happen, tenure extend checks happen elsewhere.
570-
break;
571-
}
572-
let Some(replay_tx) = replay_txs.pop_front() else {
573-
// During transaction replay, we expect that the block only
574-
// contains transactions from the replay set. Thus, if we're here,
575-
// the block contains a transaction that is not in the replay set,
576-
// and we should reject the block.
577-
warn!("Rejected block proposal. Block contains transactions beyond the replay set.";
578-
"txid" => %tx.txid(),
579-
"tx_index" => i,
580-
);
581-
return Err(BlockValidateRejectReason {
582-
reason_code: ValidateRejectCode::InvalidTransactionReplay,
583-
reason: "Block contains transactions beyond the replay set".into(),
584-
});
585-
};
586-
if replay_tx.txid() == tx.txid() {
587-
break;
588-
}
589-
590-
// The included tx doesn't match the next tx in the
591-
// replay set. Check to see if the tx is skipped because
592-
// it was unmineable.
593-
let tx_result = builder.try_mine_tx_with_len(
594-
&mut tenure_tx,
595-
&replay_tx,
596-
replay_tx.tx_len(),
597-
&BlockLimitFunction::NO_LIMIT_HIT,
598-
ASTRules::PrecheckSize,
599-
None,
600-
);
601-
match tx_result {
602-
TransactionResult::Skipped(TransactionSkipped { error, .. })
603-
| TransactionResult::ProcessingError(TransactionError { error, .. })
604-
| TransactionResult::Problematic(TransactionProblematic {
605-
error, ..
606-
}) => {
607-
// The tx wasn't able to be mined. Check the underlying error, to
608-
// see if we should reject the block or allow the tx to be
609-
// dropped from the replay set.
610-
611-
match error {
612-
ChainError::CostOverflowError(..)
613-
| ChainError::BlockTooBigError
614-
| ChainError::ClarityError(ClarityError::CostError(..)) => {
615-
// block limit reached; add tx back to replay set.
616-
// BUT we know that the block should have ended at this point, so
617-
// return an error.
618-
let txid = replay_tx.txid();
619-
replay_txs.push_front(replay_tx);
620-
621-
warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block.";
622-
"error" => %error,
623-
"txid" => %txid,
624-
);
625-
626-
return Err(BlockValidateRejectReason {
627-
reason_code: ValidateRejectCode::InvalidTransactionReplay,
628-
reason: "Transaction is not in the replay set".into(),
629-
});
630-
}
631-
_ => {
632-
// it's ok, drop it
633-
continue;
634-
}
635-
}
636-
}
637-
TransactionResult::Success(_) => {
638-
// Tx should have been included
639-
warn!("Rejected block proposal. Block doesn't contain replay transaction that should have been included.";
640-
"block_txid" => %tx.txid(),
641-
"block_tx_index" => i,
642-
"replay_txid" => %replay_tx.txid(),
643-
);
644-
return Err(BlockValidateRejectReason {
645-
reason_code: ValidateRejectCode::InvalidTransactionReplay,
646-
reason: "Transaction is not in the replay set".into(),
647-
});
648-
}
649-
};
650-
}
651-
if replay_txs.is_empty() {
652-
replay_tx_exhausted = true;
653-
}
654-
}
655-
656569
let tx_result = builder.try_mine_tx_with_len(
657570
&mut tenure_tx,
658571
tx,
@@ -757,6 +670,189 @@ impl NakamotoBlockProposal {
757670
hasher.finish()
758671
})
759672
}
673+
674+
/// Validate the block against the replay set.
675+
///
676+
/// Returns a boolean indicating whether this block exhausts the replay set.
677+
///
678+
/// Returns `false` if there is no replay set.
679+
pub fn validate_replay(
680+
&self,
681+
parent_stacks_header: &StacksHeaderInfo,
682+
tenure_change: Option<&StacksTransaction>,
683+
coinbase: Option<&StacksTransaction>,
684+
tenure_cause: Option<TenureChangeCause>,
685+
mainnet: bool,
686+
chain_id: u32,
687+
chainstate_path: &str,
688+
burn_dbconn: &SortitionHandleConn,
689+
) -> Result<bool, BlockValidateRejectReason> {
690+
let mut replay_txs_maybe: Option<VecDeque<StacksTransaction>> =
691+
self.replay_txs.clone().map(|txs| txs.into());
692+
693+
let Some(ref mut replay_txs) = replay_txs_maybe else {
694+
return Ok(false);
695+
};
696+
697+
let mut replay_builder = NakamotoBlockBuilder::new(
698+
&parent_stacks_header,
699+
&self.block.header.consensus_hash,
700+
self.block.header.burn_spent,
701+
tenure_change,
702+
coinbase,
703+
self.block.header.pox_treatment.len(),
704+
None,
705+
)?;
706+
let (mut replay_chainstate, _) =
707+
StacksChainState::open(mainnet, chain_id, chainstate_path, None)?;
708+
let mut replay_miner_tenure_info =
709+
replay_builder.load_tenure_info(&mut replay_chainstate, &burn_dbconn, tenure_cause)?;
710+
let mut replay_tenure_tx =
711+
replay_builder.tenure_begin(&burn_dbconn, &mut replay_miner_tenure_info)?;
712+
713+
for (i, tx) in self.block.txs.iter().enumerate() {
714+
let tx_len = tx.tx_len();
715+
716+
// If a list of replay transactions is set, this transaction must be the next
717+
// mineable transaction from this list.
718+
loop {
719+
if matches!(
720+
tx.payload,
721+
TransactionPayload::TenureChange(..) | TransactionPayload::Coinbase(..)
722+
) {
723+
// Allow this to happen, tenure extend checks happen elsewhere.
724+
break;
725+
}
726+
let Some(replay_tx) = replay_txs.pop_front() else {
727+
// During transaction replay, we expect that the block only
728+
// contains transactions from the replay set. Thus, if we're here,
729+
// the block contains a transaction that is not in the replay set,
730+
// and we should reject the block.
731+
warn!("Rejected block proposal. Block contains transactions beyond the replay set.";
732+
"txid" => %tx.txid(),
733+
"tx_index" => i,
734+
);
735+
return Err(BlockValidateRejectReason {
736+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
737+
reason: "Block contains transactions beyond the replay set".into(),
738+
});
739+
};
740+
if replay_tx.txid() == tx.txid() {
741+
break;
742+
}
743+
744+
// The included tx doesn't match the next tx in the
745+
// replay set. Check to see if the tx is skipped because
746+
// it was unmineable.
747+
let tx_result = replay_builder.try_mine_tx_with_len(
748+
&mut replay_tenure_tx,
749+
&replay_tx,
750+
replay_tx.tx_len(),
751+
&BlockLimitFunction::NO_LIMIT_HIT,
752+
ASTRules::PrecheckSize,
753+
None,
754+
);
755+
match tx_result {
756+
TransactionResult::Skipped(TransactionSkipped { error, .. })
757+
| TransactionResult::ProcessingError(TransactionError { error, .. })
758+
| TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
759+
// The tx wasn't able to be mined. Check the underlying error, to
760+
// see if we should reject the block or allow the tx to be
761+
// dropped from the replay set.
762+
763+
match error {
764+
ChainError::CostOverflowError(..)
765+
| ChainError::BlockTooBigError
766+
| ChainError::ClarityError(ClarityError::CostError(..)) => {
767+
// block limit reached; add tx back to replay set.
768+
// BUT we know that the block should have ended at this point, so
769+
// return an error.
770+
let txid = replay_tx.txid();
771+
replay_txs.push_front(replay_tx);
772+
773+
warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block.";
774+
"error" => %error,
775+
"txid" => %txid,
776+
);
777+
778+
return Err(BlockValidateRejectReason {
779+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
780+
reason: "Next replay tx exceeds cost limits, so should have been in the next block.".into(),
781+
});
782+
}
783+
_ => {
784+
info!("During replay block validation, allowing problematic tx to be dropped";
785+
"txid" => %replay_tx.txid(),
786+
"error" => %error,
787+
);
788+
// it's ok, drop it
789+
continue;
790+
}
791+
}
792+
}
793+
TransactionResult::Success(_) => {
794+
// Tx should have been included
795+
warn!("Rejected block proposal. Block doesn't contain replay transaction that should have been included.";
796+
"block_txid" => %tx.txid(),
797+
"block_tx_index" => i,
798+
"replay_txid" => %replay_tx.txid(),
799+
);
800+
return Err(BlockValidateRejectReason {
801+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
802+
reason: "Transaction is not in the replay set".into(),
803+
});
804+
}
805+
};
806+
}
807+
808+
// Apply the block's transaction to our block builder, but we don't
809+
// actually care about the result - that happens in the main
810+
// validation check.
811+
let _tx_result = replay_builder.try_mine_tx_with_len(
812+
&mut replay_tenure_tx,
813+
tx,
814+
tx_len,
815+
&BlockLimitFunction::NO_LIMIT_HIT,
816+
ASTRules::PrecheckSize,
817+
None,
818+
);
819+
}
820+
821+
let no_replay_txs_remaining = replay_txs.is_empty();
822+
823+
// Now, we need to check if the remaining replay transactions are unmineable.
824+
let only_unmineable_remaining = !replay_txs.is_empty()
825+
&& replay_txs.iter().all(|tx| {
826+
let tx_result = replay_builder.try_mine_tx_with_len(
827+
&mut replay_tenure_tx,
828+
&tx,
829+
tx.tx_len(),
830+
&BlockLimitFunction::NO_LIMIT_HIT,
831+
ASTRules::PrecheckSize,
832+
None,
833+
);
834+
match tx_result {
835+
TransactionResult::Skipped(TransactionSkipped { error, .. })
836+
| TransactionResult::ProcessingError(TransactionError { error, .. })
837+
| TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
838+
// If it's just a cost error, it's not unmineable.
839+
!matches!(
840+
error,
841+
ChainError::CostOverflowError(..)
842+
| ChainError::BlockTooBigError
843+
| ChainError::ClarityError(ClarityError::CostError(..))
844+
)
845+
}
846+
TransactionResult::Success(_) => {
847+
// The tx could have been included, but wasn't. This is ok, but we
848+
// haven't exhausted the replay set.
849+
false
850+
}
851+
}
852+
});
853+
854+
Ok(no_replay_txs_remaining || only_unmineable_remaining)
855+
}
760856
}
761857

762858
#[derive(Clone, Default)]

0 commit comments

Comments
 (0)