diff --git a/e2e/e2etests/legacy/test_zeta_withdraw.go b/e2e/e2etests/legacy/test_zeta_withdraw.go index 2a31326491..441ef87a6c 100644 --- a/e2e/e2etests/legacy/test_zeta_withdraw.go +++ b/e2e/e2etests/legacy/test_zeta_withdraw.go @@ -6,6 +6,7 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" ) func TestZetaWithdraw(r *runner.E2ERunner, args []string) { @@ -14,10 +15,23 @@ func TestZetaWithdraw(r *runner.E2ERunner, args []string) { // parse withdraw amount amount := utils.ParseBigInt(r, args[0]) + evmChainID, err := r.EVMClient.ChainID(r.Ctx) + require.NoError(r, err) + r.LegacyDepositAndApproveWZeta(amount) tx := r.LegacyWithdrawZeta(amount, true) cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) r.Logger.CCTX(*cctx, "zeta withdraw") utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // Get chain params for stability pool percentage + chainParams, err := r.ObserverClient.GetChainParamsForChain( + r.Ctx, + &observertypes.QueryGetChainParamsForChainRequest{ChainId: evmChainID.Int64()}, + ) + require.NoError(r, err) + + // Verify gas accounting and log refund amounts + utils.VerifyOutboundGasAccounting(r, cctx, chainParams.ChainParams.StabilityPoolPercentage, r.Logger) } diff --git a/e2e/e2etests/test_erc20_withdraw.go b/e2e/e2etests/test_erc20_withdraw.go index b6a577891d..cb8a450389 100644 --- a/e2e/e2etests/test_erc20_withdraw.go +++ b/e2e/e2etests/test_erc20_withdraw.go @@ -9,6 +9,7 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" ) func TestERC20Withdraw(r *runner.E2ERunner, args []string) { @@ -16,6 +17,9 @@ func TestERC20Withdraw(r *runner.E2ERunner, args []string) { amount := utils.ParseBigInt(r, args[0]) + evmChainID, err := r.EVMClient.ChainID(r.Ctx) + require.NoError(r, err) + r.ApproveERC20ZRC20(r.GatewayZEVMAddr) r.ApproveETHZRC20(r.GatewayZEVMAddr) @@ -26,4 +30,14 @@ func TestERC20Withdraw(r *runner.E2ERunner, args []string) { cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) r.Logger.CCTX(*cctx, "withdraw") utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // Get chain params for stability pool percentage + chainParams, err := r.ObserverClient.GetChainParamsForChain( + r.Ctx, + &observertypes.QueryGetChainParamsForChainRequest{ChainId: evmChainID.Int64()}, + ) + require.NoError(r, err) + + // Verify gas accounting and log refund amounts + utils.VerifyOutboundGasAccounting(r, cctx, chainParams.ChainParams.StabilityPoolPercentage, r.Logger) } diff --git a/e2e/e2etests/test_eth_withdraw.go b/e2e/e2etests/test_eth_withdraw.go index bc8cefb44a..999ca160a5 100644 --- a/e2e/e2etests/test_eth_withdraw.go +++ b/e2e/e2etests/test_eth_withdraw.go @@ -9,6 +9,7 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" ) func TestETHWithdraw(r *runner.E2ERunner, args []string) { @@ -16,6 +17,9 @@ func TestETHWithdraw(r *runner.E2ERunner, args []string) { amount := utils.ParseBigInt(r, args[0]) + evmChainID, err := r.EVMClient.ChainID(r.Ctx) + require.NoError(r, err) + oldBalance, err := r.EVMClient.BalanceAt(r.Ctx, r.EVMAddress(), nil) require.NoError(r, err) @@ -29,6 +33,16 @@ func TestETHWithdraw(r *runner.E2ERunner, args []string) { r.Logger.CCTX(*cctx, "withdraw") utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + // Get chain params for stability pool percentage + chainParams, err := r.ObserverClient.GetChainParamsForChain( + r.Ctx, + &observertypes.QueryGetChainParamsForChainRequest{ChainId: evmChainID.Int64()}, + ) + require.NoError(r, err) + + // Verify gas accounting and log refund amounts + utils.VerifyOutboundGasAccounting(r, cctx, chainParams.ChainParams.StabilityPoolPercentage, r.Logger) + // check the balance was updated, we just check newBalance is greater than oldBalance because of the gas fee newBalance, err := r.EVMClient.BalanceAt(r.Ctx, r.EVMAddress(), nil) require.NoError(r, err) diff --git a/e2e/e2etests/test_zeta_withdraw.go b/e2e/e2etests/test_zeta_withdraw.go index 5059a0c14f..2687a174fa 100644 --- a/e2e/e2etests/test_zeta_withdraw.go +++ b/e2e/e2etests/test_zeta_withdraw.go @@ -9,6 +9,7 @@ import ( "github.com/zeta-chain/node/e2e/runner" "github.com/zeta-chain/node/e2e/utils" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" ) func TestZetaWithdraw(r *runner.E2ERunner, args []string) { @@ -29,6 +30,16 @@ func TestZetaWithdraw(r *runner.E2ERunner, args []string) { cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) r.Logger.CCTX(*cctx, "zeta_withdraw") utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // Get chain params for stability pool percentage + chainParams, err := r.ObserverClient.GetChainParamsForChain( + r.Ctx, + &observertypes.QueryGetChainParamsForChainRequest{ChainId: evmChainID.Int64()}, + ) + require.NoError(r, err) + + // Verify gas accounting and log refund amounts + utils.VerifyOutboundGasAccounting(r, cctx, chainParams.ChainParams.StabilityPoolPercentage, r.Logger) } else { // V2 ZETA flows disabled: tx should revert on GatewayZEVM, no CCTX created utils.EnsureNoCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient) diff --git a/e2e/utils/zetacore.go b/e2e/utils/zetacore.go index 1897f408a6..25413a6c71 100644 --- a/e2e/utils/zetacore.go +++ b/e2e/utils/zetacore.go @@ -6,6 +6,7 @@ import ( "math/big" "time" + "cosmossdk.io/math" rpchttp "github.com/cometbft/cometbft/rpc/client/http" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -17,6 +18,7 @@ import ( "google.golang.org/grpc/status" "github.com/zeta-chain/node/pkg/constant" + crosschainkeeper "github.com/zeta-chain/node/x/crosschain/keeper" crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) @@ -498,3 +500,49 @@ func WaitAndVerifyZRC20BalanceChange( return } } + +// VerifyOutboundGasAccounting verifies the gas accounting for an outbound CCTX. +// It asserts that UserGasFeePaid equals GasLimit * GasPrice (gas token denomination), +// and logs the calculated refund amounts (stability pool and user refund). +// stabilityPoolPercentage should be obtained from chain params (e.g., chainParams.ChainParams.StabilityPoolPercentage) +func VerifyOutboundGasAccounting( + t require.TestingT, + cctx *crosschaintypes.CrossChainTx, + stabilityPoolPercentage uint64, + logger infoLogger, +) { + outboundParams := cctx.GetCurrentOutboundParam() + + // Verify UserGasFeePaid == GasLimit * GasPrice (gas token denomination) + gasLimit := ParseUint(t, fmt.Sprintf("%d", outboundParams.CallOptions.GasLimit)) + gasPrice := ParseUint(t, outboundParams.GasPrice) + expectedUserGasFeePaid := gasLimit.Mul(gasPrice) + require.Equal( + t, + expectedUserGasFeePaid.String(), + outboundParams.UserGasFeePaid.String(), + "UserGasFeePaid should equal GasLimit * GasPrice (gas token denomination)", + ) + + outboundTxFeePaid := math.NewUint(outboundParams.GasUsed). + Mul(math.NewUintFromBigInt(outboundParams.EffectiveGasPrice.BigInt())) + userGasFeePaid := outboundParams.UserGasFeePaid + + logger.Info("Gas accounting - UserGasFeePaid: %s, OutboundTxFeePaid: %s", + userGasFeePaid.String(), outboundTxFeePaid.String()) + + if outboundTxFeePaid.GTE(userGasFeePaid) { + logger.Info("No remaining fees to refund (outbound used all or more gas than paid)") + return + } + + // Calculate refund amounts + totalRemainingFees := userGasFeePaid.Sub(outboundTxFeePaid) + usableRemainingFees := crosschainkeeper.PercentOf(totalRemainingFees, crosschaintypes.UsableRemainingFeesPercentage) + stabilityPoolAmount := crosschainkeeper.PercentOf(usableRemainingFees, stabilityPoolPercentage) + userRefundAmount := usableRemainingFees.Sub(stabilityPoolAmount) + + logger.Info("Gas refund - TotalRemaining: %s, UsableRemaining: %s, StabilityPool: %s, UserRefund: %s", + totalRemainingFees.String(), usableRemainingFees.String(), + stabilityPoolAmount.String(), userRefundAmount.String()) +} diff --git a/x/crosschain/keeper/gas_payment.go b/x/crosschain/keeper/gas_payment.go index c046ce5d00..57ee6fc9a6 100644 --- a/x/crosschain/keeper/gas_payment.go +++ b/x/crosschain/keeper/gas_payment.go @@ -457,8 +457,8 @@ func (k Keeper) PayGasInZetaAndUpdateCctx( cctx.ZetaFees = cctx.ZetaFees.Add(feeInZeta) } - // zeta token paid by the user is swapped for gas ZRC20 and burned to pay for fee. - cctx.GetCurrentOutboundParam().UserGasFeePaid = sdkmath.NewUintFromBigInt(outTxGasFeeInZeta) + // Gas fee paid by the user in gas token + cctx.GetCurrentOutboundParam().UserGasFeePaid = outTxGasFee return nil }