Skip to content

Commit 54ae125

Browse files
authored
Merge pull request #4738 from stacks-network/fix/signer-cycle-transition
fix: handle a race condition between the signer and the /v2/pox endpoint
2 parents a9c14f9 + 430d987 commit 54ae125

File tree

2 files changed

+103
-5
lines changed

2 files changed

+103
-5
lines changed

stacks-signer/src/client/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ pub enum ClientError {
8383
/// Stacks node does not support a feature we need
8484
#[error("Stacks node does not support a required feature: {0}")]
8585
UnsupportedStacksFeature(String),
86+
/// Invalid response from the stacks node
87+
#[error("Invalid response from the stacks node: {0}")]
88+
InvalidResponse(String),
8689
}
8790

8891
/// Retry a function F with an exponential backoff and notification on transient failure

stacks-signer/src/runloop.rs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ impl RewardCycleInfo {
7474
self.reward_cycle == reward_cycle
7575
}
7676

77+
/// Get the reward cycle for a specific burnchain block height
78+
pub const fn get_reward_cycle(&self, burnchain_block_height: u64) -> u64 {
79+
let blocks_mined = burnchain_block_height.saturating_sub(self.first_burnchain_block_height);
80+
blocks_mined / self.reward_cycle_length
81+
}
82+
7783
/// Check if the provided burnchain block height is in the prepare phase
7884
pub fn is_in_prepare_phase(&self, burnchain_block_height: u64) -> bool {
7985
PoxConstants::static_is_in_prepare_phase(
@@ -83,6 +89,15 @@ impl RewardCycleInfo {
8389
burnchain_block_height,
8490
)
8591
}
92+
93+
/// Check if the provided burnchain block height is in the prepare phase of the next cycle
94+
pub fn is_in_next_prepare_phase(&self, burnchain_block_height: u64) -> bool {
95+
let effective_height = burnchain_block_height - self.first_burnchain_block_height;
96+
let reward_index = effective_height % self.reward_cycle_length;
97+
98+
reward_index >= u64::from(self.reward_cycle_length - self.prepare_phase_block_length)
99+
&& self.get_reward_cycle(burnchain_block_height) == self.reward_cycle
100+
}
86101
}
87102

