Skip to content

bug: FPs Can Bypass Jailing by Toggling Active Status at Sliding Window Boundaries #1852

@RafilxTenfen

Description

@RafilxTenfen

immunefi #56201

Description

Babylon's BTC Staking protocol introduces an additional consensus round on blocks produced by CometBFT, called the finality round. The participants in this round are referred to as finality providers (FPs), and their voting power is derived from staked Bitcoins delegated to them. [1]

The finality module requires every active finality provider to participate in voting to finalize Babylon blocks. If an active FP fails to vote on enough blocks within a sliding window, the module marks the FP as JAILED, which sets the FP's voting power to 0 and stops the accumulation of delegations and FP rewards. [2]

Let's examine the liveness verification code in the HandleFinalityProviderLiveness function [3]. As shown in the code below, the UpdateSigningInfo invocation increments the FP's signInfo.MissedBlocksCounter by one if it misses voting for a block and the block is not already marked as missed. After this invocation, if the current checked height exceeds signInfo.StartHeight + signedBlocksWindow (meaning the FP has been active for more than signedBlocksWindow blocks) and signInfo.MissedBlocksCounter exceeds the maximum allowed value, the FP is marked as JAILED and signInfo.MissedBlocksCounter is reset to zero.

func (k Keeper) HandleFinalityProviderLiveness(ctx context.Context, fpPk *types.BIP340PubKey, missed bool, height int64) error {
// ...omit...

updated, signInfo, err := k.UpdateSigningInfo(ctx, fpPk, missed, height)
if err != nil {
return err
}

signedBlocksWindow := params.SignedBlocksWindow
minSignedPerWindow := params.MinSignedPerWindowInt()

// ...omit...

minHeight := signInfo.StartHeight + signedBlocksWindow
maxMissed := signedBlocksWindow - minSignedPerWindow

// if the number of missed block reaches the threshold within the sliding window
// jail the finality provider
if height > minHeight && signInfo.MissedBlocksCounter > maxMissed {
updated = true

if err := k.jailSluggishFinalityProvider(ctx, fpPk); err != nil {
return fmt.Errorf("failed to jail sluggish finality provider %s: %w", fpPk.MarshalHex(), err)
}

signInfo.JailedUntil = sdkCtx.HeaderInfo().Time.Add(params.JailDuration)
// we need to reset the counter & bitmap so that the finality provider won't be
// immediately jailed after unjailing.
signInfo.MissedBlocksCounter = 0
if err := k.DeleteMissedBlockBitmap(ctx, fpPk); err != nil {
return fmt.Errorf("failed to remove the missed block bit map: %w", err)
}

k.Logger(sdkCtx).Info(
"finality provider is jailed",
"height", height,
"public_key", fpPk.MarshalHex(),
)
}

// Set the updated signing info
if updated {
return k.FinalityProviderSigningTracker.Set(ctx, fpPk.MustMarshal(), *signInfo)
}

return nil
}

Here, we can notice two important things:

The check signInfo.MissedBlocksCounter > maxMissed is only executed when the FP has been active for at least signedBlocksWindow blocks, even if signInfo.MissedBlocksCounter has already reached maxMissed before that.
signInfo.StartHeight is refreshed whenever the FP becomes active again. However, the check logic does not account for situations where the FP toggles from active to inactive and back to active quickly, especially at the boundary of the sliding window signedBlocksWindow.
Thus, a malicious (or faulty) FP could avoid voting entirely by making itself inactive and then reactivating quickly (e.g., by dropping out of the top active FPs and re-entering, or by unbonding delegations to reduce power to zero and then redelegating with controlled delegators) just before the sliding window boundary to refresh signInfo.StartHeight. By repeating this process, the FP can bypass the jailing penalty while still accruing rewards.

Impact Details

Finalizing blocks with FP votes is the core consensus mechanism of the Babylon genesis chain. This is why the protocol enforces FP participation in the voting process and applies jailing penalties to those that fail to comply. However, by refreshing StartHeight through active-to-inactive-to-active toggling, FPs can bypass this consensus requirement, avoid voting entirely, and still receive rewards.

Proposed Fix

The fix may depend on whether a new implementation for FP liveness metadata is desired. Alternatively, we could record the initial height when the FP first becomes active and use that height to calculate minHeight.

References

[1] https://github.com/babylonlabs-io/babylon/blob/release/v2.3.x/x/finality/README.md#finality

[2] https://docs.babylonlabs.io/operators/finality_providers/fp_operations/#64-jailing-and-unjailing

[3]

func (k Keeper) HandleFinalityProviderLiveness(ctx context.Context, fpPk *types.BIP340PubKey, missed bool, height int64) error {

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions