Skip to content

Commit e505550

Browse files
authored
Merge pull request #5994 from hstove/feat/replay-signer-validation
feat: add validation, test for signer validation replay blocks
2 parents 01ed033 + 509b103 commit e505550

File tree

12 files changed

+838
-192
lines changed

12 files changed

+838
-192
lines changed

stacks-signer/src/client/stacks_client.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ impl StacksClient {
313313
let block_proposal = NakamotoBlockProposal {
314314
block,
315315
chain_id: self.chain_id,
316+
replay_txs: None,
316317
};
317318
let timer = crate::monitoring::actions::new_rpc_call_timer(
318319
&self.block_proposal_path(),

stackslib/src/core/test_util.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use clarity::codec::StacksMessageCodec;
55
use clarity::types::chainstate::{
66
BlockHeaderHash, ConsensusHash, StacksAddress, StacksPrivateKey, StacksPublicKey,
77
};
8+
use clarity::vm::costs::ExecutionCost;
89
use clarity::vm::tests::BurnStateDB;
910
use clarity::vm::types::PrincipalData;
1011
use clarity::vm::{ClarityName, ClarityVersion, ContractName, Value};
@@ -269,17 +270,29 @@ pub fn to_addr(sk: &StacksPrivateKey) -> StacksAddress {
269270
StacksAddress::p2pkh(false, &StacksPublicKey::from_private(sk))
270271
}
271272

272-
pub fn make_stacks_transfer(
273+
pub fn make_stacks_transfer_tx(
273274
sender: &StacksPrivateKey,
274275
nonce: u64,
275276
tx_fee: u64,
276277
chain_id: u32,
277278
recipient: &PrincipalData,
278279
amount: u64,
279-
) -> Vec<u8> {
280+
) -> StacksTransaction {
280281
let payload =
281282
TransactionPayload::TokenTransfer(recipient.clone(), amount, TokenTransferMemo([0; 34]));
282-
let tx = sign_standard_single_sig_tx(payload, sender, nonce, tx_fee, chain_id);
283+
sign_standard_single_sig_tx(payload, sender, nonce, tx_fee, chain_id)
284+
}
285+
286+
/// Make a stacks transfer transaction, returning the serialized transaction bytes
287+
pub fn make_stacks_transfer_serialized(
288+
sender: &StacksPrivateKey,
289+
nonce: u64,
290+
tx_fee: u64,
291+
chain_id: u32,
292+
recipient: &PrincipalData,
293+
amount: u64,
294+
) -> Vec<u8> {
295+
let tx = make_stacks_transfer_tx(sender, nonce, tx_fee, chain_id, recipient, amount);
283296
let mut tx_bytes = vec![];
284297
tx.consensus_serialize(&mut tx_bytes).unwrap();
285298
tx_bytes
@@ -512,3 +525,25 @@ pub fn insert_tx_in_mempool(
512525
.execute(sql, args)
513526
.expect("Failed to insert transaction into mempool");
514527
}
528+
529+
/// Generate source code for a contract that exposes a public function
530+
/// `big-tx`. This function uses `proportion` of read_count when called
531+
pub fn make_big_read_count_contract(limit: ExecutionCost, proportion: u64) -> String {
532+
let read_count = (limit.read_count * proportion) / 100;
533+
534+
let read_lines = (0..read_count)
535+
.map(|_| format!("(var-get my-var)"))
536+
.collect::<Vec<_>>()
537+
.join("\n");
538+
539+
format!(
540+
"
541+
(define-data-var my-var uint u0)
542+
(define-public (big-tx)
543+
(begin
544+
{}
545+
(ok true)))
546+
",
547+
read_lines
548+
)
549+
}

stackslib/src/core/tests/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ use crate::core::mempool::{
6565
db_get_all_nonces, MemPoolSyncData, MemPoolWalkSettings, MemPoolWalkTxTypes, TxTag,
6666
BLOOM_COUNTER_DEPTH, BLOOM_COUNTER_ERROR_RATE, MAX_BLOOM_COUNTER_TXS,
6767
};
68-
use crate::core::test_util::{insert_tx_in_mempool, make_stacks_transfer, to_addr};
68+
use crate::core::test_util::{insert_tx_in_mempool, make_stacks_transfer_serialized, to_addr};
6969
use crate::core::{FIRST_BURNCHAIN_CONSENSUS_HASH, FIRST_STACKS_BLOCK_HASH};
7070
use crate::net::Error as NetError;
7171
use crate::util_lib::bloom::test::setup_bloom_counter;
@@ -2795,7 +2795,7 @@ fn large_mempool() {
27952795
let sender_addr = to_addr(sender_sk);
27962796
let fee = thread_rng().gen_range(180..2000);
27972797
let transfer_tx =
2798-
make_stacks_transfer(sender_sk, *nonce, fee, 0x80000000, &recipient, 1);
2798+
make_stacks_transfer_serialized(sender_sk, *nonce, fee, 0x80000000, &recipient, 1);
27992799
insert_tx_in_mempool(
28002800
&mempool_tx,
28012801
transfer_tx,

stackslib/src/net/api/postblock_proposal.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// You should have received a copy of the GNU General Public License
1515
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

17+
use std::collections::VecDeque;
1718
use std::io::{Read, Write};
1819
#[cfg(any(test, feature = "testing"))]
1920
use std::sync::LazyLock;
@@ -48,10 +49,14 @@ use crate::chainstate::nakamoto::miner::NakamotoBlockBuilder;
4849
use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState, NAKAMOTO_BLOCK_VERSION};
4950
use crate::chainstate::stacks::db::blocks::MINIMUM_TX_FEE_RATE_PER_BYTE;
5051
use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState};
51-
use crate::chainstate::stacks::miner::{BlockBuilder, BlockLimitFunction, TransactionResult};
52+
use crate::chainstate::stacks::miner::{
53+
BlockBuilder, BlockLimitFunction, TransactionError, TransactionProblematic, TransactionResult,
54+
TransactionSkipped,
55+
};
5256
use crate::chainstate::stacks::{
5357
Error as ChainError, StacksBlock, StacksBlockHeader, StacksTransaction, TransactionPayload,
5458
};
59+
use crate::clarity_vm::clarity::Error as ClarityError;
5560
use crate::core::mempool::{MemPoolDB, ProposalCallbackReceiver};
5661
use crate::cost_estimates::FeeRateEstimate;
5762
use crate::net::http::{
@@ -76,6 +81,11 @@ pub static TEST_VALIDATE_STALL: LazyLock<TestFlag<bool>> = LazyLock::new(TestFla
7681
/// Artificial delay to add to block validation.
7782
pub static TEST_VALIDATE_DELAY_DURATION_SECS: LazyLock<TestFlag<u64>> =
7883
LazyLock::new(TestFlag::default);
84+
#[cfg(any(test, feature = "testing"))]
85+
/// Mock for the set of transactions that must be replayed
86+
pub static TEST_REPLAY_TRANSACTIONS: LazyLock<
87+
TestFlag<std::collections::VecDeque<StacksTransaction>>,
88+
> = LazyLock::new(TestFlag::default);
7989

8090
// This enum is used to supply a `reason_code` for validation
8191
// rejection responses. This is serialized as an enum with string
@@ -87,7 +97,8 @@ define_u8_enum![ValidateRejectCode {
8797
ChainstateError = 3,
8898
UnknownParent = 4,
8999
NonCanonicalTenure = 5,
90-
NoSuchTenure = 6
100+
NoSuchTenure = 6,
101+
InvalidTransactionReplay = 7
91102
}];
92103

93104
pub static TOO_MANY_REQUESTS_STATUS: u16 = 429;
@@ -207,6 +218,8 @@ pub struct NakamotoBlockProposal {
207218
pub block: NakamotoBlock,
208219
/// Identifies which chain block is for (Mainnet, Testnet, etc.)
209220
pub chain_id: u32,
221+
/// Optional transaction replay set
222+
pub replay_txs: Option<Vec<StacksTransaction>>,
210223
}
211224

212225
impl NakamotoBlockProposal {
@@ -372,6 +385,11 @@ impl NakamotoBlockProposal {
372385
/// - Miner signature is valid
373386
/// - Validation of transactions by executing them agains current chainstate.
374387
/// This is resource intensive, and therefore done only if previous checks pass
388+
///
389+
/// During transaction replay, we also check that the block only contains the unmined
390+
/// transactions that need to be replayed, up until either:
391+
/// - The set of transactions that must be replayed is exhausted
392+
/// - A cost limit is hit
375393
pub fn validate(
376394
&self,
377395
sortdb: &SortitionDB,
@@ -541,8 +559,88 @@ impl NakamotoBlockProposal {
541559
builder.load_tenure_info(chainstate, &burn_dbconn, tenure_cause)?;
542560
let mut tenure_tx = builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info)?;
543561

562+
let mut replay_txs_maybe: Option<VecDeque<StacksTransaction>> =
563+
self.replay_txs.clone().map(|txs| txs.into());
564+
544565
for (i, tx) in self.block.txs.iter().enumerate() {
545566
let tx_len = tx.tx_len();
567+
568+
// If a list of replay transactions is set, this transaction must be the next
569+
// mineable transaction from this list.
570+
if let Some(ref mut replay_txs) = replay_txs_maybe {
571+
loop {
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+
return Err(BlockValidateRejectReason {
578+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
579+
reason: "Transaction is not in the replay set".into(),
580+
});
581+
};
582+
if replay_tx.txid() == tx.txid() {
583+
break;
584+
}
585+
586+
// The included tx doesn't match the next tx in the
587+
// replay set. Check to see if the tx is skipped because
588+
// it was unmineable.
589+
let tx_result = builder.try_mine_tx_with_len(
590+
&mut tenure_tx,
591+
&replay_tx,
592+
replay_tx.tx_len(),
593+
&BlockLimitFunction::NO_LIMIT_HIT,
594+
ASTRules::PrecheckSize,
595+
None,
596+
);
597+
match tx_result {
598+
TransactionResult::Skipped(TransactionSkipped { error, .. })
599+
| TransactionResult::ProcessingError(TransactionError { error, .. })
600+
| TransactionResult::Problematic(TransactionProblematic {
601+
error, ..
602+
}) => {
603+
// The tx wasn't able to be mined. Check the underlying error, to
604+
// see if we should reject the block or allow the tx to be
605+
// dropped from the replay set.
606+
607+
match error {
608+
ChainError::CostOverflowError(..)
609+
| ChainError::BlockTooBigError
610+
| ChainError::ClarityError(ClarityError::CostError(..)) => {
611+
// block limit reached; add tx back to replay set.
612+
// BUT we know that the block should have ended at this point, so
613+
// return an error.
614+
let txid = replay_tx.txid();
615+
replay_txs.push_front(replay_tx);
616+
617+
warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block.";
618+
"error" => %error,
619+
"txid" => %txid,
620+
);
621+
622+
return Err(BlockValidateRejectReason {
623+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
624+
reason: "Transaction is not in the replay set".into(),
625+
});
626+
}
627+
_ => {
628+
// it's ok, drop it
629+
continue;
630+
}
631+
}
632+
}
633+
TransactionResult::Success(_) => {
634+
// Tx should have been included
635+
return Err(BlockValidateRejectReason {
636+
reason_code: ValidateRejectCode::InvalidTransactionReplay,
637+
reason: "Transaction is not in the replay set".into(),
638+
});
639+
}
640+
};
641+
}
642+
}
643+
546644
let tx_result = builder.try_mine_tx_with_len(
547645
&mut tenure_tx,
548646
tx,

0 commit comments

Comments
 (0)