88103
/// The runloop for the stacks signer
@@ -253,7 +268,8 @@ impl RunLoop {
253268
let current_reward_cycle = reward_cycle_info.reward_cycle;
254269
self.refresh_signer_config(current_reward_cycle);
255270
// We should only attempt to initialize the next reward cycle signer if we are in the prepare phase of the next reward cycle
256-
if reward_cycle_info.is_in_prepare_phase(reward_cycle_info.last_burnchain_block_height) {
271+
if reward_cycle_info.is_in_next_prepare_phase(reward_cycle_info.last_burnchain_block_height)
272+
{
257273
self.refresh_signer_config(current_reward_cycle.saturating_add(1));
258274
}
259275
self.current_reward_cycle_info = Some(reward_cycle_info);
@@ -270,18 +286,34 @@ impl RunLoop {
270286
.current_reward_cycle_info
271287
.as_mut()
272288
.expect("FATAL: cannot be an initialized signer with no reward cycle info.");
289+
let current_reward_cycle = reward_cycle_info.reward_cycle;
290+
let block_reward_cycle = reward_cycle_info.get_reward_cycle(current_burn_block_height);
291+
273292
// First ensure we refresh our view of the current reward cycle information
274-
if !reward_cycle_info.is_in_reward_cycle(current_burn_block_height) {
293+
if block_reward_cycle != current_reward_cycle {
275294
let new_reward_cycle_info = retry_with_exponential_backoff(|| {
276-
self.stacks_client
295+
let info = self
296+
.stacks_client
277297
.get_current_reward_cycle_info()
278-
.map_err(backoff::Error::transient)
298+
.map_err(backoff::Error::transient)?;
299+
if info.reward_cycle < block_reward_cycle {
300+
// If the stacks-node is still processing the burn block, the /v2/pox endpoint
301+
// may return the previous reward cycle. In this case, we should retry.
302+
return Err(backoff::Error::transient(ClientError::InvalidResponse(
303+
format!("Received reward cycle ({}) does not match the expected reward cycle ({}) for block {}.",
304+
info.reward_cycle,
305+
block_reward_cycle,
306+
current_burn_block_height
307+
),
308+
)));
309+
}
310+
Ok(info)
279311
})?;
280312
*reward_cycle_info = new_reward_cycle_info;
281313
}
282314
let current_reward_cycle = reward_cycle_info.reward_cycle;
283315
// We should only attempt to refresh the signer if we are not configured for the next reward cycle yet and we received a new burn block for its prepare phase
284-
if reward_cycle_info.is_in_prepare_phase(current_burn_block_height) {
316+
if reward_cycle_info.is_in_next_prepare_phase(current_burn_block_height) {
285317
let next_reward_cycle = current_reward_cycle.saturating_add(1);
286318
if self
287319
.stacks_signers
@@ -533,4 +565,67 @@ mod tests {
533565
.wrapping_add(1)
534566
));
535567
}
568+
569+
#[test]
570+
fn is_in_next_prepare_phase() {
571+
let reward_cycle_info = RewardCycleInfo {
572+
reward_cycle: 5,
573+
reward_cycle_length: 10,
574+
prepare_phase_block_length: 5,
575+
first_burnchain_block_height: 0,
576+
last_burnchain_block_height: 50,
577+
};
578+
579+
assert!(!reward_cycle_info.is_in_next_prepare_phase(49));
580+
assert!(!reward_cycle_info.is_in_next_prepare_phase(50));
581+
assert!(!reward_cycle_info.is_in_next_prepare_phase(51));
582+
assert!(!reward_cycle_info.is_in_next_prepare_phase(52));
583+
assert!(!reward_cycle_info.is_in_next_prepare_phase(53));
584+
assert!(!reward_cycle_info.is_in_next_prepare_phase(54));
585+
assert!(reward_cycle_info.is_in_next_prepare_phase(55));
586+
assert!(reward_cycle_info.is_in_next_prepare_phase(56));
587+
assert!(reward_cycle_info.is_in_next_prepare_phase(57));
588+
assert!(reward_cycle_info.is_in_next_prepare_phase(58));
589+
assert!(reward_cycle_info.is_in_next_prepare_phase(59));
590+
assert!(!reward_cycle_info.is_in_next_prepare_phase(60));
591+
assert!(!reward_cycle_info.is_in_next_prepare_phase(61));
592+
593+
let rand_byte: u8 = std::cmp::max(1, thread_rng().gen());
594+
let prepare_phase_block_length = rand_byte as u64;
595+
// Ensure the reward cycle is not close to u64 Max to prevent overflow when adding prepare phase len
596+
let reward_cycle_length = (std::cmp::max(
597+
prepare_phase_block_length.wrapping_add(1),
598+
thread_rng().next_u32() as u64,
599+
))
600+
.wrapping_add(prepare_phase_block_length);
601+
let reward_cycle_phase_block_length =
602+
reward_cycle_length.wrapping_sub(prepare_phase_block_length);
603+
let first_burnchain_block_height = std::cmp::max(1u8, thread_rng().gen()) as u64;
604+
let last_burnchain_block_height = thread_rng().gen_range(
605+
first_burnchain_block_height
606+
..first_burnchain_block_height
607+
.wrapping_add(reward_cycle_length)
608+
.wrapping_sub(prepare_phase_block_length),
609+
);
610+
let blocks_mined = last_burnchain_block_height.wrapping_sub(first_burnchain_block_height);
611+
let reward_cycle = blocks_mined / reward_cycle_length;
612+
613+
let reward_cycle_info = RewardCycleInfo {
614+
reward_cycle,
615+
reward_cycle_length,
616+
prepare_phase_block_length,
617+
first_burnchain_block_height,
618+
last_burnchain_block_height,
619+
};
620+
621+
for i in 0..reward_cycle_length {
622+
if i < reward_cycle_phase_block_length {
623+
assert!(!reward_cycle_info
624+
.is_in_next_prepare_phase(first_burnchain_block_height.wrapping_add(i)));
625+
} else {
626+
assert!(reward_cycle_info
627+
.is_in_next_prepare_phase(first_burnchain_block_height.wrapping_add(i)));
628+
}
629+
}
630+
}
536631
}

0 commit comments

Comments
 (0)