Skip to content

Latest commit

 

History

History
1141 lines (827 loc) · 42.3 KB

README.md

File metadata and controls

1141 lines (827 loc) · 42.3 KB

Issue H-1: Refund mechanism doesn't make sure that there is a fee granter

Source: #20

Found by

0xeix

Summary

There is currently a refund logic that transfers the funds to a fee payer.

Root Cause

The root cause lies in the fact that the transaction fees can be paid by other enitities and the funds are returned to a fee payer and not a granter.

Internal Pre-conditions

External Pre-conditions

Fee granter has to pay for the fees.

Attack Path

Fee grantor paid for the fees but the funds are returned to a fee payer.

Impact

Loss of funds for a fee granter.

PoC

Consider the current refund mechanism:

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/incentive/keeper/refundable_msg_index.go#L10-23

// RefundTx refunds the given tx by sending the fee back to the fee payer.
func (k Keeper) RefundTx(ctx context.Context, tx sdk.FeeTx) error {
	txFee := tx.GetFee()
	if txFee.IsZero() {
		// not possible with the global min gas price mechanism
		// but having this check for compatibility in the future
		return nil
	}
	txFeePayer := tx.FeePayer()

	return k.bankKeeper.SendCoinsFromModuleToAccount(ctx, k.feeCollectorName, txFeePayer, txFee)
}

The problem is that the fees are refunded to the feePayer (the sender of the message) without taking into account the fact that the actual payer can be feeGranter resulting in a loss of funds for the granter:

