Skip to content

Commit 2c86c9c

Browse files
committed
feat: implement tx replay rejection across reward cycle, #5970
1 parent 1015b9f commit 2c86c9c

File tree

3 files changed

+158
-0
lines changed

3 files changed

+158
-0
lines changed

stacks-signer/src/v0/signer_state.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,21 @@ impl LocalStateMachine {
849849
}
850850
let fork_info =
851851
client.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)?;
852+
853+
// Check if fork occurred within current reward cycle. Reject tx replay otherwise.
854+
let reward_cycle_info = client.get_current_reward_cycle_info()?;
855+
let current_reward_cycle = reward_cycle_info.reward_cycle;
856+
let is_fork_in_current_reward_cycle = fork_info.iter().all(|fork_info| {
857+
let block_height = fork_info.burn_block_height;
858+
let block_rc = reward_cycle_info.get_reward_cycle(block_height);
859+
block_rc == current_reward_cycle
860+
});
861+
if !is_fork_in_current_reward_cycle {
862+
info!("Detected bitcoin fork occurred in previous reward cycle. Tx replay won't be executed");
863+
return Ok(None);
864+
}
865+
866+
// Collect transactions to be replayed across the forked blocks
852867
let mut forked_blocks = fork_info
853868
.iter()
854869
.flat_map(|fork_info| fork_info.nakamoto_blocks.iter().flatten())

stackslib/src/burnchains/burnchain.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,14 @@ impl Burnchain {
578578
.nakamoto_first_block_of_cycle(self.first_block_height, reward_cycle)
579579
}
580580

581+
/// the last burn block that must be *signed* by the signer set of `reward_cycle`.
582+
/// this is the modulo 1 block
583+
pub fn nakamoto_last_block_of_cycle(&self, reward_cycle: u64) -> u64 {
584+
self.nakamoto_first_block_of_cycle(reward_cycle)
585+
+ self.pox_constants.reward_cycle_length as u64
586+
- 1
587+
}
588+
581589
/// What is the reward cycle for this block height?
582590
/// This considers the modulo 0 block to be in reward cycle `n`, even though
583591
/// rewards for cycle `n` do not begin until modulo 1.

testnet/stacks-node/src/tests/signer/v0.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3379,6 +3379,141 @@ fn tx_replay_forking_test() {
33793379
signer_test.shutdown();
33803380
}
33813381

