FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: low

Sequencer Downtime Inflates Risk Window End Timestamp, Biasing k=2 Bonus Distribution Toward Late Stakers

Author Revealed upon completion

Root + Impact

Description

The protocol uses a lazy-observation model to seal the risk window endpoint T (riskWindowEnd). When the on-chain registry transitions to a terminal state (e.g. PRODUCTION), T is not recorded automatically — it is only written the first time someone calls pokeRiskWindow() or any function that triggers _observePoolState(). The protocol's documented defense against deliberate manipulation of T is that the poke is permissionless, so any counterparty with the opposite incentive can immediately counter-poke and collapse the bias to near zero.

The specific issue is that this defense is structurally unavailable during L2 sequencer downtime. When the sequencer is offline, no transaction can land — including pokeRiskWindow(). If the registry transitions to a terminal state while the sequencer is down, T is not sealed at the true transition time. When the sequencer resumes, the first poke seals T at block.timestamp, which is inflated by the full duration of the outage. Because the k=2 bonus formula scores each staker as (T − entryTime)² × stake, inflating T compresses the ratio between early and late stakers' scores, measurably shifting bonus share toward late stakers.

// _markRiskWindowEnd() seals T at block.timestamp of first observation, not true transition time
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry; // @> cap only protects post-expiry case
riskWindowEnd = uint32(t); // @> inflated by downtime duration if sequencer was offline
emit RiskWindowEnded(t);
}
// _bonusShare uses T directly in the quadratic scoring formula
uint256 T = outcomeFlaggedAt; // @> outcomeFlaggedAt = riskWindowEnd (inflated)
uint256 userScore = T * T * userEligible + userSumStakeTimeSq[u] - 2 * T * userSumStakeTime[u];
// @> late stakers' (T - lateEntry)^2 grows faster than early stakers' when T is inflated

Risk

Likelihood:

  • BattleChain is an EVM-compatible L2. All L2 sequencers have documented downtime history; this is an infrastructure reality, not a theoretical edge case.

  • The window of exposure is narrow: downtime must coincide with the exact period between the registry transitioning to a terminal state and the first successful pokeRiskWindow() call. However, this window can be hours long if the outage is sustained.

Impact:

  • Late stakers receive a disproportionately larger share of the bonus pool than the k=2 formula intends. In the PoC, a 1-day outage causes Bob's bonus to nearly triple (20 ONE → 58.8 ONE) while Alice's drops from 980 ONE to 941 ONE, on a 1,000 ONE bonus pool.

  • No principal is at risk. The effect is purely a redistribution of the bonus pool among stakers. No third party loses funds.


pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract SequencerDowntimeBonusBiasPoc is BaseConfidencePoolTest {
function _enterRisk() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
}
function _flagSurvived() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claim(address user) internal returns (uint256 payout) {
uint256 before = token.balanceOf(user);
vm.prank(user);
pool.claimSurvived();
payout = token.balanceOf(user) - before;
}
/// @notice Demonstrates that sequencer downtime inflates T and shifts bonus share
/// from early stakers (Alice) to late stakers (Bob).
///
/// Key fix vs previous attempt: contributeBonus must be called while registry is
/// still UNDER_ATTACK. Once PRODUCTION is set, contributeBonus reverts StakingClosed.
function test_SequencerDowntime_InflatesT_BenefitsLateStaker() external {
uint256 STAKE = 100 * ONE;
uint256 BONUS = 1_000 * ONE;
// ── Scenario A: no downtime (baseline) ───────────────────────────────────────────────
_enterRisk();
uint256 riskStart = pool.riskWindowStart();
// Alice stakes at risk window open (early staker).
_stake(alice, STAKE);
// 6 days pass. Bob stakes 1 day before the registry transitions (late staker).
vm.warp(riskStart + 6 days);
_stake(bob, STAKE);
// Contribute bonus while still UNDER_ATTACK (before PRODUCTION closes it).
_contributeBonus(carol, BONUS);
// 1 more day: registry transitions to PRODUCTION.
vm.warp(riskStart + 7 days);
// Poke fires immediately — no downtime. T sealed at true transition time.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 T_noDowntime = pool.riskWindowEnd();
_flagSurvived();
uint256 aliceBonus_A = _claim(alice) - STAKE;
uint256 bobBonus_A = _claim(bob) - STAKE;
// ── Scenario B: 1-day sequencer downtime ─────────────────────────────────────────────
setUp();
_enterRisk();
riskStart = pool.riskWindowStart();
_stake(alice, STAKE);
vm.warp(riskStart + 6 days);
_stake(bob, STAKE);
// Contribute bonus while still UNDER_ATTACK.
_contributeBonus(carol, BONUS);
// Registry transitions to PRODUCTION at the same true time.
vm.warp(riskStart + 7 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// ── SEQUENCER DOWN: 1 day elapses, nobody can poke ───────────────────────────────────
vm.warp(riskStart + 8 days);
// ── SEQUENCER RESUMES ────────────────────────────────────────────────────────────────
// First poke on resumption. T is now inflated by 1 day.
pool.pokeRiskWindow();
uint256 T_withDowntime = pool.riskWindowEnd();
_flagSurvived();
uint256 aliceBonus_B = _claim(alice) - STAKE;
uint256 bobBonus_B = _claim(bob) - STAKE;
// ── Assertions ───────────────────────────────────────────────────────────────────────
assertEq(T_withDowntime - T_noDowntime, 1 days, "T inflated by 1 day of downtime");
assertGt(bobBonus_B, bobBonus_A, "Bob (late staker) gains more bonus under downtime");
assertLt(aliceBonus_B, aliceBonus_A, "Alice (early staker) loses bonus under downtime");
emit log_named_uint("T no-downtime (s)", T_noDowntime);
emit log_named_uint("T with-downtime (s)", T_withDowntime);
emit log_named_uint("Alice bonus A — no downtime", aliceBonus_A);
emit log_named_uint("Alice bonus B — downtime", aliceBonus_B);
emit log_named_uint("Bob bonus A — no downtime", bobBonus_A);
emit log_named_uint("Bob bonus B — downtime", bobBonus_B);
}
/// @notice Confirms the expiry cap fully mitigates post-expiry downtime.
function test_SequencerDowntime_PostExpiry_MitigatedByExpiryClamp() external {
uint256 STAKE = 100 * ONE;
uint256 BONUS = 100 * ONE;
_enterRisk();
_stake(alice, STAKE);
// Contribute bonus while still UNDER_ATTACK.
_contributeBonus(carol, BONUS);
// Registry transitions to PRODUCTION 5 days before expiry.
vm.warp(pool.expiry() - 5 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Sequencer goes down. Nobody pokes. Time passes past expiry.
vm.warp(pool.expiry() + 30 days);
// First poke after expiry: T must be clamped to expiry, not block.timestamp.
pool.pokeRiskWindow();
assertEq(pool.riskWindowEnd(), pool.expiry(), "post-expiry downtime fully mitigated by cap");
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), STAKE + BONUS);
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!