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

`instantPromote` lets a last-block staker capture the bonus without bearing meaningful risk

Author Revealed upon completion

Root + Impact

Description

The protocol intends to prevent stakers from entering immediately before a known successful resolution and earning bonus without bearing meaningful risk.

For this reason, deposits are blocked during PROMOTION_REQUESTED, the state preceding the normal transition to PRODUCTION:

function _assertDepositsAllowed(
IAttackRegistry.ContractState state
) private pure {
if (
state
== IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state
== IAttackRegistry.ContractState.PRODUCTION
|| state
== IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// @> UNDER_ATTACK deposits remain permitted.
}

The documented rationale is that PROMOTION_REQUESTED represents the closing portion of the risk window, so deposits are blocked to prevent a staker from entering immediately before survival becomes final.

However, the upstream Attack Registry contains an instantPromote() function that can transition an agreement directly from UNDER_ATTACK to PRODUCTION, entirely bypassing PROMOTION_REQUESTED.

The pinned upstream implementation explicitly permits instantPromote() from UNDER_ATTACK:

function instantPromote(
address agreementAddress
) external onlyRegistryModerator {
ContractState currentState =
_getAgreementState(agreementAddress);
// @> A direct UNDER_ATTACK -> PRODUCTION transition is allowed.
if (
currentState
!= ContractState.ATTACK_REQUESTED
&& currentState
!= ContractState.UNDER_ATTACK
&& currentState
!= ContractState.PROMOTION_REQUESTED
) {
revert AttackRegistry__InvalidState(
currentState
);
}
s_agreementInfo[agreementAddress].promoted =
true;
}

The normal promotion route first enters PROMOTION_REQUESTED and applies a three-day delay, but instantPromote() intentionally bypasses that process.

An unprivileged searcher can observe a pending instantPromote() transaction and place a stake() transaction immediately before it:

  1. The pool contains a funded bonus.

  2. The agreement is in UNDER_ATTACK.

  3. The registry moderator submits instantPromote().

  4. A searcher places stake() immediately before that transaction.

  5. The stake succeeds because the pool still observes UNDER_ATTACK.

  6. instantPromote() executes directly afterward and permanently sets the agreement to PRODUCTION.

  7. The late staker's principal is now safe, since PRODUCTION is terminal.

  8. The late staker can claim a bonus despite bearing effectively no corruption risk.

The issue is especially severe when the attacker becomes the only eligible staker. Bonus shares are relative rather than absolute, so a sole staker receives the entire bonus regardless of how little time they were exposed.

If the risk window opens and closes at the same timestamp, _bonusShare() deliberately falls back to an amount-weighted distribution:

uint256 globalScore =
plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
// @> The only staker receives 100% of the bonus even when
// @> their measured risk-bearing duration is zero.
return Math.mulDiv(
userEligible,
snapshotTotalBonus,
snapshotTotalStaked
);
}

For a sole last-block staker:

userEligible == snapshotTotalStaked

and therefore:

bonusShare == snapshotTotalBonus

The result contradicts the purpose of blocking deposits during the normal closing window. The protection applies only when the registry uses PROMOTION_REQUESTED, but the trusted upstream state machine contains a separate direct survival transition that bypasses it. The relevant pool behavior and intended deposit gating are contained in the submitted repository. :contentReference[oaicite:2]{index=2}

Risk

A searcher or sequencer can extract the complete bonus pool while bearing little or no economic risk.

The attack does not require the searcher to control the pool, the agreement, or the registry moderator. It only requires transaction-ordering visibility around a legitimate instantPromote() operation.

Consequences include:

  • theft of up to 100% of the contributed bonus;

  • dilution of honest stakers if other stakes exist;

  • risk-free or near-risk-free reward extraction;

  • failure of the intended PROMOTION_REQUESTED late-entry protection;

  • an MEV opportunity around every emergency promotion from UNDER_ATTACK.

The attacker's principal is not exposed to a meaningful corruption interval because the known terminal PRODUCTION transition executes immediately after their deposit.

The impact is Medium because the full bonus can be extracted, although existing staker principal is not stolen.

The likelihood is Medium because exploitation requires an instantPromote() operation from UNDER_ATTACK and transaction ordering ahead of that operation.

Proof of Concept

Add the following test to a contract inheriting BaseConfidencePoolTest:

function test_LastBlockStakerCapturesBonusBeforeInstantPromotion()
external
{
uint256 minimumStake = pool.minStake();
uint256 bonus = 1_000 * ONE;
// Sponsor or another contributor funds the incentive pool.
_contributeBonus(carol, bonus);
// Agreement enters its active-risk state.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// In production, the attacker observes a pending instantPromote()
// transaction and places this stake immediately before it.
_stake(dave, minimumStake);
assertEq(
pool.eligibleStake(dave),
minimumStake
);
assertGt(pool.riskWindowStart(), 0);
// Simulate instantPromote executing directly after the attacker's
// transaction, without passing through PROMOTION_REQUESTED.
//
// No vm.warp is performed: both actions occur at the same timestamp.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
assertEq(
pool.riskWindowStart(),
pool.riskWindowEnd(),
"attacker bore zero measured risk duration"
);
uint256 attackerBefore =
token.balanceOf(dave);
vm.prank(dave);
pool.claimSurvived();
uint256 attackerPayout =
token.balanceOf(dave) - attackerBefore;
// The sole last-block staker recovers principal and extracts the
// entire bonus despite entering immediately before PRODUCTION.
assertEq(
attackerPayout,
minimumStake + bonus
);
assertEq(
token.balanceOf(address(pool)),
0
);
}

A second test can demonstrate dilution of existing stakers:

function test_InstantPromotionBypassesClosingDepositGate()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 1_000 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 7 days);
// Searcher enters immediately before instantPromote.
_stake(dave, 1_000_000 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
// The pool accepted the transaction because the registry had not
// passed through the state that normally closes deposits.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.SURVIVED)
);
}

Recommended Mitigation

The protocol should not rely solely on PROMOTION_REQUESTED to provide a closing window, because the upstream registry allows a direct UNDER_ATTACK -> PRODUCTION transition.

The most robust fix is to require every survival transition to pass through a non-depositable delay state. At the registry level, instantPromote() should either:

  • enter PROMOTION_REQUESTED with an abbreviated but nonzero delay; or

  • expose an INSTANT_PROMOTION_REQUESTED state that closes pool deposits before finalization.

If the upstream registry cannot be changed, the pool should impose a minimum bonus-eligibility duration.

For example, store each deposit's block number or timestamp and exclude deposits that have not matured before the terminal transition:

uint256 public constant MIN_RISK_DURATION =
1 days;
function _bonusShare(
address staker,
uint256 userEligible
) internal view returns (uint256) {
if (
outcomeFlaggedAt
<= userFirstEligibleAt[staker]
+ MIN_RISK_DURATION
) {
return 0;
}
...
}

A stronger version should apply the minimum duration per deposit rather than per user, preserving the existing per-deposit weighting semantics.

The zero-score fallback should also not reward a newly entered staker when the measured risk duration is zero. Instead, distinguish between:

  • longstanding stake for which the window could not produce a time spread; and

  • stake entered in the same block as the terminal transition.

At minimum, deposits made in the terminal-transition block should receive no bonus:

if (
depositBlock[staker]
>= riskWindowEndBlock
) {
return 0;
}

The mitigation must cover every direct transition to PRODUCTION, not only the normal PROMOTION_REQUESTED route.

Support

FAQs

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

Give us feedback!