3382+
#[test]
3383+
#[ignore]
3384+
/// Trigger a Bitcoin fork across reward cycle
3385+
/// and ensure that the signers detect the fork,
3386+
/// but reject to move into a tx replay state
3387+
///
3388+
/// The test flow is:
3389+
///
3390+
/// - Boot to Epoch 3 (that is in the middle of reward cycle N)
3391+
/// - Mine until the last tenure of the reward cycle N
3392+
/// - Include a STX transfer in the last tenure
3393+
/// - Mine 1 Bitcoin block in the next reward cycle N+1
3394+
/// - Trigger a Bitcoin fork from reward cycle N (3 blocks)
3395+
/// - Verify that signers don't move into tx replay state
3396+
/// - In the end, the STX transfer transaction is not replayed
3397+
fn tx_replay_rejected_when_forking_across_reward_cycle() {
3398+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
3399+
return;
3400+
}
3401+
3402+
let num_signers = 5;
3403+
let sender_sk = Secp256k1PrivateKey::random();
3404+
let sender_addr = tests::to_addr(&sender_sk);
3405+
let send_amt = 100;
3406+
let send_fee = 180;
3407+
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
3408+
num_signers,
3409+
vec![(sender_addr, (send_amt + send_fee) * 10)],
3410+
|_| {},
3411+
|node_config| {
3412+
node_config.miner.block_commit_delay = Duration::from_secs(1);
3413+
},
3414+
None,
3415+
None,
3416+
);
3417+
let conf = signer_test.running_nodes.conf.clone();
3418+
let http_origin = format!("http://{}", &conf.node.rpc_bind);
3419+
let burn_chain = signer_test
3420+
.running_nodes
3421+
.btc_regtest_controller
3422+
.get_burnchain();
3423+
3424+
signer_test.boot_to_epoch_3();
3425+
info!("------------------------- Reached Epoch 3.0 -------------------------");
3426+
3427+
let burn_block_height = get_chain_info(&conf).burn_block_height;
3428+
let initial_reward_cycle = signer_test.get_current_reward_cycle();
3429+
let rc_last_height = burn_chain.nakamoto_last_block_of_cycle(initial_reward_cycle);
3430+
3431+
info!("----- Mine to the end of reward cycle {initial_reward_cycle} height {rc_last_height} -----");
3432+
let pre_fork_tenures = rc_last_height - burn_block_height;
3433+
for i in 1..=pre_fork_tenures {
3434+
info!("Mining pre-fork tenure {i} of {pre_fork_tenures}");
3435+
signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
3436+
}
3437+
signer_test.check_signer_states_normal();
3438+
3439+
info!("----- Submit Stx transfer in last tenure height {rc_last_height} -----");
3440+
// Make a transfer tx that will get forked
3441+
let tip = get_chain_info(&conf);
3442+
let _ = signer_test
3443+
.submit_transfer_tx(&sender_sk, send_fee, send_amt)
3444+
.unwrap();
3445+
wait_for(30, || {
3446+
let new_tip = get_chain_info(&conf);
3447+
Ok(new_tip.stacks_tip_height > tip.stacks_tip_height)
3448+
})
3449+
.expect("Timed out waiting for transfer tx to be mined");
3450+
3451+
let pre_fork_tx_nonce = get_account(&http_origin, &sender_addr).nonce;
3452+
assert_eq!(1, pre_fork_tx_nonce);
3453+
3454+
info!("----- Mine 1 block in new reward cycle -----");
3455+
signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
3456+
signer_test.check_signer_states_normal();
3457+
3458+
let next_reward_cycle = initial_reward_cycle + 1;
3459+
let new_burn_block_height = get_chain_info(&conf).burn_block_height;
3460+
assert_eq!(next_reward_cycle, signer_test.get_current_reward_cycle());
3461+
assert_eq!(
3462+
new_burn_block_height,
3463+
burn_chain.nakamoto_first_block_of_cycle(next_reward_cycle)
3464+
);
3465+
3466+
info!("----- Trigger Bitcoin fork -----");
3467+
//Fork on the third-to-last tenure of prev reward cycle
3468+
let btc_controller = &signer_test.running_nodes.btc_regtest_controller;
3469+
let burn_block_hash_to_fork = btc_controller.get_block_hash(new_burn_block_height - 2);
3470+
btc_controller.invalidate_block(&burn_block_hash_to_fork);
3471+
btc_controller.build_next_block(3);
3472+
3473+
// note, we should still have normal signer states!
3474+
signer_test.check_signer_states_normal();
3475+
3476+
//mine throught the fork (just check commits because of naka block mining stalled)
3477+
TEST_MINE_STALL.set(true);
3478+
let submitted_commits = signer_test
3479+
.running_nodes
3480+
.counters
3481+
.naka_submitted_commits
3482+
.clone();
3483+
3484+
for i in 0..3 {
3485+
let current_burn_height = get_chain_info(&signer_test.running_nodes.conf).burn_block_height;
3486+
info!(
3487+
"Mining block #{i} to be considered a frequent miner";
3488+
"current_burn_height" => current_burn_height,
3489+
);
3490+
let commits_count = submitted_commits.load(Ordering::SeqCst);
3491+
next_block_and(
3492+
&mut signer_test.running_nodes.btc_regtest_controller,
3493+
60,
3494+
|| {
3495+
let commits_submitted = submitted_commits.load(Ordering::SeqCst);
3496+
Ok(commits_submitted > commits_count)
3497+
},
3498+
)
3499+
.unwrap();
3500+
}
3501+
3502+
let post_fork_tx_nonce = get_account(&http_origin, &sender_addr).nonce;
3503+
assert_eq!(0, post_fork_tx_nonce);
3504+
3505+
info!("----- Check Signers Tx Replay state -----");
3506+
let (signer_states, _) = signer_test.get_burn_updated_states();
3507+
for state in signer_states {
3508+
assert!(
3509+
state.get_tx_replay_set().is_none(),
3510+
"Signer state is in tx replay state, when it shouldn't be"
3511+
);
3512+
}
3513+
3514+
signer_test.shutdown();
3515+
}
3516+
33823517
#[test]
33833518
#[ignore]
33843519
fn multiple_miners() {

0 commit comments

Comments
 (0)