Assessment reports>ZetaChain>Discussion>Review of changes

Review of changes

In this section, we summarize all the changes that were made to the codebase to remediate our findings from the previous audit.

3.1 --- Any ZetaSent events are processed regardless of what contract emits them

This finding was fixed by introducing the ConnectorZEVM contract's address into the SystemContract structure. This address is now used in the EVM hooks to ensure that ZetaSent events are only processed if they are emitted by the ConnectorZEVM contract.

All nonrelevant code has been omitted from the excerpt below.

func (k Keeper) ProcessLogs(ctx sdk.Context, logs []*ethtypes.Log, emittingContract ethcommon.Address, txOrigin string) error {

	system, found := k.fungibleKeeper.GetSystemContract(ctx)
	connectorZEVMAddr := ethcommon.HexToAddress(system.ConnectorZevm) // Zellic: new ConnectorZEVM address

	for _, log := range logs {
		eZeta, err := ParseZetaSentEvent(*log, connectorZEVMAddr)
		if err == nil {
			if err := k.ProcessZetaSentEvent(ctx, eZeta, emittingContract, txOrigin); err != nil {
				return err
			}
		}
	}
	return nil
}

func ParseZetaSentEvent(log ethtypes.Log, connectorZEVM ethcommon.Address) (*connectorzevm.ZetaConnectorZEVMZetaSent, error) {
	// [ ... ]

    // Zellic: new check to ensure events are emitted by this contract
	if event.Raw.Address != connectorZEVM {
		return nil, fmt.Errorf("ParseZetaSentEvent: event address %s does not match connectorZEVM %s", event.Raw.Address.Hex(), connectorZEVM.Hex())
	}
	return event, nil
}

3.2 --- Bonded validators can trigger reverts for successful transactions

This finding was fixed by introducing a new Admin Policy account type. With this fix, although outgoing transactions can still be removed from the tracker, this is only allowed if the message creator is the Admin Policy account. Previously, any bonded validators could perform this action.

func (k msgServer) RemoveFromOutTxTracker(goCtx context.Context, msg *types.MsgRemoveFromOutTxTracker) (*types.MsgRemoveFromOutTxTrackerResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

    // Zellic: new check added to ensure only the Admin Policy account can remove transactions from the out TX tracker
	if msg.Creator != k.zetaObserverKeeper.GetParams(ctx).GetAdminPolicyAccount
	(zetaObserverTypes.Policy_Type_out_tx_tracker) {
		return &types.MsgRemoveFromOutTxTrackerResponse{}, zetaObserverTypes.ErrNotAuthorizedPolicy
	}

	k.RemoveOutTxTracker(ctx, msg.ChainId, msg.Nonce)
	return &types.MsgRemoveFromOutTxTrackerResponse{}, nil
}

3.3 --- Sending ZETA to a Bitcoin network results in BTC being sent instead

This finding was fixed by introducing a new check in the ProcessZetaSentEvent() function that checks to ensure that any external chains ZETA is being sent to has a ZETA-token contract deployed on it. Since the Bitcoin chain does not implement smart contracts, this check will prevent ZETA from being sent to the Bitcoin chain (and any other non--EVM-compatible chains).

func (k Keeper) ProcessZetaSentEvent(ctx sdk.Context, event *connectorzevm.ZetaConnectorZEVMZetaSent, emittingContract ethcommon.Address, txOrigin string) error {
	// [ ... ]

	// Validation if we want to send ZETA to external chain, but there is no ZETA token.
	coreParams, found := k.zetaObserverKeeper.GetCoreParamsByChainID(ctx, receiverChain.ChainId)
	if !found {
		return zetacoretypes.ErrNotFoundCoreParams
	}
	if receiverChain.IsExternalChain() && coreParams.ZetaTokenContractAddress == "" {
		return zetacoretypes.ErrUnableToSendCoinType
	}

    // [ ... ]
}

3.4 --- Race condition in Bitcoin client leads to double spend

This finding was fixed by introducing a new map to the Bitcoin client that now tracks all transaction hashes of successfully broadcasted transactions to the Bitcoin chain. This prevents the same transaction from being broadcasted twice, thus preventing the double spend.

// returns isIncluded, isConfirmed, Error
func (ob *BitcoinChainClient) IsSendOutTxProcessed(sendHash string, nonce int, _ common.CoinType, logger zerolog.Logger) (bool, bool, error) {
	outTxID := ob.GetTxID(uint64(nonce))
	logger.Info().Msgf("IsSendOutTxProcessed %s", outTxID)

	ob.mu.Lock()
	txnHash, broadcasted := ob.broadcastedTx[outTxID]
	res, mined := ob.minedTx[outTxID]
	ob.mu.Unlock()

	if !mined {
		if !broadcasted {
			return false, false, nil
		}
		
        // [ ... ]
	}
	
    // [ ... ]
    return true, true, nil
}

