Skip to content

Commit bca320c

Browse files
committed
fix: allow replay set to be cleared when there are only unmineable transactions
1 parent 2493ed1 commit bca320c

File tree

5 files changed

+457
-112
lines changed

5 files changed

+457
-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: 199 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,190 @@ 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+
// if let Some(ref mut replay_txs) = replay_txs_maybe {
719+
loop {
720+
if matches!(
721+
tx.payload,
722+
TransactionPayload::TenureChange(..) | TransactionPayload::Coinbase(..)
723+
) {
724+
// Allow this to happen, tenure extend checks happen elsewhere.
725+
break;
726+
}
727+
let Some(replay_tx) = replay_txs.pop_front() else {
728+
// During transaction replay, we expect that the block only
729+
// contains transactions from the replay set. Thus, if we're here,
730+
// the block contains a transaction that is not in the replay set,
731+
// and we should reject the block.
732+
warn!("Rejected block proposal. Block contains transactions beyond the replay set.";
733+
"txid" => %tx.txid(),
734+
"tx_index" => i,
735+
);
736+
return Err(BlockValidateRejectReason {
737+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
738+
reason: "Block contains transactions beyond the replay set".into(),
739+
});
740+
};
741+
if replay_tx.txid() == tx.txid() {
742+
break;
743+
}
744+
745+
// The included tx doesn't match the next tx in the
746+
// replay set. Check to see if the tx is skipped because
747+
// it was unmineable.
748+
let tx_result = replay_builder.try_mine_tx_with_len(
749+
&mut replay_tenure_tx,
750+
&replay_tx,
751+
replay_tx.tx_len(),
752+
&BlockLimitFunction::NO_LIMIT_HIT,
753+
ASTRules::PrecheckSize,
754+
None,
755+
);
756+
match tx_result {
757+
TransactionResult::Skipped(TransactionSkipped { error, .. })
758+
| TransactionResult::ProcessingError(TransactionError { error, .. })
759+
| TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
760+
// The tx wasn't able to be mined. Check the underlying error, to
761+
// see if we should reject the block or allow the tx to be
762+
// dropped from the replay set.
763+
764+
match error {
765+
ChainError::CostOverflowError(..)
766+
| ChainError::BlockTooBigError
767+
| ChainError::ClarityError(ClarityError::CostError(..)) => {
768+
// block limit reached; add tx back to replay set.
769+
// BUT we know that the block should have ended at this point, so
770+
// return an error.
771+
let txid = replay_tx.txid();
772+
replay_txs.push_front(replay_tx);
773+
774+
warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block.";
775+
"error" => %error,
776+
"txid" => %txid,
777+
);
778+
779+
return Err(BlockValidateRejectReason {
780+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
781+
reason: "Transaction is not in the replay set".into(),
782+
});
783+
}
784+
_ => {
785+
info!("During replay block validation, allowing problematic tx to be dropped";
786+
"txid" => %replay_tx.txid(),
787+
"error" => %error,
788+
);
789+
// it's ok, drop it
790+
continue;
791+
}
792+
}
793+
}
794+
TransactionResult::Success(_) => {
795+
// Tx should have been included
796+
warn!("Rejected block proposal. Block doesn't contain replay transaction that should have been included.";
797+
"block_txid" => %tx.txid(),
798+
"block_tx_index" => i,
799+
"replay_txid" => %replay_tx.txid(),
800+
);
801+
return Err(BlockValidateRejectReason {
802+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
803+
reason: "Transaction is not in the replay set".into(),
804+
});
805+
}
806+
};
807+
}
808+
809+
// Apply the block's transaction to our block builder, but we don't
810+
// actually care about the result - that happens in the main
811+
// validation check.
812+
let _tx_result = replay_builder.try_mine_tx_with_len(
813+
&mut replay_tenure_tx,
814+
tx,
815+
tx_len,
816+
&BlockLimitFunction::NO_LIMIT_HIT,
817+
ASTRules::PrecheckSize,
818+
None,
819+
);
820+
}
821+
822+
let no_replay_txs_remaining = replay_txs.is_empty();
823+
824+
// Now, we need to check if the remaining replay transactions are unmineable.
825+
let only_unmineable_remaining = replay_txs.is_empty()
826+
|| replay_txs.iter().all(|tx| {
827+
let tx_result = replay_builder.try_mine_tx_with_len(
828+
&mut replay_tenure_tx,
829+
&tx,
830+
tx.tx_len(),
831+
&BlockLimitFunction::NO_LIMIT_HIT,
832+
ASTRules::PrecheckSize,
833+
None,
834+
);
835+
match tx_result {
836+
TransactionResult::Skipped(TransactionSkipped { error, .. })
837+
| TransactionResult::ProcessingError(TransactionError { error, .. })
838+
| TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
839+
// If it's just a cost error, it's not unmineable.
840+
!matches!(
841+
error,
842+
ChainError::CostOverflowError(..)
843+
| ChainError::BlockTooBigError
844+
| ChainError::ClarityError(ClarityError::CostError(..))
845+
)
846+
}
847+
TransactionResult::Success(_) => {
848+
// The tx could have been included, but wasn't. This is ok, but we
849+
// haven't exhausted the replay set.
850+
false
851+
}
852+
}
853+
});
854+
855+
Ok(no_replay_txs_remaining || only_unmineable_remaining)
856+
}
760857
}
761858

762859
#[derive(Clone, Default)]

0 commit comments

Comments
 (0)