Skip to content

Commit 559ea2f

Browse files
authored
Merge pull request #7314 from brice-stacks/feat/unstake-during-prepare
fix: reject unstake-sbtc and announce-l1-early-exit during prepare
2 parents 722df5c + 965ff74 commit 559ea2f

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Reject `unstake-sbtc` and `announce-l1-early-exit` calls during a prepare phase.

contrib/core-contract-tests/tests/pox-5/pox-5.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4436,6 +4436,104 @@ test('unstake is rejected when called during the prepare phase', () => {
44364436
expect(result.value).toBe(errorCodes.ERR_UNSTAKE_IN_PREPARE_PHASE);
44374437
});
44384438

4439+
/**
4440+
* Regression for stacks-network/stacks-core#7295. `unstake-sbtc` mutates
4441+
* next-cycle bond / signer shares, and the next-cycle signer set is frozen
4442+
* during the current cycle's prepare phase. The other share-mutating
4443+
* entry-points (`stake`, `stake-update`, `register-for-bond`,
4444+
* `update-bond-registration`) all gate on `verify-not-prepare-phase`;
4445+
* `unstake-sbtc` previously side-stepped it. After the fix it returns
4446+
* `ERR_STAKE_IN_PREPARE_PHASE` mid-prepare and succeeds once the next
4447+
* cycle starts.
4448+
*/
4449+
test('unstake-sbtc is rejected during the prepare phase', () => {
4450+
const signer = testSigner.identifier;
4451+
const aliceSbtc = 100000n;
4452+
const unstakeAmount = 40000n;
4453+
4454+
registerSigner();
4455+
setupBondForAllowlist([{ maxSats: aliceSbtc, staker: alice }]);
4456+
txOk(
4457+
pox5.registerForBond({
4458+
bondIndex: 0n,
4459+
signerManager: signer,
4460+
amountUstx: stxToUStx(50_000),
4461+
btcLockup: err(aliceSbtc),
4462+
signerCalldata: null,
4463+
}),
4464+
alice,
4465+
);
4466+
4467+
// Cycle 0 prepare phase begins at (cycle-1-start - 10). Land mid-prepare.
4468+
mineUntil(rov(pox5.rewardCycleToBurnHeight(1n)) - 9n);
4469+
expect(rov(pox5.isInPreparePhase(rov(pox5.currentPoxRewardCycle())))).toBe(
4470+
true,
4471+
);
4472+
4473+
expect(
4474+
txErr(
4475+
pox5.unstakeSbtc({
4476+
signerManager: signer,
4477+
amountToWithdrawalSats: unstakeAmount,
4478+
}),
4479+
alice,
4480+
).value,
4481+
).toBe(pox5Errors.ERR_STAKE_IN_PREPARE_PHASE);
4482+
4483+
// Crossing into the next cycle clears the prepare phase: the same call
4484+
// now succeeds, confirming the guard was the sole blocker.
4485+
mineUntil(rov(pox5.rewardCycleToBurnHeight(1n)));
4486+
expect(rov(pox5.isInPreparePhase(rov(pox5.currentPoxRewardCycle())))).toBe(
4487+
false,
4488+
);
4489+
txOk(
4490+
pox5.unstakeSbtc({
4491+
signerManager: signer,
4492+
amountToWithdrawalSats: unstakeAmount,
4493+
}),
4494+
alice,
4495+
);
4496+
});
4497+
4498+
/**
4499+
* Regression for stacks-network/stacks-core#7295. `announce-l1-early-exit`
4500+
* also mutates next-cycle bond / signer shares (zeros the staker's
4501+
* `amount-sats`, debits `protocol-bonds-total-staked`, and rewrites the
4502+
* per-cycle bond-share maps via `remove-staker-from-bond-cycles`), so it
4503+
* must reject during the current cycle's prepare phase too. Simnet can't
4504+
* register a real L1-lock bond (fake burn header hashes fail
4505+
* `ERR_INVALID_BTC_HEADER`), so we exercise the guard on an sBTC bond:
4506+
* pre-fix the call falls through to `ERR_CANNOT_ANNOUNCE_L1_EARLY_UNLOCK`
4507+
* at the `is-l1-lock` assertion; post-fix the prepare-phase guard fires
4508+
* first.
4509+
*/
4510+
test('announce-l1-early-exit is rejected during the prepare phase', () => {
4511+
const signer = testSigner.identifier;
4512+
const aliceSbtc = 100000n;
4513+
4514+
registerSigner();
4515+
setupBondForAllowlist([{ maxSats: aliceSbtc, staker: alice }]);
4516+
txOk(
4517+
pox5.registerForBond({
4518+
bondIndex: 0n,
4519+
signerManager: signer,
4520+
amountUstx: stxToUStx(50_000),
4521+
btcLockup: err(aliceSbtc),
4522+
signerCalldata: null,
4523+
}),
4524+
alice,
4525+
);
4526+
4527+
mineUntil(rov(pox5.rewardCycleToBurnHeight(1n)) - 9n);
4528+
expect(rov(pox5.isInPreparePhase(rov(pox5.currentPoxRewardCycle())))).toBe(
4529+
true,
4530+
);
4531+
4532+
expect(txErr(pox5.announceL1EarlyExit(alice, signer), alice).value).toBe(
4533+
pox5Errors.ERR_STAKE_IN_PREPARE_PHASE,
4534+
);
4535+
});
4536+
44394537
/**
44404538
* After the bond period ends, an sBTC bond participant should still be able to
44414539
* retrieve their locked sBTC.

stackslib/src/chainstate/stacks/boot/pox-5.clar

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@
675675
(stx-balance (stx-account tx-sender))
676676
(total-balance (+ (get locked stx-balance) (get unlocked stx-balance)))
677677
)
678+
;; Reject during the prepare phase since next-cycle data is mutated
678679
(try! (verify-not-prepare-phase))
679680
;; Verify that they're sending enough STX
680681
(asserts!
@@ -836,6 +837,7 @@
836837
(amount-sats (get amount-sats current-membership))
837838
(num-cycles (- bond-end-cycle first-reward-cycle))
838839
)
840+
;; Reject during the prepare phase since next-cycle data is mutated
839841
(try! (verify-not-prepare-phase))
840842

841843
;; Check that the old signer is the current signer
@@ -967,6 +969,7 @@
967969
(stx-balance (stx-account tx-sender))
968970
(total-balance (+ (get locked stx-balance) (get unlocked stx-balance)))
969971
)
972+
;; Reject during the prepare phase since next-cycle data is mutated
970973
(try! (verify-not-prepare-phase))
971974

972975
;; Validate that the staker can join this signer
@@ -1081,6 +1084,7 @@
10811084
(first-reward-cycle (+ current-cycle u1))
10821085
(num-cycles (- unlock-cycle current-cycle u1))
10831086
)
1087+
;; Reject during the prepare phase since next-cycle data is mutated
10841088
(try! (verify-not-prepare-phase))
10851089

10861090
;; Validate that the staker can join this signer
@@ -1182,6 +1186,9 @@
11821186
(first-changed-reward-cycle (clamp current-cycle bond-start-cycle bond-end-cycle))
11831187
(amount-sats (get amount-sats membership))
11841188
)
1189+
;; Reject during the prepare phase since next-cycle data is mutated
1190+
(try! (verify-not-prepare-phase))
1191+
11851192
;; ensure no reentrancy through signer-manager trait calls
11861193
(try! (validate-no-reentrancy))
11871194

@@ -1256,6 +1263,9 @@
12561263
ERR_INVALID_UNSTAKE_SBTC_AMOUNT
12571264
)))
12581265
)
1266+
;; Reject during the prepare phase since next-cycle data is mutated
1267+
(try! (verify-not-prepare-phase))
1268+
12591269
;; `signer-manager` must match the current signer
12601270
(asserts! (is-eq (contract-of signer-manager) signer)
12611271
ERR_INVALID_OLD_SIGNER_MANAGER

0 commit comments

Comments
 (0)