3.5 --- Not waiting for minimum number of block confirmations results in double spend

This finding was fixed by waiting for a configured amount of blocks before considering a block confirmed. In the testing environment, the Bitcoin client now waits for at least two blocks before considering a block as confirmed. On average, each block takes 10 minutes to be mined, so this would be 20 minutes for a block to be considered confirmed.

func GetCoreParams() CoreParamsList {
	params := CoreParamsList{
		CoreParams: []*CoreParams{
			// [ ... ]
			{
				ChainId:                     common.BtcRegtestChain().ChainId,
				ConfirmationCount:           2,
				// [ ... ]
			}},
	}
    
	// [ ... ]
}

func (ob *BitcoinChainClient) observeInTx() error {
	// [ ... ]

	// "confirmed" current block number
	confirmedBlockNum := cnt - int64(ob.GetChainConfig().CoreParams.ConfCount)
	if confirmedBlockNum < 0 || confirmedBlockNum > math2.MaxInt64 {
		return fmt.Errorf("skipping observer , confirmedBlockNum is negative or too large ")
	}

	// query incoming gas asset
	if confirmedBlockNum > lastBN {
		// [ ... ]
	}
    // [ ... ]
}

3.6 --- Multiple events in the same transaction causes loss of funds and chain halting

This finding was fixed by introducing an index to the emitted ZetaSent event. This is then used within the x/crosschain module to distinguish between multiple ZetaSent events within the same transaction. The ZetaChain team decided to add this index onto the gasLimit for each cross-chain transaction, which effectively turns the gasLimit into a unique identifier for each emitted event. Their reasoning behind this approach (as opposed to just adding the index to the MsgVoteOnObservedInboundTx message) was to not unnecessarily add another parameter to the MsgVoteOnObservedInboundTx message just for the purpose of computing a unique hash digest.

func (k Keeper) ProcessZetaSentEvent(ctx sdk.Context, event *connectorzevm.ZetaConnectorZEVMZetaSent, emittingContract ethcommon.Address, txOrigin string) error {
	// [ ... ]

	// Bump gasLimit by event index (which is very unlikely to be larger than 1000) to always have different ZetaSent events msgs.
	msg := zetacoretypes.NewMsgSendVoter("", emittingContract.Hex(), senderChain.ChainId, txOrigin, toAddr, receiverChain.ChainId, amount, "", event.Raw.TxHash.String(), event.Raw.BlockNumber, 90000+uint64(event.Raw.Index), common.CoinType_Zeta, "")

	// [ ... ]
}

3.7 --- Missing authentication when adding node keys

This finding was fixed by removing the public SetNodeKeys message handler completely. This prevents anyone from supplying a public key to be used for TSS signing.

3.8 --- Missing nil check when parsing client event

This finding was not fixed. See finding of this report for more details.

3.9 --- Case-sensitive address check allows for double signing

This finding was fixed by completely removing the IsDuplicateSigner() function that was responsible for the case-sensitive address checking.

3.10 --- No panic handler in Zetaclient may halt cross-chain communication

This finding was fixed by adding panic handlers to the Bitcoin client's fetchUTXOs() and TryProcessOutTx() functions.

func (ob *BitcoinChainClient) fetchUTXOS() error {
	defer func() {
		if err := recover(); err != nil {
			ob.logger.WatchUTXOS.Error().Msgf("BTC fetchUTXOS: caught panic error: %v", err)
		}
	}()

    // [ ... ]
}

func (signer *BTCSigner) TryProcessOutTx(send *types.CrossChainTx, outTxMan *OutTxProcessorManager, outTxID string, chainclient ChainClient, zetaBridge *ZetaCoreBridge, height uint64) {
	defer func() {
		if err := recover(); err != nil {
			signer.logger.Error().Msgf("BTC TryProcessOutTx: %s, caught panic error: %v", send.Index, err)
		}
	}()

    // [ ... ]
}

3.11 --- Ethermint Ante handler bypass

This finding was partially fixed, but another bypass was found due to the inclusion of the x/group module. See finding of this report for more details.

3.12 --- Unbonded validators prevent the TSS vote from passing

This finding was fixed by refactoring the CreateTSSVoter() function. It now only allows authorized node accounts (which are created in genesis) to vote on creating a new TSS key.

3.13 --- Code maturity

This finding was acknowledged and partially fixed by removing the commented out code. The remaining FIXMEs and TODOs will continue to be worked on to improve the maturity of the code.

Zellic © 2025Back to top ↑