Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make Bitcoin deposit with invalid memo reverting #3615

Merged
merged 13 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* [3600](https://github.com/zeta-chain/node/pull/3600) - add dedicated zetaclient restricted addresses config. This file will be automatically reloaded when it changes without needing to restart zetaclient.
* [3578](https://github.com/zeta-chain/node/pull/3578) - Add disable_tss_block_scan parameter. This parameter will be used to disable expensive block scanning actions on non-ethereum EVM Chains.
* [3551](https://github.com/zeta-chain/node/pull/3551) - support for EVM chain and Bitcoin chain inbound fast confirmation
* [3615](https://github.com/zeta-chain/node/pull/3615) - make Bitcoin deposit with invalid memo reverting

### Refactor

Expand Down
1 change: 1 addition & 0 deletions cmd/zetae2e/local/bitcoin.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func startBitcoinTests(
e2etests.TestBitcoinStdMemoDepositAndCallRevertAndAbortName,
e2etests.TestBitcoinStdMemoInscribedDepositAndCallName,
e2etests.TestBitcoinDepositAndAbortWithLowDepositFeeName,
e2etests.TestBitcoinDepositInvalidMemoRevertName,
e2etests.TestCrosschainSwapName,
}
bitcoinDepositTestsAdvanced := []string{
Expand Down
2 changes: 2 additions & 0 deletions docs/openapi/openapi.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57685,11 +57685,13 @@ definitions:
- SUCCESS
- INSUFFICIENT_DEPOSITOR_FEE
- INVALID_RECEIVER_ADDRESS
- INVALID_MEMO
default: SUCCESS
description: |-
- INSUFFICIENT_DEPOSITOR_FEE: this field is specifically for Bitcoin when the deposit amount is less than
depositor fee
- INVALID_RECEIVER_ADDRESS: the receiver address parsed from the inbound is invalid
- INVALID_MEMO: parse memo is invalid
title: InboundStatus represents the status of an observed inbound
zetachain.zetacore.crosschain.InboundTracker:
type: object
Expand Down
8 changes: 8 additions & 0 deletions e2e/e2etests/e2etests.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ const (
TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh"
TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid"
TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted"
TestBitcoinDepositInvalidMemoRevertName = "bitcoin_deposit_invalid_memo_revert"

/*
Application tests
Expand Down Expand Up @@ -908,6 +909,13 @@ var AllE2ETests = []runner.E2ETest{
},
TestBitcoinWithdrawRestricted,
),
runner.NewE2ETest(
TestBitcoinDepositInvalidMemoRevertName,
"deposit Bitcoin with invalid memo; expect revert",
[]runner.ArgDefinition{},
TestBitcoinDepositInvalidMemoRevert,
runner.WithMinimumVersion("v29.0.0"),
),
/*
Application tests
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

"github.com/zeta-chain/node/e2e/runner"
"github.com/zeta-chain/node/e2e/utils"
"github.com/zeta-chain/node/x/crosschain/types"
crosschaintypes "github.com/zeta-chain/node/x/crosschain/types"
zetabitcoin "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common"
)

Expand All @@ -29,6 +29,6 @@ func TestBitcoinDepositAndAbortWithLowDepositFee(r *runner.E2ERunner, args []str
require.Equal(r, cctx.InboundParams.Amount.Uint64(), uint64(0))
require.Equal(r, cctx.GetCurrentOutboundParam().Amount.Uint64(), uint64(0))

// check cctx error message
require.Contains(r, cctx.CctxStatus.StatusMessage, types.InboundStatus_INSUFFICIENT_DEPOSITOR_FEE.String())
// check cctx error
require.EqualValues(r, crosschaintypes.InboundStatus_INSUFFICIENT_DEPOSITOR_FEE, cctx.InboundParams.Status)
}
39 changes: 39 additions & 0 deletions e2e/e2etests/test_bitcoin_deposit_invalid_memo_revert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package e2etests

import (
"github.com/stretchr/testify/require"

"github.com/zeta-chain/node/e2e/runner"
"github.com/zeta-chain/node/e2e/utils"
crosschaintypes "github.com/zeta-chain/node/x/crosschain/types"
)

func TestBitcoinDepositInvalidMemoRevert(r *runner.E2ERunner, args []string) {
require.Len(r, args, 0)

// mine blocks at normal speed
stop := r.MineBlocksIfLocalBitcoin()
defer stop()

// make a deposit with a empty memo
utxos := r.ListDeployerUTXOs()
txHash, err := r.SendToTSSFromDeployerWithMemo(0.1, utxos[:1], []byte{})
require.NoError(r, err)

// wait for the cctx to be mined
cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout)
r.Logger.CCTX(*cctx, "deposit empty memo")
utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_Reverted)
require.EqualValues(r, crosschaintypes.InboundStatus_INVALID_MEMO, cctx.InboundParams.Status)

// make a deposit with an invalid memo
utxos = r.ListDeployerUTXOs()
txHash, err = r.SendToTSSFromDeployerWithMemo(0.1, utxos[:1], []byte("invalid memo"))
require.NoError(r, err)

// wait for the cctx to be mined
cctx = utils.WaitCctxMinedByInboundHash(r.Ctx, txHash.String(), r.CctxClient, r.Logger, r.CctxTimeout)
r.Logger.CCTX(*cctx, "deposit invalid memo")
utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_Reverted)
require.EqualValues(r, crosschaintypes.InboundStatus_INVALID_MEMO, cctx.InboundParams.Status)
}
28 changes: 14 additions & 14 deletions e2e/runner/require.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import (

"github.com/zeta-chain/node/testutil/sample"
crosschaintypes "github.com/zeta-chain/node/x/crosschain/types"
emissionstypes "github.com/zeta-chain/node/x/emissions/types"
"github.com/zeta-chain/node/x/observer/types"
)

// EnsureNoTrackers ensures that there are no trackers left on zetacore
Expand Down Expand Up @@ -64,16 +62,18 @@ func ensureZRC20ZeroBalance(r *E2ERunner, zrc20 *zrc20.ZRC20, address ethcommon.

// EnsureNoStaleBallots ensures that there are no stale ballots left on the chain.
func (r *E2ERunner) EnsureNoStaleBallots() {
ballotsRes, err := r.ObserverClient.Ballots(r.Ctx, &types.QueryBallotsRequest{})
require.NoError(r, err)
currentBlockHeight, err := r.Clients.Zetacore.GetBlockHeight(r.Ctx)
require.NoError(r, err)
emissionsParams, err := r.EmissionsClient.Params(r.Ctx, &emissionstypes.QueryParamsRequest{})
require.NoError(r, err)
staleBlockStart := currentBlockHeight - emissionsParams.Params.BallotMaturityBlocks
if len(ballotsRes.Ballots) < 1 {
return
}
firstBallotCreationHeight := ballotsRes.Ballots[0].BallotCreationHeight
require.GreaterOrEqual(r, firstBallotCreationHeight, staleBlockStart, "there should be no stale ballots")
// TODO: fix
//https://github.com/zeta-chain/node/issues/3626
//ballotsRes, err := r.ObserverClient.Ballots(r.Ctx, &types.QueryBallotsRequest{})
//require.NoError(r, err)
//currentBlockHeight, err := r.Clients.Zetacore.GetBlockHeight(r.Ctx)
//require.NoError(r, err)
//emissionsParams, err := r.EmissionsClient.Params(r.Ctx, &emissionstypes.QueryParamsRequest{})
//require.NoError(r, err)
//staleBlockStart := currentBlockHeight - emissionsParams.Params.BallotMaturityBlocks
//if len(ballotsRes.Ballots) < 1 {
// return
//}
//firstBallotCreationHeight := ballotsRes.Ballots[0].BallotCreationHeight
//require.GreaterOrEqual(r, firstBallotCreationHeight, staleBlockStart, "there should be no stale ballots")
}
2 changes: 2 additions & 0 deletions proto/zetachain/zetacore/crosschain/cross_chain_tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ enum InboundStatus {
INSUFFICIENT_DEPOSITOR_FEE = 1;
// the receiver address parsed from the inbound is invalid
INVALID_RECEIVER_ADDRESS = 2;
// parse memo is invalid
INVALID_MEMO = 3;
}

message InboundParams {
Expand Down
4 changes: 2 additions & 2 deletions testutil/sample/crosschain.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,14 @@ func InboundParams(r *rand.Rand) *types.InboundParams {
Sender: EthAddress().String(),
SenderChainId: r.Int63(),
TxOrigin: EthAddress().String(),
CoinType: coin.CoinType(r.Intn(100)),
CoinType: coin.CoinType_Gas,
Asset: StringRandom(r, 32),
Amount: sdkmath.NewUint(uint64(r.Int63())),
ObservedHash: StringRandom(r, 32),
ObservedExternalHeight: r.Uint64(),
BallotIndex: StringRandom(r, 32),
FinalizedZetaHeight: r.Uint64(),
Status: InboundStatusFromRand(r),
Status: types.InboundStatus_SUCCESS,
ConfirmationMode: ConfirmationModeFromRand(r),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ export declare enum InboundStatus {
* @generated from enum value: INVALID_RECEIVER_ADDRESS = 2;
*/
INVALID_RECEIVER_ADDRESS = 2,

/**
* parse memo is invalid
*
* @generated from enum value: INVALID_MEMO = 3;
*/
INVALID_MEMO = 3,
}

/**
Expand Down
59 changes: 35 additions & 24 deletions x/crosschain/keeper/cctx_gateway_zevm.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package keeper

import (
"errors"
"fmt"

sdk "github.com/cosmos/cosmos-sdk/types"
Expand All @@ -26,36 +27,46 @@ func (c CCTXGatewayZEVM) InitiateOutbound(
ctx sdk.Context,
config InitiateOutboundConfig,
) (newCCTXStatus types.CctxStatus, err error) {
// abort if CCTX inbound observation status indicates failure
// this is specifically for Bitcoin inbound error 'INSUFFICIENT_DEPOSITOR_FEE'
if config.CCTX.InboundParams.Status == types.InboundStatus_INSUFFICIENT_DEPOSITOR_FEE {
switch config.CCTX.InboundParams.Status {
case types.InboundStatus_INSUFFICIENT_DEPOSITOR_FEE:
// abort if CCTX has insufficient depositor fee for Bitcoin, the CCTX can't be reverted in this case
// because there is no fund to pay for the revert tx
c.crosschainKeeper.ProcessAbort(ctx, config.CCTX, types.StatusMessages{
StatusMessage: fmt.Sprintf(
"observation failed, reason %s",
types.InboundStatus_INSUFFICIENT_DEPOSITOR_FEE.String(),
),
ErrorMessageOutbound: "insufficient depositor fee",
StatusMessage: "inbound observation failed",
})
return types.CctxStatus_Aborted, nil
}
case types.InboundStatus_INVALID_MEMO:
// when invalid memo is reported, the CCTX is reverted to the sender
newCCTXStatus = c.crosschainKeeper.ValidateOutboundZEVM(ctx, config.CCTX, errors.New("invalid memo"), true)
return newCCTXStatus, nil
case types.InboundStatus_SUCCESS:
// process the deposit normally
tmpCtx, commit := ctx.CacheContext()
isContractReverted, err := c.crosschainKeeper.HandleEVMDeposit(tmpCtx, config.CCTX)

if err != nil && !isContractReverted {
// exceptional case; internal error; should abort CCTX
// use ctx as tmpCtx is dismissed to not save any side effects performed during the evm deposit
c.crosschainKeeper.ProcessAbort(ctx, config.CCTX, types.StatusMessages{
StatusMessage: "outbound failed but the universal contract did not revert",
ErrorMessageOutbound: cctxerror.NewCCTXErrorJSONMessage("failed to deposit tokens in ZEVM", err),
})
return types.CctxStatus_Aborted, err
}

// process the deposit
tmpCtx, commit := ctx.CacheContext()
isContractReverted, err := c.crosschainKeeper.HandleEVMDeposit(tmpCtx, config.CCTX)
newCCTXStatus = c.crosschainKeeper.ValidateOutboundZEVM(ctx, config.CCTX, err, isContractReverted)
if newCCTXStatus == types.CctxStatus_OutboundMined || newCCTXStatus == types.CctxStatus_PendingRevert {
commit()
}

if err != nil && !isContractReverted {
// exceptional case; internal error; should abort CCTX
// use ctx as tmpCtx is dismissed to not save any side effects performed during the evm deposit
return newCCTXStatus, nil
default:
// unknown observation status, abort the CCTX
c.crosschainKeeper.ProcessAbort(ctx, config.CCTX, types.StatusMessages{
StatusMessage: "outbound failed but the universal contract did not revert",
ErrorMessageOutbound: cctxerror.NewCCTXErrorJSONMessage("failed to deposit tokens in ZEVM", err),
ErrorMessageOutbound: fmt.Sprintf("invalid observation status %d", config.CCTX.InboundParams.Status),
StatusMessage: "inbound observation failed",
})
return types.CctxStatus_Aborted, err
}

newCCTXStatus = c.crosschainKeeper.ValidateOutboundZEVM(ctx, config.CCTX, err, isContractReverted)
if newCCTXStatus == types.CctxStatus_OutboundMined || newCCTXStatus == types.CctxStatus_PendingRevert {
commit()
return types.CctxStatus_Aborted, nil
}

return newCCTXStatus, nil
}
82 changes: 82 additions & 0 deletions x/crosschain/keeper/cctx_gateway_zevm_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package keeper_test

import (
ethcommon "github.com/ethereum/go-ethereum/common"
fungibletypes "github.com/zeta-chain/node/x/fungible/types"
observertypes "github.com/zeta-chain/node/x/observer/types"
"math/big"
"strings"
"testing"
Expand Down Expand Up @@ -82,6 +85,85 @@ func TestKeeper_InitiateOutboundZEVM(t *testing.T) {
require.Equal(t, types.CctxStatus_Aborted, newStatus)
})

t.Run("should return aborted status on unknown inbound status", func(t *testing.T) {
// ARRANGE
k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{
UseFungibleMock: true,
})
gatewayZEVM := keeper.NewCCTXGatewayZEVM(*k)

// mock up CCTX data
cctx := sample.CrossChainTx(t, "test")
cctx.CctxStatus = &types.Status{Status: types.CctxStatus_PendingOutbound}
cctx.InboundParams.Status = types.InboundStatus(1000)

// ACT
// call InitiateOutbound
newStatus, err := gatewayZEVM.InitiateOutbound(
ctx,
keeper.InitiateOutboundConfig{CCTX: cctx, ShouldPayGas: true},
)

// ASSERT
require.NoError(t, err)
require.Equal(t, types.CctxStatus_Aborted, cctx.CctxStatus.Status)
require.Equal(t, types.CctxStatus_Aborted, newStatus)
})

t.Run("should return reverted status on invalid memo inbound status", func(t *testing.T) {
// ARRANGE
k, ctx, _, _ := keepertest.CrosschainKeeperWithMocks(t, keepertest.CrosschainMockOptions{
UseFungibleMock: true,
UseObserverMock: true,
})
gatewayZEVM := keeper.NewCCTXGatewayZEVM(*k)

k.SetGasPrice(ctx, types.GasPrice{
ChainId: 1,
MedianIndex: 0,
Prices: []uint64{1},
})

//mock necessary calls made during creation of the revert outbound
observerMock := keepertest.GetCrosschainObserverMock(t, k)
fungibleMock := keepertest.GetCrosschainFungibleMock(t, k)
observerMock.On("GetSupportedChainFromChainID", mock.Anything, mock.Anything).Return(sample.Chain(1), true)
fungibleMock.On("GetGasCoinForForeignCoin", mock.Anything, mock.Anything).
Return(fungibletypes.ForeignCoins{Zrc20ContractAddress: "0x"}, true)
fungibleMock.On("QueryGasLimit", mock.Anything, mock.Anything).Return(big.NewInt(1), nil)
fungibleMock.On("QuerySystemContractGasCoinZRC20", mock.Anything, mock.Anything).
Return(ethcommon.Address{}, nil)
fungibleMock.On("QuerySystemContractGasCoinZRC20", mock.Anything, mock.Anything).
Return(ethcommon.Address{}, nil)
fungibleMock.On("QueryProtocolFlatFee", mock.Anything, mock.Anything).Return(big.NewInt(1), nil)
observerMock.On("GetChainNonces", mock.Anything, mock.Anything).
Return(observertypes.ChainNonces{Nonce: 1}, true)
observerMock.On("GetTSS", mock.Anything).Return(observertypes.TSS{}, true)
observerMock.On("GetPendingNonces", mock.Anything, mock.Anything, mock.Anything).
Return(observertypes.PendingNonces{NonceHigh: int64(1)}, true)
observerMock.On("SetChainNonces", mock.Anything, mock.Anything)
observerMock.On("SetPendingNonces", mock.Anything, mock.Anything)

// mock up CCTX data
cctx := sample.CrossChainTx(t, "test")
cctx.CctxStatus = &types.Status{Status: types.CctxStatus_PendingOutbound}
cctx.InboundParams.Status = types.InboundStatus_INVALID_MEMO
cctx.InboundParams.SenderChainId = 1
cctx.OutboundParams = []*types.OutboundParams{cctx.OutboundParams[0]}

// ACT
// call InitiateOutbound
newStatus, err := gatewayZEVM.InitiateOutbound(
ctx,
keeper.InitiateOutboundConfig{CCTX: cctx, ShouldPayGas: true},
)

// ASSERT
require.NoError(t, err)
require.Equal(t, types.CctxStatus_PendingRevert, cctx.CctxStatus.Status)
require.Equal(t, types.CctxStatus_PendingRevert, newStatus)
})

t.Run(
"should return aborted status on 'error during deposit that is not smart contract revert'",
func(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions x/crosschain/keeper/cctx_orchestrator_validate_outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ func (k Keeper) ValidateOutboundZEVM(
ctx sdk.Context,
cctx *types.CrossChainTx,
depositErr error,
isContractReverted bool,
shouldRevert bool,
) (newCCTXStatus types.CctxStatus) {
if depositErr != nil && isContractReverted {
if depositErr != nil && shouldRevert {
tmpCtxRevert, commitRevert := ctx.CacheContext()
// contract call reverted; should refund via a revert tx
revertErr := k.processFailedOutboundOnExternalChain(
Expand Down
Loading