Skip to content

Commit 9dd349d

Browse files
authored
Merge pull request #6110 from fdefelici/feat/tx-replay-ignore-forks-across-reward-cycle
implement tx replay rejection on fork across reward cycle
2 parents eda674c + 1d478bf commit 9dd349d

File tree

3 files changed

+319
-0
lines changed

3 files changed

+319
-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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,15 @@ impl Burnchain {
578578
.nakamoto_first_block_of_cycle(self.first_block_height, reward_cycle)
579579
}
580580

581+
#[cfg(any(test, feature = "testing"))]
582+
/// the last burn block that must be *signed* by the signer set of `reward_cycle`.
583+
/// this is the modulo -1 block
584+
pub fn nakamoto_last_block_of_cycle(&self, reward_cycle: u64) -> u64 {
585+
self.nakamoto_first_block_of_cycle(reward_cycle)
586+
+ self.pox_constants.reward_cycle_length as u64
587+
- 1
588+
}
589+
581590
/// What is the reward cycle for this block height?
582591
/// This considers the modulo 0 block to be in reward cycle `n`, even though
583592
/// rewards for cycle `n` do not begin until modulo 1.
@@ -1765,3 +1774,173 @@ impl Burnchain {
17651774
Ok(block_header)
17661775
}
17671776
}
1777+
1778+
#[cfg(test)]
1779+
mod tests {
1780+
use regex::Regex;
1781+
1782+
use super::*;
1783+
use crate::burnchains::*;
1784+
1785+
#[test]
1786+
fn test_creation_by_new_for_bitcoin_mainnet() {
1787+
let burn_chain = Burnchain::new("workdir/path", "bitcoin", "mainnet");
1788+
assert!(burn_chain.is_ok());
1789+
1790+
let burn_chain = burn_chain.unwrap();
1791+
let first_block_hash =
1792+
BurnchainHeaderHash::from_hex(BITCOIN_MAINNET_FIRST_BLOCK_HASH).unwrap();
1793+
assert_eq!(PEER_VERSION_MAINNET, burn_chain.peer_version);
1794+
assert_eq!(BITCOIN_NETWORK_ID_MAINNET, burn_chain.network_id);
1795+
assert_eq!(BITCOIN_MAINNET_NAME, burn_chain.network_name);
1796+
assert_eq!("workdir/path", burn_chain.working_dir);
1797+
assert_eq!(24, burn_chain.consensus_hash_lifetime);
1798+
assert_eq!(7, burn_chain.stable_confirmations);
1799+
assert_eq!(
1800+
BITCOIN_MAINNET_FIRST_BLOCK_HEIGHT,
1801+
burn_chain.first_block_height
1802+
);
1803+
assert_eq!(first_block_hash, burn_chain.first_block_hash);
1804+
assert_eq!(
1805+
BITCOIN_MAINNET_FIRST_BLOCK_TIMESTAMP,
1806+
burn_chain.first_block_timestamp
1807+
);
1808+
assert_eq!(PoxConstants::mainnet_default(), burn_chain.pox_constants);
1809+
assert_eq!(
1810+
BITCOIN_MAINNET_INITIAL_REWARD_START_BLOCK,
1811+
burn_chain.initial_reward_start_block
1812+
);
1813+
}
1814+
1815+
#[test]
1816+
fn test_creation_by_new_for_bitcoin_testnet() {
1817+
let burn_chain = Burnchain::new("workdir/path", "bitcoin", "testnet");
1818+
assert!(burn_chain.is_ok());
1819+
1820+
let burn_chain = burn_chain.unwrap();
1821+
let first_block_hash =
1822+
BurnchainHeaderHash::from_hex(BITCOIN_TESTNET_FIRST_BLOCK_HASH).unwrap();
1823+
assert_eq!(PEER_VERSION_TESTNET, burn_chain.peer_version);
1824+
assert_eq!(BITCOIN_NETWORK_ID_TESTNET, burn_chain.network_id);
1825+
assert_eq!(BITCOIN_TESTNET_NAME, burn_chain.network_name);
1826+
assert_eq!("workdir/path", burn_chain.working_dir);
1827+
assert_eq!(24, burn_chain.consensus_hash_lifetime);
1828+
assert_eq!(7, burn_chain.stable_confirmations);
1829+
assert_eq!(
1830+
BITCOIN_TESTNET_FIRST_BLOCK_HEIGHT,
1831+
burn_chain.first_block_height
1832+
);
1833+
assert_eq!(first_block_hash, burn_chain.first_block_hash);
1834+
assert_eq!(
1835+
BITCOIN_TESTNET_FIRST_BLOCK_TIMESTAMP,
1836+
burn_chain.first_block_timestamp
1837+
);
1838+
assert_eq!(PoxConstants::testnet_default(), burn_chain.pox_constants);
1839+
assert_eq!(1_990_000, burn_chain.initial_reward_start_block);
1840+
}
1841+
1842+
#[test]
1843+
fn test_creation_by_new_for_bitcoin_regtest() {
1844+
let burn_chain = Burnchain::new("workdir/path", "bitcoin", "regtest");
1845+
assert!(burn_chain.is_ok());
1846+
1847+
let burn_chain = burn_chain.unwrap();
1848+
let first_block_hash =
1849+
BurnchainHeaderHash::from_hex(BITCOIN_REGTEST_FIRST_BLOCK_HASH).unwrap();
1850+
assert_eq!(PEER_VERSION_TESTNET, burn_chain.peer_version);
1851+
assert_eq!(BITCOIN_NETWORK_ID_REGTEST, burn_chain.network_id);
1852+
assert_eq!(BITCOIN_REGTEST_NAME, burn_chain.network_name);
1853+
assert_eq!("workdir/path", burn_chain.working_dir);
1854+
assert_eq!(24, burn_chain.consensus_hash_lifetime);
1855+
assert_eq!(1, burn_chain.stable_confirmations);
1856+
assert_eq!(
1857+
BITCOIN_REGTEST_FIRST_BLOCK_HEIGHT,
1858+
burn_chain.first_block_height
1859+
);
1860+
assert_eq!(first_block_hash, burn_chain.first_block_hash);
1861+
assert_eq!(
1862+
BITCOIN_REGTEST_FIRST_BLOCK_TIMESTAMP,
1863+
burn_chain.first_block_timestamp
1864+
);
1865+
assert_eq!(PoxConstants::regtest_default(), burn_chain.pox_constants);
1866+
assert_eq!(
1867+
BITCOIN_REGTEST_FIRST_BLOCK_HEIGHT,
1868+
burn_chain.initial_reward_start_block
1869+
);
1870+
}
1871+
1872+
#[test]
1873+
fn test_creation_by_new_failure() {
1874+
//case: wrong chain name
1875+
let burn_chain = Burnchain::new("workdir/path", "wrong_chain_name", "regtest");
1876+
assert!(burn_chain.is_err());
1877+
assert!(matches!(
1878+
burn_chain.unwrap_err(),
1879+
burnchain_error::UnsupportedBurnchain
1880+
));
1881+
1882+
//case: wrong network name
1883+
let burn_chain = Burnchain::new("workdir/path", "bitcoin", "wrong_net_name");
1884+
assert!(burn_chain.is_err());
1885+
assert!(matches!(
1886+
burn_chain.unwrap_err(),
1887+
burnchain_error::UnsupportedBurnchain
1888+
));
1889+
1890+
//case: wrong chain name + wrong network name
1891+
let burn_chain = Burnchain::new("workdir/path", "wrong_chain_name", "wrong_net_name");
1892+
assert!(burn_chain.is_err());
1893+
assert!(matches!(
1894+
burn_chain.unwrap_err(),
1895+
burnchain_error::UnsupportedBurnchain
1896+
));
1897+
}
1898+
1899+
#[test]
1900+
fn test_creation_by_default_unittest() {
1901+
let first_block_height = 0;
1902+
let first_block_hash = BurnchainHeaderHash([0u8; 32]);
1903+
let burn_chain = Burnchain::default_unittest(first_block_height, &first_block_hash);
1904+
1905+
let workdir_re = Regex::new(r"^/tmp/stacks-node-tests/unit-tests-[0-9a-f]{32}$").unwrap();
1906+
1907+
assert_eq!(PEER_VERSION_MAINNET, burn_chain.peer_version);
1908+
assert_eq!(BITCOIN_NETWORK_ID_MAINNET, burn_chain.network_id);
1909+
assert_eq!(BITCOIN_MAINNET_NAME, burn_chain.network_name);
1910+
assert!(workdir_re.is_match(&burn_chain.working_dir));
1911+
assert_eq!(24, burn_chain.consensus_hash_lifetime);
1912+
assert_eq!(7, burn_chain.stable_confirmations);
1913+
assert_eq!(first_block_height, burn_chain.first_block_height);
1914+
assert_eq!(first_block_hash, burn_chain.first_block_hash);
1915+
assert_eq!(
1916+
BITCOIN_MAINNET_FIRST_BLOCK_TIMESTAMP,
1917+
burn_chain.first_block_timestamp
1918+
);
1919+
assert_eq!(PoxConstants::mainnet_default(), burn_chain.pox_constants);
1920+
assert_eq!(first_block_height, burn_chain.initial_reward_start_block);
1921+
}
1922+
1923+
#[test]
1924+
fn test_nakamoto_reward_cycle_boundaries() {
1925+
let first_block_height = 0;
1926+
let first_block_hash = BurnchainHeaderHash([0u8; 32]);
1927+
let burn_chain = Burnchain::default_unittest(first_block_height, &first_block_hash);
1928+
1929+
//making obvious the reward cycle length used
1930+
assert_eq!(2100, burn_chain.pox_constants.reward_cycle_length);
1931+
1932+
//Reward Cycle: 0
1933+
let rc = 0;
1934+
let rc_first_block = burn_chain.nakamoto_first_block_of_cycle(rc);
1935+
let rc_last_block = burn_chain.nakamoto_last_block_of_cycle(rc);
1936+
assert_eq!(0, rc_first_block);
1937+
assert_eq!(2099, rc_last_block);
1938+
1939+
//Reward Cycle: 1
1940+
let rc = 1;
1941+
let rc_first_block = burn_chain.nakamoto_first_block_of_cycle(rc);
1942+
let rc_last_block = burn_chain.nakamoto_last_block_of_cycle(rc);
1943+
assert_eq!(2100, rc_first_block);
1944+
assert_eq!(4199, rc_last_block);
1945+
}
1946+
}

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3388,6 +3388,131 @@ fn tx_replay_forking_test() {
33883388
signer_test.shutdown();
33893389
}
33903390

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

0 commit comments

Comments
 (0)