Skip to content

Commit f323760

Browse files
committed
feat: construct replay set after fork
1 parent 4a7319f commit f323760

File tree

6 files changed

+119
-16
lines changed

6 files changed

+119
-16
lines changed

stacks-signer/src/client/stacks_client.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,22 @@ impl StacksClient {
452452
})
453453
}
454454

455+
/// Get the sortition info for a given consensus hash
456+
pub fn get_sortition_by_consensus_hash(
457+
&self,
458+
consensus_hash: &ConsensusHash,
459+
) -> Result<SortitionInfo, ClientError> {
460+
let path = self.sortition_by_consensus_hash_path(consensus_hash);
461+
let response = self.stacks_node_client.get(&path).send()?;
462+
if !response.status().is_success() {
463+
return Err(ClientError::RequestFailure(response.status()));
464+
}
465+
let sortition_info = response.json::<Vec<SortitionInfo>>()?;
466+
sortition_info.get(0).cloned().ok_or_else(|| {
467+
ClientError::InvalidResponse("No sortition info found for given consensus hash".into())
468+
})
469+
}
470+
455471
/// Get the current peer info data from the stacks node
456472
pub fn get_peer_info(&self) -> Result<PeerInfo, ClientError> {
457473
debug!("StacksClient: Getting peer info");
@@ -725,6 +741,14 @@ impl StacksClient {
725741
format!("{}{RPC_SORTITION_INFO_PATH}", self.http_origin)
726742
}
727743

744+
fn sortition_by_consensus_hash_path(&self, consensus_hash: &ConsensusHash) -> String {
745+
format!(
746+
"{}{RPC_SORTITION_INFO_PATH}/consensus/{}",
747+
self.http_origin,
748+
consensus_hash.to_hex()
749+
)
750+
}
751+
728752
fn tenure_forking_info_path(&self, start: &ConsensusHash, stop: &ConsensusHash) -> String {
729753
format!(
730754
"{}{RPC_TENURE_FORKING_INFO_PATH}/{}/{}",

stacks-signer/src/tests/signer_state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ fn determine_global_states() {
220220
current_miner: (&current_miner).into(),
221221
active_signer_protocol_version: local_supported_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here...
222222
tx_replay_state: false,
223+
tx_replay_set: None,
223224
};
224225

225226
assert_eq!(
@@ -265,6 +266,7 @@ fn determine_global_states() {
265266
current_miner: (&new_miner).into(),
266267
active_signer_protocol_version: local_supported_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here...
267268
tx_replay_state: false,
269+
tx_replay_set: None,
268270
};
269271

270272
// Let's tip the scales over to a different miner

stacks-signer/src/v0/signer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ impl SignerTrait<SignerMessage> for Signer {
369369
});
370370
self.local_state_machine
371371
.bitcoin_block_arrival(&self.signer_db, stacks_client, &self.proposal_config, Some(NewBurnBlock {
372-
height: *burn_height,
372+
burn_block_height: *burn_height,
373373
consensus_hash: *consensus_hash,
374374
}))
375375
.unwrap_or_else(|e| error!("{self}: failed to update local state machine for latest bitcoin block arrival"; "err" => ?e));

stacks-signer/src/v0/signer_state.rs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::time::{Duration, UNIX_EPOCH};
1818

1919
use blockstack_lib::chainstate::burn::ConsensusHashExtensions;
2020
use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader};
21+
use blockstack_lib::chainstate::stacks::{StacksTransaction, TransactionPayload};
2122
use clarity::types::chainstate::StacksAddress;
2223
use libsigner::v0::messages::{
2324
MessageSlotID, SignerMessage, StateMachineUpdate as StateMachineUpdateMessage,
@@ -151,6 +152,7 @@ impl GlobalStateEvaluator {
151152
current_miner: current_miner.into(),
152153
active_signer_protocol_version,
153154
tx_replay_state: false,
155+
tx_replay_set: None,
154156
};
155157
let entry = state_views
156158
.entry(state_machine.clone())
@@ -250,6 +252,8 @@ pub struct SignerStateMachine {
250252
/// Whether or not we're in a tx replay state
251253
/// TODO: just a placeholder for now
252254
pub tx_replay_state: bool,
255+
/// Transaction replay set
256+
pub tx_replay_set: Option<Vec<StacksTransaction>>,
253257
}
254258

255259
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash)]
@@ -324,7 +328,7 @@ pub enum StateMachineUpdate {
324328
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325329
pub struct NewBurnBlock {
326330
/// The height of the new burn block
327-
pub height: u64,
331+
pub burn_block_height: u64,
328332
/// The hash of the new burn block
329333
pub consensus_hash: ConsensusHash,
330334
}
@@ -389,6 +393,7 @@ impl LocalStateMachine {
389393
current_miner: MinerState::NoValidMiner,
390394
active_signer_protocol_version: SUPPORTED_SIGNER_PROTOCOL_VERSION,
391395
tx_replay_state: false,
396+
tx_replay_set: None,
392397
}
393398
}
394399

@@ -687,10 +692,10 @@ impl LocalStateMachine {
687692
// TODO: test only, remove
688693
match expected_burn_block.clone() {
689694
Some(expected_burn_block) => {
690-
if expected_burn_block.height > 230 {
695+
if expected_burn_block.burn_block_height > 230 {
691696
info!(
692697
"---- bitcoin_block_arrival {} {} ----",
693-
expected_burn_block.height, expected_burn_block.consensus_hash
698+
expected_burn_block.burn_block_height, expected_burn_block.consensus_hash
694699
);
695700
}
696701
}
@@ -712,7 +717,8 @@ impl LocalStateMachine {
712717
match expected_burn_block {
713718
None => expected_burn_block = Some(pending_burn_block),
714719
Some(ref expected) => {
715-
if pending_burn_block.height > expected.height {
720+
if pending_burn_block.burn_block_height > expected.burn_block_height
721+
{
716722
expected_burn_block = Some(pending_burn_block);
717723
}
718724
}
@@ -728,36 +734,80 @@ impl LocalStateMachine {
728734
let next_burn_block_height = peer_info.burn_block_height;
729735
let next_burn_block_hash = peer_info.pox_consensus;
730736
let mut fork_detected = prior_state_machine.tx_replay_state;
737+
let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
731738

732739
if let Some(expected_burn_block) = expected_burn_block {
733740
// If the next height is less than the expected height, we need to wait.
734741
// OR if the next height is the same, but with a different hash, we need to wait.
735-
if next_burn_block_height < expected_burn_block.height || {
736-
next_burn_block_height == expected_burn_block.height
742+
if next_burn_block_height < expected_burn_block.burn_block_height || {
743+
next_burn_block_height == expected_burn_block.burn_block_height
737744
&& next_burn_block_hash != expected_burn_block.consensus_hash
738745
} {
739746
let err_msg = format!(
740747
"Node has not processed the next burn block ({}) yet",
741-
expected_burn_block.height
748+
expected_burn_block.burn_block_height
742749
);
743750
*self = Self::Pending {
744751
update: StateMachineUpdate::BurnBlock(expected_burn_block),
745752
prior: prior_state_machine,
746753
};
747754
return Err(ClientError::InvalidResponse(err_msg).into());
748755
}
749-
if expected_burn_block.height <= prior_state_machine.burn_block_height
756+
if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
750757
&& expected_burn_block.consensus_hash != prior_state_machine.burn_block
758+
// TODO: handle fork while still in replay
759+
&& tx_replay_set.is_none()
751760
{
752761
fork_detected = true;
753762
info!("---- Signer State: Possible fork! ----";
754-
"expected_burn_block.height" => expected_burn_block.height,
763+
"expected_burn_block.height" => expected_burn_block.burn_block_height,
755764
"expected_burn_block.hash" => %expected_burn_block.consensus_hash,
756765
"next_burn_block_height" => next_burn_block_height,
757766
"next_burn_block_hash" => %next_burn_block_hash,
758767
"prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
759768
"prior_state_machine.burn_block" => %prior_state_machine.burn_block,
760769
);
770+
// Determine the tenures that were forked
771+
let mut sortition_info =
772+
client.get_sortition_by_consensus_hash(&prior_state_machine.burn_block)?;
773+
let last_forked_tenure = prior_state_machine.burn_block;
774+
let mut first_forked_tenure = prior_state_machine.burn_block;
775+
let mut forked_tenures = vec![(
776+
prior_state_machine.burn_block,
777+
prior_state_machine.burn_block_height,
778+
)];
779+
while sortition_info.burn_block_height > expected_burn_block.burn_block_height {
780+
let Some(stacks_parent_ch) = sortition_info.stacks_parent_ch else {
781+
info!("No stacks parent ch found for sortition info";
782+
"sortition_info" => ?sortition_info,
783+
);
784+
break;
785+
};
786+
sortition_info = client.get_sortition_by_consensus_hash(&stacks_parent_ch)?;
787+
first_forked_tenure = sortition_info.consensus_hash;
788+
forked_tenures.push((stacks_parent_ch, sortition_info.burn_block_height));
789+
}
790+
let fork_info =
791+
client.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)?;
792+
let forked_txs = fork_info
793+
.iter()
794+
.flat_map(|fork_info| {
795+
fork_info
796+
.nakamoto_blocks
797+
.iter()
798+
.flat_map(|blocks| blocks.iter())
799+
.flat_map(|block| block.txs.iter())
800+
})
801+
.cloned()
802+
.filter(|tx| match tx.payload {
803+
// Don't include Coinbase, TenureChange, or PoisonMicroblock transactions
804+
TransactionPayload::TenureChange(..)
805+
| TransactionPayload::Coinbase(..)
806+
| TransactionPayload::PoisonMicroblock(..) => false,
807+
_ => true,
808+
})
809+
.collect::<Vec<_>>();
810+
tx_replay_set = Some(forked_txs);
761811
}
762812
}
763813

@@ -802,6 +852,7 @@ impl LocalStateMachine {
802852
current_miner: miner_state,
803853
active_signer_protocol_version: prior_state_machine.active_signer_protocol_version,
804854
tx_replay_state: fork_detected,
855+
tx_replay_set,
805856
});
806857

807858
if prior_state != *self {
@@ -847,6 +898,7 @@ impl LocalStateMachine {
847898
current_miner: current_miner.into(),
848899
active_signer_protocol_version,
849900
tx_replay_state: false,
901+
tx_replay_set: None,
850902
});
851903
// Because we updated our active signer protocol version, update local_update so its included in the subsequent evaluations
852904
let update: Result<StateMachineUpdateMessage, _> = (&*self).try_into();
@@ -880,6 +932,7 @@ impl LocalStateMachine {
880932
current_miner: (&new_miner).into(),
881933
active_signer_protocol_version,
882934
tx_replay_state: false,
935+
tx_replay_set: None,
883936
});
884937
}
885938
}

stackslib/src/chainstate/stacks/transaction.rs

Lines changed: 9 additions & 0 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::hash::{Hash, Hasher};
1718
use std::io;
1819
use std::io::prelude::*;
1920
use std::io::{Read, Write};
@@ -683,6 +684,14 @@ impl StacksTransaction {
683684
}
684685
}
685686

687+
impl Hash for StacksTransaction {
688+
fn hash<H: Hasher>(&self, state: &mut H) {
689+
self.txid().hash(state);
690+
}
691+
}
692+
693+
impl Eq for StacksTransaction {}
694+
686695
impl StacksMessageCodec for StacksTransaction {
687696
fn consensus_serialize<W: Write>(&self, fd: &mut W) -> Result<(), codec_error> {
688697
write_next(fd, &(self.version as u8))?;

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2666,6 +2666,15 @@ fn bitcoind_forking_test() {
26662666
#[ignore]
26672667
/// Trigger a Bitcoin fork and ensure that the signer
26682668
/// both detects the fork and moves into a tx replay state
2669+
///
2670+
/// The test flow is:
2671+
///
2672+
/// - Mine 10 tenures after epoch 3
2673+
/// - Include a STX transfer in the 10th tenure
2674+
/// - Trigger a Bitcoin fork (3 blocks)
2675+
/// - Verify that the signer moves into tx replay state
2676+
/// - Verify that the signer correctly includes the stx transfer
2677+
/// in the tx replay set
26692678
fn tx_replay_forking_test() {
26702679
if env::var("BITCOIND_TEST") != Ok("1".into()) {
26712680
return;
@@ -2731,7 +2740,7 @@ fn tx_replay_forking_test() {
27312740

27322741
let burn_blocks = test_observer::get_burn_blocks();
27332742
let forked_blocks = burn_blocks.iter().rev().take(2).collect::<Vec<_>>();
2734-
let start_tenure: ConsensusHash = hex_bytes(
2743+
let last_forked_tenure: ConsensusHash = hex_bytes(
27352744
&forked_blocks[0]
27362745
.get("consensus_hash")
27372746
.unwrap()
@@ -2741,7 +2750,7 @@ fn tx_replay_forking_test() {
27412750
.unwrap()
27422751
.as_slice()
27432752
.into();
2744-
let end_tenure: ConsensusHash = hex_bytes(
2753+
let first_forked_tenure: ConsensusHash = hex_bytes(
27452754
&forked_blocks[1]
27462755
.get("consensus_hash")
27472756
.unwrap()
@@ -2754,7 +2763,7 @@ fn tx_replay_forking_test() {
27542763

27552764
let tip = get_chain_info(&signer_test.running_nodes.conf);
27562765
// Make a transfer tx (this will get forked)
2757-
signer_test
2766+
let (txid, _) = signer_test
27582767
.submit_transfer_tx(&sender_sk, send_fee, send_amt)
27592768
.unwrap();
27602769

@@ -2797,8 +2806,7 @@ fn tx_replay_forking_test() {
27972806

27982807
let fork_info = signer_test
27992808
.stacks_client
2800-
// .get_tenure_forking_info(&start_tenure, &end_tenure)
2801-
.get_tenure_forking_info(&end_tenure, &start_tenure)
2809+
.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)
28022810
.unwrap();
28032811

28042812
info!("---- Fork info: {fork_info:?} ----");
@@ -2846,7 +2854,6 @@ fn tx_replay_forking_test() {
28462854
},
28472855
)
28482856
.unwrap();
2849-
// signer_test.check_signer_states_normal_missed_sortition();
28502857
}
28512858

28522859
let post_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce;
@@ -2867,6 +2874,14 @@ fn tx_replay_forking_test() {
28672874
match state {
28682875
LocalStateMachine::Initialized(signer_state_machine) => {
28692876
assert!(signer_state_machine.tx_replay_state);
2877+
let Some(tx_replay_set) = signer_state_machine.tx_replay_set else {
2878+
panic!(
2879+
"Signer state machine is in tx replay state, but tx replay set is not set"
2880+
);
2881+
};
2882+
info!("---- Tx replay set: {:?} ----", tx_replay_set);
2883+
assert_eq!(tx_replay_set.len(), 1);
2884+
assert_eq!(tx_replay_set[0].txid().to_hex(), txid);
28702885
}
28712886
_ => {
28722887
panic!("Signer state is not in the initialized state");

0 commit comments

Comments
 (0)