type FeeTx interface {
	[Tx](https://pkg.go.dev/github.com/cosmos/cosmos-sdk/types#Tx)
	GetGas() [uint64](https://pkg.go.dev/builtin#uint64)
	GetFee() [Coins](https://pkg.go.dev/github.com/cosmos/cosmos-sdk/types#Coins)
	FeePayer() [][byte](https://pkg.go.dev/builtin#byte)
	FeeGranter() [][byte](https://pkg.go.dev/builtin#byte)
}

Mitigation

Check out the Celestia solution for this issue:

https://github.com/rootulp/celestia-app/blob/d10fdbd4e5507f8449747a2388c4657f593b691b/app/posthandler/refund_gas_remaining.go#L133-L138

// getRecipient returns the address that should receive the refund.
func getRecipient(feeTx sdk.FeeTx) sdk.AccAddress {
	if feeGranter := feeTx.FeeGranter(); feeGranter != nil {
		return feeGranter
	}
	return feeTx.FeePayer()
}

Here the funds are actually sent to the right entity.

Discussion

sherlock-admin2

The protocol team fixed this issue in the following PRs/commits: babylonlabs-io/babylon#594

Issue H-2: The EXPIRED judgment does not include the current block

Source: #33

Found by

LZ_security

Summary

The EXPIRED judgment does not include the current block, fp can still be unbonded after Delegation EXPIRED.

Root Cause

btcHeight+d.UnbondingTime > d.EndHeight should be btcHeight+d.UnbondingTime >= d.EndHeight

Internal Pre-conditions

External Pre-conditions

Attack Path

  1. The Delegation of fp is about to expire.
  2. fp executes BTCUndelegate in the same block that expires.

Impact

When the unbonded event is executed after the EXPIRED event, a painc occurs and the chain is halted. Or it will result in a double reduction in theTotalBondedSat of fp.

PoC

The BTCUndelegate function gets the status through GetStatus, and Delegation EXPIRED returns an error:

func (ms msgServer) BTCUndelegate(goCtx context.Context, req *types.MsgBTCUndelegate) (*types.MsgBTCUndelegateResponse, error) {
	defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), types.MetricsKeyBTCUndelegate)

	ctx := sdk.UnwrapSDKContext(goCtx)
	// basic stateless checks
	if err := req.ValidateBasic(); err != nil {
		return nil, status.Errorf(codes.InvalidArgument, "%v", err)
	}

	btcDel, bsParams, err := ms.getBTCDelWithParams(ctx, req.StakingTxHash)

	if err != nil {
		return nil, err
	}

	// ensure the BTC delegation with the given staking tx hash is active
	btcTip := ms.btclcKeeper.GetTipInfo(ctx)

->	btcDelStatus := btcDel.GetStatus(
		btcTip.Height,
		bsParams.CovenantQuorum,
	)

->	if btcDelStatus == types.BTCDelegationStatus_UNBONDED || btcDelStatus == types.BTCDelegationStatus_EXPIRED {
		return nil, types.ErrInvalidBTCUndelegateReq.Wrap("cannot unbond an unbonded BTC delegation")
	}
    ......
}

The problem is that GetStatus does not include the current block in its judgment. If the current block is in d.EndHeight - d.UnbondingTime, BTCDelegationStatus_EXPIRED will not be returned:

func (d *BTCDelegation) GetStatus(
	btcHeight uint32,
	covenantQuorum uint32,
) BTCDelegationStatus {
    ......

->   // btcHeight = currentBtcTipHeight 
->   // if btcHeight > d.EndHeight - d.UnbondingTime
->	if btcHeight+d.UnbondingTime > d.EndHeight {
		return BTCDelegationStatus_EXPIRED
	}

	return BTCDelegationStatus_ACTIVE
}

The EXPIRED event executes at: d.EndHeight - d.UnbondingTime

func (k Keeper) AddBTCDelegation(
	ctx sdk.Context,
	btcDel *types.BTCDelegation,
) error {
    ......
    // record event that the BTC delegation will become expired (unbonded) at EndHeight-w
    // This event will be generated to subscribers as block event, when the
    // btc light client block height will reach btcDel.EndHeight-wValue
    expiredEvent := types.NewEventPowerDistUpdateWithBTCDel(&types.EventBTCDelegationStateUpdate{
        StakingTxHash: stakingTxHash.String(),
        NewState:      types.BTCDelegationStatus_EXPIRED,
    })

    // NOTE: we should have verified that EndHeight > btcTip.Height + unbonding_time
->  k.addPowerDistUpdateEvent(ctx, btcDel.EndHeight-btcDel.UnbondingTime, expiredEvent)
	......
}

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/btcstaking/types/btc_delegation.go#L145-L147

So BTCUndelegate and EXPIRED can be executed in the same block.

When handling EXPIRED events, delegater's unbond state is determined first(IsUnbondedEarly), but since the event is handled in BeginBlocker and sent before BTCUndelegate, so DelegatorUnbondingInfo is not set:

func (k Keeper) ProcessAllPowerDistUpdateEvents(
	ctx context.Context,
	dc *ftypes.VotingPowerDistCache,
	events []*types.EventPowerDistUpdate,
) *ftypes.VotingPowerDistCache {
	        ........
			case types.BTCDelegationStatus_UNBONDED:
				// add the unbonded BTC delegation to the map
				//@audit-info unbonded 之fp 应该从 fpByBtcPkHex 缓存列表中删除 UNBONDED 和 EXPIRED 同时发送?
				k.processPowerDistUpdateEventUnbond(ctx, fpByBtcPkHex, btcDel, unbondedSatsByFpBtcPk)
			case types.BTCDelegationStatus_EXPIRED:
				types.EmitExpiredDelegationEvent(sdkCtx, delStkTxHash)

->				// IsUnbondedEarly -> return d.BtcUndelegation.DelegatorUnbondingInfo != nil
->				if !btcDel.IsUnbondedEarly() {
					// only adds to the new unbonded list if it hasn't
					// previously unbonded with types.BTCDelegationStatus_UNBONDED
					k.processPowerDistUpdateEventUnbond(ctx, fpByBtcPkHex, btcDel, unbondedSatsByFpBtcPk)
				}
			}

This leads to a painc:

UpdatePowerDist -> ProcessAllPowerDistUpdateEvents -> MustProcessBtcDelegationUnbonded :

func (k Keeper) MustProcessBtcDelegationUnbonded(ctx context.Context, fp, del sdk.AccAddress, sats uint64) {
	err := k.IncentiveKeeper.BtcDelegationUnbonded(ctx, fp, del, sats)
->	if err != nil {
->		panic(err)
	}
}

func (k Keeper) BtcDelegationUnbonded(ctx context.Context, fp, del sdk.AccAddress, sat uint64) error {
	amtSat := sdkmath.NewIntFromUint64(sat)
	return k.btcDelegationModifiedWithPreInitDel(ctx, fp, del, func(ctx context.Context, fp, del sdk.AccAddress) error {
->		return k.subDelegationSat(ctx, fp, del, amtSat)
	})
}


func (k Keeper) subDelegationSat(ctx context.Context, fp, del sdk.AccAddress, amt sdkmath.Int) error {
	btcDelRwdTracker, err := k.GetBTCDelegationRewardsTracker(ctx, fp, del)
	if err != nil {
		return err
	}

->	btcDelRwdTracker.SubTotalActiveSat(amt)
->	if btcDelRwdTracker.TotalActiveSat.IsNegative() {
->		return types.ErrBTCDelegationRewardsTrackerNegativeAmount
	}
	if err := k.setBTCDelegationRewardsTracker(ctx, fp, del, btcDelRwdTracker); err != nil {
		return err
	}

	return k.subFinalityProviderStaked(ctx, fp, amt)
}

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/finality/keeper/power_dist_change.go#L437-L442

The event is handled in BeginBlocker, and painc causes the chain to halt.

finality/abci.go/BeginBlocker -> UpdatePowerDist -> ProcessAllPowerDistUpdateEvents

Theamt passed by thesubDelegationSat function, which is actually the amount of delegation, is set whenAddBTCDelegation is added.

UpdatePowerDist function, through k.BTCStakingKeeper.GetBTCDelegation(ctx, delStkTxHash) get this value and use this value to update fp.TotalBondedSat. If UNBOND is repeated, fp.TotalBondedSat will also be reduced repeatedly.

Mitigation

func (d *BTCDelegation) GetStatus(
	btcHeight uint32,
	covenantQuorum uint32,
) BTCDelegationStatus {

    ......
-	if btcHeight+d.UnbondingTime > d.EndHeight {
+	if btcHeight+d.UnbondingTime >= d.EndHeight {
		return BTCDelegationStatus_EXPIRED
	}

	return BTCDelegationStatus_ACTIVE
}

Discussion

sherlock-admin2

The protocol team fixed this issue in the following PRs/commits: babylonlabs-io/babylon#597

Issue H-3: If the Covenant signature does not pass , EXPIRED events it will still be executed.

Source: #35

Found by

LZ_security

Summary

If the Covenant signature does not pass , EXPIRED events it will still be executed, causes fp.TotalBondedSat to be incorrectly reduced.

Root Cause

If the Covenant signature does not pass , EXPIRED events it will still be executed.

Internal Pre-conditions

External Pre-conditions

Attack Path

  1. Delegater uses the CreateBTCDelegation command to create a Delegation. The EXPIRED message is sent.
  2. The Covenants began to sign.
  3. Delegater unstake before the number of signatures reaches the threshold.
  4. The signature fails. The Active message cannot be sent, but the EXPIRED message is sent.
  5. The EXPIRED file is executed after a period of time.

Impact

fp.TotalBondedSat to be incorrectly reduced.

PoC

Create a Delegation, if by AddBTCDelegationInclusionProof function, activeEvent and expiredEvent will be added at the same time:

func (ms msgServer) AddBTCDelegationInclusionProof(
	goCtx context.Context,
	req *types.MsgAddBTCDelegationInclusionProof,
) (*types.MsgAddBTCDelegationInclusionProofResponse, error) {
    ......
	activeEvent := types.NewEventPowerDistUpdateWithBTCDel(
		&types.EventBTCDelegationStateUpdate{
			StakingTxHash: stakingTxHash.String(),
			NewState:      types.BTCDelegationStatus_ACTIVE,
		},
	)

	ms.addPowerDistUpdateEvent(ctx, timeInfo.TipHeight, activeEvent)

	// record event that the BTC delegation will become unbonded at EndHeight-w
	expiredEvent := types.NewEventPowerDistUpdateWithBTCDel(&types.EventBTCDelegationStateUpdate{
		StakingTxHash: req.StakingTxHash,
		NewState:      types.BTCDelegationStatus_EXPIRED,
	})

	// NOTE: we should have verified that EndHeight > btcTip.Height + min_unbonding_time
	ms.addPowerDistUpdateEvent(ctx, btcDel.EndHeight-params.UnbondingTimeBlocks, expiredEvent)
......
}

The problem is that if the CreateBTCDelegation function is used, the expiredEvent is sent first, and the activeEvent is sent after the Covenants signature is passed. If the signature does not pass the activeEvent, it will never be sent, but expiredEvent has been sent and will eventually execute:

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/btcstaking/keeper/msg_server.go#L294-L297

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/btcstaking/keeper/btc_delegations.go#L76-L83

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/btcstaking/keeper/btc_delegations.go#L134-L141

If staker/Delegater is unstake in the btc chain after the CreateBTCDelegation enters the Covenants signature phase,Covenants should not sign:

In addition,BTCUndelegate causes the signature to fail:

func (ms msgServer) AddCovenantSigs(goCtx context.Context, req *types.MsgAddCovenantSigs) (*types.MsgAddCovenantSigsResponse, error) {
    ......
	// ensure BTC delegation is still pending, i.e., not unbonded
	btcTipHeight := ms.btclcKeeper.GetTipInfo(ctx).Height
	status := btcDel.GetStatus(btcTipHeight, params.CovenantQuorum)
->	if status == types.BTCDelegationStatus_UNBONDED || status == types.BTCDelegationStatus_EXPIRED {
		ms.Logger(ctx).Debug("Received covenant signature after the BTC delegation is already unbonded", "covenant pk", req.Pk.MarshalHex())
		return nil, types.ErrInvalidCovenantSig.Wrap("the BTC delegation is already unbonded")
	}

    ......
}

Covenants If the signature is not passed, the BTCUndelegate can be executed only when the UNBONDED or EXPIRED status is unavailable Undelegate, So staker can block Covenants' signatures by using BTCUndelegate.

func (ms msgServer) BTCUndelegate(goCtx context.Context, req *types.MsgBTCUndelegate) (*types.MsgBTCUndelegateResponse, error) {
	defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), types.MetricsKeyBTCUndelegate)

	ctx := sdk.UnwrapSDKContext(goCtx)
	// basic stateless checks
	if err := req.ValidateBasic(); err != nil {
		return nil, status.Errorf(codes.InvalidArgument, "%v", err)
	}

	btcDel, bsParams, err := ms.getBTCDelWithParams(ctx, req.StakingTxHash)

	if err != nil {
		return nil, err
	}

	// ensure the BTC delegation with the given staking tx hash is active
	btcTip := ms.btclcKeeper.GetTipInfo(ctx)

->	btcDelStatus := btcDel.GetStatus(
		btcTip.Height,
		bsParams.CovenantQuorum,
	)

->	if btcDelStatus == types.BTCDelegationStatus_UNBONDED || btcDelStatus == types.BTCDelegationStatus_EXPIRED {
		return nil, types.ErrInvalidBTCUndelegateReq.Wrap("cannot unbond an unbonded BTC delegation")
	}
    ......
}

expiredEvent causes fp's TotalBondedSat to decrease, or a negative number in the system causes painc.

The balance of Delegater is set upon creation and stored in the Store:

// CreateBTCDelegation creates a BTC delegation
func (ms msgServer) CreateBTCDelegation(goCtx context.Context, req *types.MsgCreateBTCDelegation) (*types.MsgCreateBTCDelegationResponse, error) {
    ......

	// 7.all good, construct BTCDelegation and insert BTC delegation
	// NOTE: the BTC delegation does not have voting power yet. It will
	// have voting power only when it receives a covenant signatures
	newBTCDel := &types.BTCDelegation{
		StakerAddr:       parsedMsg.StakerAddress.String(),
		BtcPk:            parsedMsg.StakerPK.BIP340PubKey,
		Pop:              parsedMsg.ParsedPop,
		FpBtcPkList:      parsedMsg.FinalityProviderKeys.PublicKeysBbnFormat,
		StakingTime:      uint32(parsedMsg.StakingTime),
		StartHeight:      timeInfo.StartHeight,
		EndHeight:        timeInfo.EndHeight,
->		TotalSat:         uint64(parsedMsg.StakingValue),
		StakingTx:        parsedMsg.StakingTx.TransactionBytes,
		StakingOutputIdx: paramsValidationResult.StakingOutputIdx,
		SlashingTx:       types.NewBtcSlashingTxFromBytes(parsedMsg.StakingSlashingTx.TransactionBytes),
		DelegatorSig:     parsedMsg.StakerStakingSlashingTxSig.BIP340Signature,
		UnbondingTime:    uint32(parsedMsg.UnbondingTime),
		CovenantSigs:     nil, // NOTE: covenant signature will be submitted in a separate msg by covenant
		BtcUndelegation: &types.BTCUndelegation{
			UnbondingTx:              parsedMsg.UnbondingTx.TransactionBytes,
			SlashingTx:               types.NewBtcSlashingTxFromBytes(parsedMsg.UnbondingSlashingTx.TransactionBytes),
			DelegatorSlashingSig:     parsedMsg.StakerUnbondingSlashingSig.BIP340Signature,
			CovenantSlashingSigs:     nil, // NOTE: covenant signature will be submitted in a separate msg by covenant
			CovenantUnbondingSigList: nil, // NOTE: covenant signature will be submitted in a separate msg by covenant
			DelegatorUnbondingInfo:   nil,
		},
		ParamsVersion: paramsVersion,      // version of the params against which delegation was validated
		BtcTipHeight:  timeInfo.TipHeight, // height of the BTC light client tip at the time of the delegation creation
	}

	// add this BTC delegation, and emit corresponding events
->	if err := ms.AddBTCDelegation(ctx, newBTCDel); err != nil {
		panic(fmt.Errorf("failed to add BTC delegation that has passed verification: %w", err))
	}

When handling expiredEvent events, the balance of Delegater is read and fp TotalBondedSat is reduced:

func (k Keeper) ProcessAllPowerDistUpdateEvents(
	ctx context.Context,
	dc *ftypes.VotingPowerDistCache,
	events []*types.EventPowerDistUpdate,
) *ftypes.VotingPowerDistCache {
	// a map where key is finality provider's BTC PK hex and value is a list
	// of BTC delegations satoshis amount that newly become active under this provider
	activedSatsByFpBtcPk := map[string][]uint64{}
	// a map where key is finality provider's BTC PK hex and value is a list
	// of BTC delegations satoshis that were unbonded or expired without previously
	// being unbonded
	unbondedSatsByFpBtcPk := map[string][]uint64{}
	// a map where key is slashed finality providers' BTC PK
	slashedFPs := map[string]struct{}{}
	// a map where key is jailed finality providers' BTC PK
	jailedFPs := map[string]struct{}{}
	// a map where key is unjailed finality providers' BTC PK
	unjailedFPs := map[string]struct{}{}

	// simple cache to load fp by his btc pk hex
	fpByBtcPkHex := map[string]*types.FinalityProvider{}

	/*
		filter and classify all events into new/expired BTC delegations and jailed/slashed FPs
	*/
	sdkCtx := sdk.UnwrapSDKContext(ctx)
	for _, event := range events {
		switch typedEvent := event.Ev.(type) {
		case *types.EventPowerDistUpdate_BtcDelStateUpdate:
			delEvent := typedEvent.BtcDelStateUpdate
			delStkTxHash := delEvent.StakingTxHash

->			btcDel, err := k.BTCStakingKeeper.GetBTCDelegation(ctx, delStkTxHash)
			if err != nil {
				panic(err) // only programming error
			}

			switch delEvent.NewState {
			case types.BTCDelegationStatus_ACTIVE:
				// newly active BTC delegation
				// add the BTC delegation to each restaked finality provider
				for _, fpBTCPK := range btcDel.FpBtcPkList {
					fpBTCPKHex := fpBTCPK.MarshalHex()
					activedSatsByFpBtcPk[fpBTCPKHex] = append(activedSatsByFpBtcPk[fpBTCPKHex], btcDel.TotalSat)
				}

				k.processRewardTracker(ctx, fpByBtcPkHex, btcDel, func(fp, del sdk.AccAddress, sats uint64) {
					k.MustProcessBtcDelegationActivated(ctx, fp, del, sats)//
				})
			case types.BTCDelegationStatus_UNBONDED:
				// add the unbonded BTC delegation to the map
->				k.processPowerDistUpdateEventUnbond(ctx, fpByBtcPkHex, btcDel, unbondedSatsByFpBtcPk)
			case types.BTCDelegationStatus_EXPIRED:
				types.EmitExpiredDelegationEvent(sdkCtx, delStkTxHash)

				// IsUnbondedEarly -> return d.BtcUndelegation.DelegatorUnbondingInfo != nil
				if !btcDel.IsUnbondedEarly() {
					// only adds to the new unbonded list if it hasn't
					// previously unbonded with types.BTCDelegationStatus_UNBONDED
->					k.processPowerDistUpdateEventUnbond(ctx, fpByBtcPkHex, btcDel, unbondedSatsByFpBtcPk)
				}
			}
            .....


            // process all new unbonding BTC delegations under this finality provider
            if fpUnbondedSats, ok := unbondedSatsByFpBtcPk[fpBTCPKHex]; ok {
                // handle unbonded delegations for this finality provider
                for _, unbodedSats := range fpUnbondedSats {
->                    // RemoveBondedSats -> v.TotalBondedSat -= sats
->                    fp.RemoveBondedSats(unbodedSats)
                }
                // remove the finality provider entry in fpUnbondedSats map, so that
                // after the for loop the rest entries in fpUnbondedSats belongs to new
                // finality providers that might have btc delegations entries
                // that activated and unbonded in the same slice of events
                delete(unbondedSatsByFpBtcPk, fpBTCPKHex)
            }
            ......
        }
    }

func (k Keeper) processPowerDistUpdateEventUnbond(
	ctx context.Context,
	cacheFpByBtcPkHex map[string]*types.FinalityProvider,
	btcDel *types.BTCDelegation,
	unbondedSatsByFpBtcPk map[string][]uint64,
) {
	for _, fpBTCPK := range btcDel.FpBtcPkList {
		fpBTCPKHex := fpBTCPK.MarshalHex()
->		unbondedSatsByFpBtcPk[fpBTCPKHex] = append(unbondedSatsByFpBtcPk[fpBTCPKHex], btcDel.TotalSat)
	}
	k.processRewardTracker(ctx, cacheFpByBtcPkHex, btcDel, func(fp, del sdk.AccAddress, sats uint64) {
		k.MustProcessBtcDelegationUnbonded(ctx, fp, del, sats)
	})
}

Mitigation

Send EXPIRED and Active messages at the same time instead of separately.

Issue M-1: Btcstaking module allows stakingTx to be coinbase transaction which is unslashable for 100 blocks

Source: #6

Found by

n4nika

Summary

Coinbase transactions have a special property of not being spendable for 100 bocks after creation. If now a staker uses such a transaction as a staking transaction (by adding the required outputs), that transaction will be recognized as a valid staking TX but if the owner of it double-signs, he cannot be slashed for 100 blocks due to the unspendability of the coinbase TX.

Root Cause

Looking at CreateBTCDelegation and ValidateParsedMessageAgainstTheParams which does verification on the provided TX, there are no specific checks for whether a transaction is a coinbase transaction.

Internal Pre-conditions

None

External Pre-conditions

Attacker needs to be the creator of a block (a miner) in order to build the coinbase TX like they want

Attack Path

  • Create a coinbase TX which is a valid staking TX
  • Call CreateBTCDelegation with that TX
  • It gets accepted and its value added as voting power

Impact

The README states under High impact: Inability to slash BTC stake for which the voting power was involved in double-signing.

That is exactly what happens here. Even if the staker's delegator misbehaves, the staker's stakingTx cannot be spent until 100 blocks after. Adding to the impact, if now the minimum staking time is less than 100 bitcoin blocks, this allows the malicious staker to unstake before getting slashed (if the slashing even gets retried after 100 blocks)

PoC

No response

Mitigation

Coinbase transactions can be identified since the ID of their input must be all zeros. Therefore consider checking whether a staking transaction has one input with an ID of all-zeros and reject it if so.

Discussion

sherlock-admin2

The protocol team fixed this issue in the following PRs/commits: babylonlabs-io/babylon#563

Issue M-2: Message is indexed as refundable even if the signature was over a fork

Source: #18

Found by

0xeix

Summary

When adding a finality sig, a message is still marked as refundable even if the vote was over the fork.

Root Cause

The root cause lies in the fact that there is no return after slashing the finality provider resulting in a message being indexed as refundable in the IncentiveKeeper.

Internal Pre-conditions

External Pre-conditions

A FP adds a finality sig.

Attack Path

A FP adds a finality sig and his message is incorrectly marked as refundable due to the missing return.

Impact

The message can still be refunded even if it's a signature addition with invalid data.

PoC

The current code makes no return when slashing a provider that adds a finality sig:

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/finality/keeper/msg_server.go#L192-216

// if this finality provider has signed the canonical block before,
	// slash it via extracting its secret key, and emit an event
	if ms.HasEvidence(ctx, req.FpBtcPk, req.BlockHeight) {
		// the finality provider has voted for a fork before!
		// If this evidence is at the same height as this signature, slash this finality provider

		// get evidence
		evidence, err := ms.GetEvidence(ctx, req.FpBtcPk, req.BlockHeight)
		if err != nil {
			panic(fmt.Errorf("failed to get evidence despite HasEvidence returns true"))
		}

		// set canonical sig to this evidence
		evidence.CanonicalFinalitySig = req.FinalitySig
		ms.SetEvidence(ctx, evidence)

		// slash this finality provider, including setting its voting power to
		// zero, extracting its BTC SK, and emit an event
		ms.slashFinalityProvider(ctx, req.FpBtcPk, evidence)
	}

	// at this point, the finality signature is 1) valid, 2) over a canonical block,
	// and 3) not duplicated.
	// Thus, we can safely consider this message as refundable
	ms.IncentiveKeeper.IndexRefundableMsg(ctx, req)

As you can see here, after slashing a provider, the message is marked as refundable:

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/incentive/keeper/refundable_msg_index.go#L23-38

// IndexRefundableMsg indexes the given refundable message by its hash.
func (k Keeper) IndexRefundableMsg(ctx context.Context, msg sdk.Msg) {
	msgHash := types.HashMsg(msg)
	err := k.RefundableMsgKeySet.Set(ctx, msgHash)
	if err != nil {
		panic(err) // encoding issue; this can only be a programming error
	}
}

The problem is that there has to be some conditions to be satisfied outlined in the comments for the message to be refundable:

// at this point, the finality signature is 1) valid, 2) over a canonical block,
	// and 3) not duplicated.
	// Thus, we can safely consider this message as refundable

The problem is that there is no return after slashing the provider previously meaning the next line executed will be the indexing of the message. In contrary, there is another slashing logic in the same function that correctly returns the response:

https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/finality/keeper/msg_server.go#L172-178

	// save evidence
		ms.SetEvidence(ctx, evidence)

		// NOTE: we should NOT return error here, otherwise the state change triggered in this tx
		// (including the evidence) will be rolled back
		return &types.MsgAddFinalitySigResponse{}, nil
	}

Mitigation

After slashing a finality provider, add a response with nil as an error so the message is not marked as refundable later.

Discussion

sherlock-admin2

The protocol team fixed this issue in the following PRs/commits: babylonlabs-io/babylon#592

Issue M-3: maybeResendFromStore may wrongly submit a checkpoint transaction twice

Source: #23

Found by

valuevalk

Summary

maybeResendFromStore in relayer.go in vigilante module, currently resends the same checkpoint tx again, no matter what the error returned from the json-rpc function GetRawTransactionFunc is, which in some situations could obstruct the correct flow, as we could end up submitting the same transaction twice.

Root Cause

Bitcoin rpc errors: https://github.com/btcsuite/btcd/blob/bb52d7d78d9cf335e0611b9ae06ad8c77e75de0b/btcjson/jsonrpcerr.go#L177

When submitting a checkpoint tx to bitcoin, we use maybeResendFromStore which has a fail-safe mechanism which only proceeds with sending the transaction if there is an error when calling GetRawTransactionFunc, this is to prevent sending already send transactions, if for example the submitter service is restarted.

However there is a flaw, currently we do that no matter what the returned error is, this poses a problem as there is possibility that error could be returned due to problems with the rpc( rate limits exceeded, network error & etc ), or other json-rpc error codes that are not transaction not found.

func (rl *Relayer) SendCheckpointToBTC(ckpt *ckpttypes.RawCheckpointWithMetaResponse) error {
.........
	if rl.shouldSendCompleteCkpt(ckptEpoch) || rl.shouldSendTx2(ckptEpoch) {
@>>        	hasBeenProcessed, err := maybeResendFromStore(
			ckptEpoch,
			rl.store.LatestCheckpoint,
			rl.GetRawTransaction,
			rl.sendTxToBTC,
		)
		if err != nil {
			return err
		}
		if hasBeenProcessed {
			return nil
		}
	}
....
}

@>> // maybeResendFromStore - checks if we need to resubmit txns from a store
@>> // in case "submitter" service was restarted, we want to ensure that we don't send txns again for a checkpoint
@>> // that has already been processed.
@>> // Returns true if the first transactions are in the mempool (no resubmission needed),
@>> // and false if any transaction was re-sent from the store.
func maybeResendFromStore(
	epoch uint64,
	getLatestStoreCheckpoint GetLatestCheckpointFunc,
	getRawTransaction GetRawTransactionFunc,
	sendTransaction SendTransactionFunc,
) (bool, error) {
	storedCkpt, exists, err := getLatestStoreCheckpoint()
	if err != nil {
		return false, err
	} else if !exists {
		return false, nil
	}
	if storedCkpt.Epoch != epoch {
		return false, nil
	}
	maybeResendFunc := func(tx *wire.MsgTx) error {
		txID := tx.TxHash()
@>>	_, err = getRawTransaction(&txID) // todo(lazar): check for specific not found err
@>>	if err != nil {
@>>		_, err := sendTransaction(tx)
			if err != nil {
@>>			return err //we could end up here
			}
			// we know about this tx, but we needed to resend it from already constructed tx from db
			return nil
		}
		// tx exists in mempool and is known to us
		return nil
	}
@>>	if err := maybeResendFunc(storedCkpt.Tx1); err != nil {
		return false, err
	}
@>>	if err := maybeResendFunc(storedCkpt.Tx2); err != nil {
		return false, err
	}
	return true, nil
}

A few bad scenarios could happen here:

  • If in reality storedCkpt.Tx2 needs to be resend, but we failed on maybeResendFunc(storedCkpt.Tx1) because we falsely send the same tx1 again, we would not be able to re-submit storedCkpt.Tx2.
  • The MaybeResubmitSecondCheckpointTx flow will not be executed because we call continue.
func (s *Submitter) processCheckpoints() {
	defer s.wg.Done()
	quit := s.quitChan()

	for {
		select {
		case ckpt := <-s.poller.GetSealedCheckpointChan():
			s.logger.Infof("A sealed raw checkpoint for epoch %v is found", ckpt.Ckpt.EpochNum)
			if err := s.relayer.SendCheckpointToBTC(ckpt); err != nil {
				s.logger.Errorf("Failed to submit the raw checkpoint for %v: %v", ckpt.Ckpt.EpochNum, err)
				s.metrics.FailedCheckpointsCounter.Inc()

@>>			continue
			}
			if err := s.relayer.MaybeResubmitSecondCheckpointTx(ckpt); err != nil {
				s.logger.Errorf("Failed to resubmit the raw checkpoint for %v: %v", ckpt.Ckpt.EpochNum, err)
				s.metrics.FailedCheckpointsCounter.Inc()
			}
			s.metrics.SecondsSinceLastCheckpointGauge.Set(0)
		case <-quit:
			// We have been asked to stop
			return
		}
	}
}

Attack Path:

  1. getRawTransaction returns an error, due to network connection ( for example )
  2. sendTransaction(tx) is called, however it fails with error as its already submitted to the bitcoin chain
  3. Error is propagated from relayer.go maybeResendFromStore to submitter.go
  4. As result even though checkpoint 2 needed to be re-submitted its not. ( Either with the maybeResendFunc(storedCkpt.Tx2) flow, or with the bump fee flow in MaybeResubmitSecondCheckpointTx )

Impact

If sendTx is called for the 1st checkpoint tx, when the tx is already on bitcoin chain, the operation will fail and will obstruct the flow, possibly leading to not correctly submitting the 2nd tx if it needed to be resubmitted.

Recommendation

The correct behaviours would be to handle separately RPC/curl request error or any json-rpc error code different from "transaction not found".

Discussion

sherlock-admin2

The protocol team fixed this issue in the following PRs/commits: babylonlabs-io/vigilante#250

Issue M-4: Incorrect BTC Delegation Reward Calculation Due to Using Current Stake Amount Instead of Historical Stake

Source: #70

Found by

0xNirix

Summary

Missing historical stake tracking in the incentive reward system will cause incorrect reward distribution for BTC delegators as changing delegation amounts after a period but before rewards are calculated will result in rewards based on current stake rather than historical stake.

Root Cause

In https://github.com/sherlock-audit/2024-12-babylon/blob/main/babylon/x/incentive/keeper/reward_tracker.go#L200 the calculateDelegationRewardsBetween function calculates rewards using the delegation's current active satoshi amount rather than the historical amount that was active during the reward period. This means:

// This incorrectly uses current stake amount (btcDelRwdTracker.TotalActiveSat)
// rather than the historical amount that was active during that period
rewardsWithDecimals := differenceWithDecimals.MulInt(btcDelRwdTracker.TotalActiveSat)

This issue exists because the BTCDelegationRewardsTracker struct stores only the current stake amount and starting period, without preserving historical stake amounts per period. When delegations are modified (through unbonding or new activations), the stake amount is immediately updated, but there's no record of what it was at different periods. However, rewards of a block may be delayed (they are always delayed by FinalitySigTimeout to allow for voting time, but can be massively delayed when governance resumes finality after halt). Finality providers are rewarded in proportion to the correct cached total bonded delegations, however individual delegators may receive incorrect reward out of that based on their current stake.

Internal Pre-conditions

NA

External Pre-conditions

NA

Attack Path

  1. Delegator A has 100,000 sats delegated to a finality provider during Period 1
  2. Rewards accrue but are not yet calculated and distributed as finality has halted.
  3. Before rewards are calculated, Delegator A reduces their delegation to 50,000 sats (period changes to Period 2)
  4. A governance action triggers calculation of rewards for Period 1 after resuming finality
  5. The system incorrectly uses the current stake amount (50,000 sats) to calculate Period 1's rewards instead of the original amount (100,000 sats)
  6. Delegator A receives only half the rewards they should have earned

Alternatively:

  1. Delegator B has 50,000 sats delegated to a finality provider during Period 1
  2. Rewards accrue for Period 1 but are not yet calculated and distributed as finality has halted.
  3. Before rewards are calculated, Delegator B increases their delegation to 100,000 sats (period changes to Period 2)
  4. A governance action triggers calculation of rewards for Period 1
  5. The system incorrectly uses the current stake amount (100,000 sats) to calculate Period 1's rewards
  6. Delegator B receives double the rewards they should have earned

Please not this issue can happen (even without finality halt and governance resumption) for FinalitySigTimeout blocks, as protocol waits for FinalitySigTimeout blocks before finalizing a block's reward and unbonding/ bonding in that period can cause this issue.

Impact

The BTC delegators suffer from incorrect reward distribution. Those who decrease their stake before rewards are calculated receive fewer rewards than they deserve, while those who increase their stake receive more rewards than they deserve. This creates unfairness in the protocol and could potentially be exploited by sophisticated users who understand the timing of reward calculations specially in cases of governance resumption of finality.

PoC

To demonstrate the issue with concrete examples, let's walk through how the incentive system handles reward calculations when delegations change, and why this leads to incorrect reward distribution.

Scenario: Delegator Reduces Stake After Period 1 But Before Rewards Are Calculated Due to Governance Action

Initial Setup

  1. DelegatorA has 100,000 sats staked to FinalityProviderX during Period 1
  2. A finality halt occurs during Period 1, preventing rewards from being distributed
  3. Period 2 begins with the halt still in effect if delegator unbonds

Step 1: Delegation Reduction in Period 2

// DelegatorA calls to unbond 50,000 sats creating Period 2
k.BtcDelegationUnbonded(ctx, fpAddr, delegatorAddr, 50000)

// This function calls:
k.btcDelegationModifiedWithPreInitDel(ctx, fp, del, function(ctx, fp, del) {
    return k.subDelegationSat(ctx, fp, del, 50000)
})

// Which increments the period and calculates rewards for current period:
endedPeriod, err := k.IncrementFinalityProviderPeriod(ctx, fp)

// The delegation's TotalActiveSat is reduced:
if err := k.subDelegationSat(ctx, fp, del, amtSat); err != nil {
    return err
}

Here, the system:

  1. Increments the period from 1 to 2
  2. Attempts to calculate rewards for Period 1, but since there was a finality halt, no rewards are distributed yet
  3. Reduces the delegation's TotalActiveSat from 100,000 to 50,000
  4. Initializes a new BTCDelegationRewardsTracker with 50,000 sats and a starting period of 2

Step 2: Governance Action to Resume Finality

Later, a governance proposal is submitted to resume finality:

// HandleResumeFinalityProposal is called with the finality providers to jail
k.HandleResumeFinalityProposal(ctx, fpPksHex, haltingHeight)

// This jails the specified FPs and recalculates voting power for previous heights
for h := uint64(haltingHeight); h <= uint64(currentHeight); h++ {
    distCache := k.GetVotingPowerDistCache(ctx, h)
    // ... updating distCache ...
    k.SetVotingPowerDistCache(ctx, h, distCache)
}

// Then it tallies blocks
k.TallyBlocks(ctx)

Step 3: Reward Distribution

The TallyBlocks finalizes and eventually leads to reward distribution for previously finalized blocks in HandleRewarding (called via EndBlocker)

// HandleRewarding calls the reward to stakers if the block is finalized
func (k Keeper) HandleRewarding(ctx context.Context, targetHeight int64) {
	...
	for height := nextHeightToReward; height <= uint64(targetHeight); height++ {

		if !block.Finalized {
			continue
		}
		k.rewardBTCStaking(ctx, height)
	
	}

}

Step 4: Reward Calculation in RewardBTCStaking

Now, RewardBTCStaking but after DelegatorA's stake has been reduced:

// RewardBTCStaking distributes rewards based on voting power
func (k Keeper) RewardBTCStaking(ctx context.Context, height uint64, dc *ftypes.VotingPowerDistCache, voters map[string]struct{}) {
    // Get the gauge for this height
    gauge := k.GetBTCStakingGauge(ctx, height)
    
    // Calculate total voting power of voters
    var totalVotingPowerOfVoters uint64
    for i, fp := range dc.FinalityProviders {
        if _, ok := voters[fp.BtcPk.MarshalHex()]; ok {
            totalVotingPowerOfVoters += fp.TotalBondedSat
        }
    }
    
    // Distribute rewards to FPs and delegations
    for _, fp := range dc.FinalityProviders {
        if _, ok := voters[fp.BtcPk.MarshalHex()]; !ok {
            continue
        }
        
        // Calculate portion of rewards for this FP
        fpPortion := sdkmath.LegacyNewDec(int64(fp.TotalBondedSat)). => this is cached total bonded delegations to the F
            QuoTruncate(sdkmath.LegacyNewDec(int64(totalVotingPowerOfVoters)))
        coinsForFpsAndDels := gauge.GetCoinsPortion(fpPortion)
        
        // Give commission to FP
        coinsForCommission := types.GetCoinsPortion(coinsForFpsAndDels, *fp.Commission)
        k.accumulateRewardGauge(ctx, types.FinalityProviderType, fp.GetAddress(), coinsForCommission)
        
        // Distribute remaining rewards to delegations
        coinsForBTCDels := coinsForFpsAndDels.Sub(coinsForCommission...)
        k.AddFinalityProviderRewardsForBtcDelegations(ctx, fp.GetAddress(), coinsForBTCDels)
    }
}

Important Note: The finality provider's share of rewards is correctly calculated based on the cached voting power distribution (dc) from. This is properly preserved in the system and is not affected by the issue. The problem only occurs at the individual delegator level when rewards are calculated for each delegator.

This leads to:

// AddFinalityProviderRewardsForBtcDelegations adds rewards to the FP's current rewards
func (k Keeper) AddFinalityProviderRewardsForBtcDelegations(ctx context.Context, fp sdk.AccAddress, rwd sdk.Coins) error {
    fpCurrentRwd, err := k.GetFinalityProviderCurrentRewards(ctx, fp)
    if err != nil {
        return err
    }

    // Add rewards to the FP's current rewards pool
    fpCurrentRwd.AddRewards(rwd)
    return k.setFinalityProviderCurrentRewards(ctx, fp, fpCurrentRwd)
}

Step 5: Later, When Delegator Withdraws Rewards

When DelegatorA later calls to withdraw rewards:

// MsgWithdrawReward handler calls:
k.sendAllBtcDelegationTypeToRewardsGauge(ctx, sType, addr)

// Which calls:
k.sendAllBtcRewardsToGauge(ctx, del)

// Which iterates through all FPs associated with this delegator:
k.iterBtcDelegationsByDelegator(ctx, del, func(del, fp sdk.AccAddress) error {
    return k.btcDelegationModified(ctx, fp, del)
})

// This calls:
k.btcDelegationModifiedWithPreInitDel(ctx, fp, del, func(ctx context.Context, fp, del sdk.AccAddress) error { return nil })

// Which leads to:
endedPeriod, err := k.IncrementFinalityProviderPeriod(ctx, fp)

// And then:
k.CalculateBTCDelegationRewardsAndSendToGauge(ctx, fp, del, endedPeriod)

// Which calls:
rewards, err := k.CalculateBTCDelegationRewards(ctx, fp, del, endPeriod)

// Finally leading to the problematic calculation:
func (k Keeper) calculateDelegationRewardsBetween(
    ctx context.Context,
    fp sdk.AccAddress,
    btcDelRwdTracker types.BTCDelegationRewardsTracker,
    endingPeriod uint64,
) (sdk.Coins, error) {
    // ... get historical rewards ...
    
    // Calculate difference in rewards per satoshi
    differenceWithDecimals := ending.CumulativeRewardsPerSat.Sub(starting.CumulativeRewardsPerSat...)
    
    // HERE IS THE ISSUE:
    // It uses current stake (50,000) instead of correct historical stake (100,000)
    rewardsWithDecimals := differenceWithDecimals.MulInt(btcDelRwdTracker.TotalActiveSat)
    
    rewards := rewardsWithDecimals.QuoInt(types.DecimalAccumulatedRewards)
    return rewards, nil
}

Result: DelegatorA receives only 5 BBN in rewards instead of the 10 BBN they should have received based on their stake during Period 1, because the rewards were calculated using their current stake (50,000 sats) rather than their historical stake (100,000 sats).

Even worse, in a similar way if DelegatorA increases their stake, it will unfairly steal rewards of other delegators.

Mitigation

No response