diff --git a/changelog.md b/changelog.md index b3bf6789a0..9df2e9ad9d 100644 --- a/changelog.md +++ b/changelog.md @@ -17,11 +17,16 @@ * [4548](https://github.com/zeta-chain/node/pull/4548) - check grantee for IsSystemTx * [4550](https://github.com/zeta-chain/node/pull/4550) - add nil guards for Solana tx metadata and use safe signature parsing in inbound tracker * [4557](https://github.com/zeta-chain/node/pull/4557) - reject Solana transactions with nil metadata instead of treating as successful +* [4560](https://github.com/zeta-chain/node/pull/4560) - update get tss address query ### Tests * [4539](https://github.com/zeta-chain/node/pull/4539) - add support for `signet` name in the e2e config +### Features + +* [4559](https://github.com/zeta-chain/node/pull/4559) - add `tss-number` flag to `tss-balances` command and add a new commands `list-observers`,`track-tss-migration` to zetatools + ## Release ReForge - zetacored: v37.0.0 - zetaclientd: v38.0.0 diff --git a/cmd/zetatool/cli/db_stats.go b/cmd/zetatool/cli/db_stats.go index 4ea0e4ddaf..46b40d6acc 100644 --- a/cmd/zetatool/cli/db_stats.go +++ b/cmd/zetatool/cli/db_stats.go @@ -183,8 +183,7 @@ func displayStats(stats *databaseStats, format OutputFormat) error { // displayTable renders the statistics in a formatted table func displayTable(stats *databaseStats) { - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) + t := newTableWriter() t.AppendHeader( table.Row{"Module", "Avg Key Size", "Avg Value Size", "Total Key Size", "Total Value Size", "Total Key Pairs"}, ) diff --git a/cmd/zetatool/cli/list_chains.go b/cmd/zetatool/cli/list_chains.go index 45980ba8fc..a123a10e6b 100644 --- a/cmd/zetatool/cli/list_chains.go +++ b/cmd/zetatool/cli/list_chains.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "os" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" @@ -84,8 +83,7 @@ func listChains(cmd *cobra.Command, args []string) error { func printChainList(chainList []chains.Chain) { fmt.Println() - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) + t := newTableWriter() t.AppendHeader(table.Row{"Chain ID", "Name", "Network", "VM", "External"}) for _, c := range chainList { diff --git a/cmd/zetatool/cli/list_observers.go b/cmd/zetatool/cli/list_observers.go new file mode 100644 index 0000000000..67d682de7d --- /dev/null +++ b/cmd/zetatool/cli/list_observers.go @@ -0,0 +1,161 @@ +package cli + +import ( + "context" + "fmt" + "sync" + + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + zetatoolcommon "github.com/zeta-chain/node/cmd/zetatool/common" + "github.com/zeta-chain/node/cmd/zetatool/config" + "github.com/zeta-chain/node/pkg/rpc" + observertypes "github.com/zeta-chain/node/x/observer/types" +) + +// observerInfo holds observer address and resolved validator moniker +type observerInfo struct { + ObserverAddress string + OperatorAddress string + Moniker string + Error string +} + +// NewListObserversCMD creates a command to list all observers with their validator monikers +func NewListObserversCMD() *cobra.Command { + return &cobra.Command{ + Use: "list-observers ", + Short: "List observers with their validator monikers", + Long: `List all active observers and resolve their validator monikers from the staking module. + +The chain argument can be: + - A chain ID (e.g., 7000, 7001) + - A chain name (e.g., zeta_mainnet, zeta_testnet) + +The network type (mainnet/testnet/etc) is inferred from the chain. + +Examples: + zetatool list-observers 7000 + zetatool list-observers zeta_mainnet + zetatool list-observers zeta_testnet --config custom_config.json`, + Args: cobra.ExactArgs(1), + RunE: listObservers, + } +} + +func listObservers(cmd *cobra.Command, args []string) error { + chain, err := zetatoolcommon.ResolveChain(args[0]) + if err != nil { + return fmt.Errorf("failed to resolve chain %q: %w", args[0], err) + } + + network := zetatoolcommon.NetworkTypeFromChain(chain) + + configFile, err := cmd.Flags().GetString(config.FlagConfig) + if err != nil { + return fmt.Errorf("failed to read value for flag %s: %w", config.FlagConfig, err) + } + + cfg, err := config.GetConfigByNetwork(network, configFile) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + if cfg.ZetaChainRPC == "" { + return fmt.Errorf("ZetaChainRPC is not configured for network %s", network) + } + + zetacoreClient, err := rpc.NewCometBFTClients(cfg.ZetaChainRPC) + if err != nil { + return fmt.Errorf("failed to create zetacore client: %w", err) + } + + ctx := context.Background() + + // Fetch the observer set + observerSetRes, err := zetacoreClient.Observer.ObserverSet(ctx, &observertypes.QueryObserverSet{}) + if err != nil { + return fmt.Errorf("failed to fetch observer set: %w", err) + } + + if len(observerSetRes.Observers) == 0 { + fmt.Println("No observers found") + return nil + } + + // Resolve monikers concurrently + var wg sync.WaitGroup + results := make(chan observerInfo, len(observerSetRes.Observers)) + + for _, obs := range observerSetRes.Observers { + wg.Add(1) + go func(observerAddr string) { + defer wg.Done() + results <- resolveObserverMoniker(ctx, zetacoreClient.Staking, observerAddr) + }(obs) + } + + go func() { + wg.Wait() + close(results) + }() + + infos := make([]observerInfo, 0, len(observerSetRes.Observers)) + for info := range results { + infos = append(infos, info) + } + + printObserverTable(infos) + return nil +} + +// resolveObserverMoniker converts an observer address to a validator operator address +// and queries the staking module for the validator's moniker. +func resolveObserverMoniker( + ctx context.Context, + stakingClient stakingtypes.QueryClient, + observerAddr string, +) observerInfo { + info := observerInfo{ObserverAddress: observerAddr} + + // Convert observer address (zeta1xxx) to validator operator address (zetavaloper1xxx) + accAddr, err := sdk.AccAddressFromBech32(observerAddr) + if err != nil { + info.Error = fmt.Sprintf("invalid address: %v", err) + return info + } + valAddr := sdk.ValAddress(accAddr.Bytes()) + info.OperatorAddress = valAddr.String() + + // Query the staking module for this validator + valRes, err := stakingClient.Validator(ctx, &stakingtypes.QueryValidatorRequest{ + ValidatorAddr: info.OperatorAddress, + }) + if err != nil { + info.Error = fmt.Sprintf("validator query failed: %v", err) + return info + } + + info.Moniker = valRes.Validator.Description.Moniker + return info +} + +func printObserverTable(infos []observerInfo) { + t := newTableWriter() + t.AppendHeader(table.Row{"#", "Observer Address", "Moniker"}) + + for i, info := range infos { + moniker := info.Moniker + if info.Error != "" { + moniker = info.Error + } + + t.AppendRow(table.Row{i + 1, info.ObserverAddress, moniker}) + } + + fmt.Println() + t.Render() +} diff --git a/cmd/zetatool/cli/table.go b/cmd/zetatool/cli/table.go new file mode 100644 index 0000000000..4dbb6af06d --- /dev/null +++ b/cmd/zetatool/cli/table.go @@ -0,0 +1,21 @@ +package cli + +import ( + "os" + + "github.com/jedib0t/go-pretty/v6/table" + "golang.org/x/term" +) + +// newTableWriter creates a table.Writer configured to fit the terminal width. +// If the terminal width cannot be detected, no width limit is applied. +func newTableWriter() table.Writer { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + t.Style().Size.WidthMax = width + } + + return t +} diff --git a/cmd/zetatool/cli/track_tss_migration.go b/cmd/zetatool/cli/track_tss_migration.go new file mode 100644 index 0000000000..c3d81191d2 --- /dev/null +++ b/cmd/zetatool/cli/track_tss_migration.go @@ -0,0 +1,268 @@ +package cli + +import ( + "context" + "fmt" + "sync" + + ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + "github.com/zeta-chain/node/cmd/zetatool/clients" + zetatoolcommon "github.com/zeta-chain/node/cmd/zetatool/common" + "github.com/zeta-chain/node/cmd/zetatool/config" + pkgchains "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/rpc" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" +) + +// migrationStatus holds the tracked status for a single TSS migration CCTX +type migrationStatus struct { + ChainID int64 + ChainName string + CctxIndex string + CctxStatus string + OutboundHash string + ReceiptStatus string +} + +// NewTrackTSSMigrationCMD creates a command to track TSS fund migration status +func NewTrackTSSMigrationCMD() *cobra.Command { + return &cobra.Command{ + Use: "track-tss-migration ", + Short: "Track TSS fund migration CCTX status and outbound receipts", + Long: `Track the status of TSS fund migration CCTXs across all chains. + +For each migration CCTX, fetches the CCTX status, outbound hash, and queries the +destination chain for the transaction receipt status. + +The chain argument can be: + - A chain ID (e.g., 7000, 7001) + - A chain name (e.g., zeta_mainnet, zeta_testnet) + +The network type (mainnet/testnet/etc) is inferred from the chain. + +Examples: + zetatool track-tss-migration 7000 + zetatool track-tss-migration zeta_mainnet + zetatool track-tss-migration zeta_testnet --config custom_config.json`, + Args: cobra.ExactArgs(1), + RunE: trackTSSMigration, + } +} + +func trackTSSMigration(cmd *cobra.Command, args []string) error { + chain, err := zetatoolcommon.ResolveChain(args[0]) + if err != nil { + return fmt.Errorf("failed to resolve chain %q: %w", args[0], err) + } + + network := zetatoolcommon.NetworkTypeFromChain(chain) + + configFile, err := cmd.Flags().GetString(config.FlagConfig) + if err != nil { + return fmt.Errorf("failed to read value for flag %s: %w", config.FlagConfig, err) + } + + cfg, err := config.GetConfigByNetwork(network, configFile) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + if cfg.ZetaChainRPC == "" { + return fmt.Errorf("ZetaChainRPC is not configured for network %s", network) + } + + zetacoreClient, err := rpc.NewCometBFTClients(cfg.ZetaChainRPC) + if err != nil { + return fmt.Errorf("failed to create zetacore client: %w", err) + } + + ctx := context.Background() + + // Fetch all TSS fund migrators + migratorsRes, err := zetacoreClient.Observer.TssFundsMigratorInfoAll( + ctx, + &observertypes.QueryTssFundsMigratorInfoAllRequest{}, + ) + if err != nil { + return fmt.Errorf("failed to fetch TSS fund migrators: %w", err) + } + + if len(migratorsRes.TssFundsMigrators) == 0 { + fmt.Println("No TSS fund migration entries found") + return nil + } + + fmt.Printf("Found %d TSS fund migration(s)\n", len(migratorsRes.TssFundsMigrators)) + + // Process each migrator concurrently + var wg sync.WaitGroup + results := make(chan migrationStatus, len(migratorsRes.TssFundsMigrators)) + + for _, migrator := range migratorsRes.TssFundsMigrators { + wg.Add(1) + go func(m observertypes.TssFundMigratorInfo) { + defer wg.Done() + results <- processMigration(ctx, &zetacoreClient, cfg, m) + }(migrator) + } + + go func() { + wg.Wait() + close(results) + }() + + statuses := make([]migrationStatus, 0, len(migratorsRes.TssFundsMigrators)) + for status := range results { + statuses = append(statuses, status) + } + + printMigrationTable(statuses) + return nil +} + +// processMigration fetches the CCTX and receipt for a single migration entry +func processMigration( + ctx context.Context, + zetacoreClient *rpc.Clients, + cfg *config.Config, + migrator observertypes.TssFundMigratorInfo, +) migrationStatus { + status := migrationStatus{ + ChainID: migrator.ChainId, + CctxIndex: migrator.MigrationCctxIndex, + } + + // Resolve chain name + chain, found := pkgchains.GetChainFromChainID(migrator.ChainId, nil) + if found { + status.ChainName = chain.Name + } else { + status.ChainName = fmt.Sprintf("unknown(%d)", migrator.ChainId) + } + + // Fetch the CCTX + cctx, err := zetacoreClient.GetCctxByHash(ctx, migrator.MigrationCctxIndex) + if err != nil { + status.CctxStatus = fmt.Sprintf("error: %v", err) + status.ReceiptStatus = "N/A" + return status + } + + if cctx.CctxStatus != nil { + status.CctxStatus = cctx.CctxStatus.Status.String() + } else { + status.CctxStatus = "unknown" + } + + // Get the first (and only) outbound + if len(cctx.OutboundParams) == 0 { + status.OutboundHash = "N/A" + status.ReceiptStatus = "no outbound" + return status + } + + outbound := cctx.OutboundParams[0] + status.OutboundHash = outbound.Hash + + if outbound.Hash == "" { + status.ReceiptStatus = "no hash yet" + return status + } + + // Fetch receipt from the outbound chain + status.ReceiptStatus = fetchOutboundReceipt(ctx, cfg, outbound) + return status +} + +// fetchOutboundReceipt fetches the transaction receipt from the outbound chain +func fetchOutboundReceipt( + ctx context.Context, + cfg *config.Config, + outbound *crosschaintypes.OutboundParams, +) string { + outboundChain, found := pkgchains.GetChainFromChainID(outbound.ReceiverChainId, nil) + if !found { + return fmt.Sprintf("unknown chain %d", outbound.ReceiverChainId) + } + + switch outboundChain.Vm { + case pkgchains.Vm_evm: + return fetchEVMReceipt(ctx, cfg, outboundChain, outbound.Hash) + case pkgchains.Vm_no_vm: + return fetchBTCTxStatus(ctx, outbound.Hash, outboundChain.ChainId) + default: + return fmt.Sprintf("receipt check not supported for %s", outboundChain.Vm.String()) + } +} + +// fetchEVMReceipt fetches a transaction receipt from an EVM chain and returns its status +func fetchEVMReceipt( + ctx context.Context, + cfg *config.Config, + chain pkgchains.Chain, + txHash string, +) string { + rpcURL := clients.ResolveEVMRPC(chain, cfg) + if rpcURL == "" { + return "RPC not configured" + } + + client, err := ethclient.Dial(rpcURL) + if err != nil { + return fmt.Sprintf("dial error: %v", err) + } + defer client.Close() + + receipt, err := client.TransactionReceipt(ctx, ethcommon.HexToHash(txHash)) + if err != nil { + return fmt.Sprintf("receipt error: %v", err) + } + + return formatEVMReceiptStatus(receipt) +} + +func formatEVMReceiptStatus(receipt *ethtypes.Receipt) string { + switch receipt.Status { + case ethtypes.ReceiptStatusSuccessful: + return "success" + case ethtypes.ReceiptStatusFailed: + return "failed" + default: + return fmt.Sprintf("unknown(%d)", receipt.Status) + } +} + +// fetchBTCTxStatus checks if a BTC transaction exists using mempool.space API +func fetchBTCTxStatus(ctx context.Context, txHash string, chainID int64) string { + confirmed, err := clients.IsBTCTxConfirmed(ctx, txHash, chainID) + if err != nil { + return fmt.Sprintf("error: %v", err) + } + if confirmed { + return "confirmed" + } + return "unconfirmed" +} + +func printMigrationTable(statuses []migrationStatus) { + t := newTableWriter() + t.AppendHeader(table.Row{"Chain ID", "Chain", "Migration CCTX Index", "CCTX Status", "Outbound Hash", "Receipt Status"}) + + for _, s := range statuses { + outHash := s.OutboundHash + if len(outHash) > 20 { + outHash = outHash[:10] + "..." + outHash[len(outHash)-10:] + } + + t.AppendRow(table.Row{s.ChainID, s.ChainName, s.CctxIndex, s.CctxStatus, outHash, s.ReceiptStatus}) + } + + fmt.Println() + t.Render() +} diff --git a/cmd/zetatool/cli/tss_balances.go b/cmd/zetatool/cli/tss_balances.go index 974c84598e..f4f05adeb6 100644 --- a/cmd/zetatool/cli/tss_balances.go +++ b/cmd/zetatool/cli/tss_balances.go @@ -3,10 +3,12 @@ package cli import ( "context" "fmt" - "os" + "math/big" + "sort" "sync" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "github.com/jedib0t/go-pretty/v6/table" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -33,6 +35,25 @@ const ( // satoshisPerBTC is the number of satoshis in 1 BTC satoshisPerBTC = 100_000_000 + + // evmMigrationGasLimit is the gas limit used to estimate EVM migration fees in the tss-balances tool. + // The zetaclient signer enforces a minimum gas limit of 100,000 for non-Gas CoinType transactions + // (including CoinType_Cmd used by TSS migrations), but zetacore calculates fees using 21,000 (gas.EVMSend). + // This mismatch causes insufficient funds errors when migrating the full balance. + // We use 100,000 here to match the actual gas consumed at broadcast time. + // TODO: remove this buffer once zetacore or zetaclient is fixed to use consistent gas limits + // https://github.com/zeta-chain/node/issues/3725 + evmMigrationGasLimit = 100_000 + + // evmMigrationGasPriceMultiplierNum and evmMigrationGasPriceMultiplierDen represent the 2.5× + // gas price multiplier as a rational number (5/2) for integer math. + // Zetacore multiplies the median gas price by 2.5 (TssMigrationGasMultiplierEVM) when creating the + // migration CCTX, and the signer broadcasts using that inflated price. Our fee buffer must account + // for the same multiplier to avoid shortfalls. + // TODO: remove this once zetacore or zetaclient gas limit mismatch is fixed + // https://github.com/zeta-chain/node/issues/3725 + evmMigrationGasPriceMultiplierNum = 5 + evmMigrationGasPriceMultiplierDen = 2 ) // chainBalance represents the balance information for a single chain @@ -72,6 +93,8 @@ const ( FlagMigrationAmounts = "migration-amounts" // FlagShowNonces is the flag to show pending nonce low and high columns FlagShowNonces = "show-nonces" + // FlagTSSNumber is the flag to select a specific TSS by position (1 = oldest, N = latest) + FlagTSSNumber = "tss-number" ) // NewTSSBalancesCMD creates a new command to check TSS address balances across all chains @@ -91,13 +114,16 @@ Examples: zetatool tss-balances 7000 zetatool tss-balances zeta_mainnet zetatool tss-balances zeta_testnet --config custom_config.json - zetatool tss-balances zeta_testnet --raw-amounts`, + zetatool tss-balances zeta_testnet --raw-amounts + zetatool tss-balances zeta_mainnet --tss-number 1 # oldest TSS + zetatool tss-balances zeta_mainnet --tss-number 3 # 3rd TSS (use total count for latest)`, Args: cobra.ExactArgs(1), RunE: getTSSBalances, } cmd.Flags().Bool(FlagMigrationAmounts, false, "Show migration amount and raw migration amount columns") cmd.Flags().Bool(FlagShowNonces, false, "Show pending nonce low and high columns") + cmd.Flags().Int(FlagTSSNumber, 0, "Show only the Nth TSS ordered by finalized height (1 = oldest, N = latest)") return cmd } @@ -127,6 +153,11 @@ func getTSSBalances(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read value for flag %s: %w", FlagShowNonces, err) } + tssNumber, err := cmd.Flags().GetInt(FlagTSSNumber) + if err != nil { + return fmt.Errorf("failed to read value for flag %s: %w", FlagTSSNumber, err) + } + cfg, err := config.GetConfigByNetwork(network, configFile) if err != nil { return fmt.Errorf("failed to get config: %w", err) @@ -153,11 +184,25 @@ func getTSSBalances(cmd *cobra.Command, args []string) error { return fmt.Errorf("no TSS entries found") } - for i, tss := range tssHistoryRes.TssList { + // Sort TSS list by finalized zeta height ascending (oldest first, so TSS 1 = oldest, TSS N = latest) + tssList := tssHistoryRes.TssList + sort.Slice(tssList, func(i, j int) bool { + return tssList[i].FinalizedZetaHeight < tssList[j].FinalizedZetaHeight + }) + + // Filter to a specific TSS if --tss-number is set + if tssNumber > 0 { + if tssNumber > len(tssList) { + return fmt.Errorf("TSS number %d out of range, only %d TSS entries available", tssNumber, len(tssList)) + } + tssList = []observertypes.TSS{tssList[tssNumber-1]} + } + + for i, tss := range tssList { if i > 0 { fmt.Println() // Add spacing between TSS entries } - fmt.Printf("=== TSS %d of %d ===\n", i+1, len(tssHistoryRes.TssList)) + fmt.Printf("=== TSS %d of %d ===\n", i+1, len(tssList)) if err := printTSSBalances( ctx, @@ -204,6 +249,38 @@ func calculateBTCMigrationAmount(balance float64) (migrationAmt float64) { return migrationAmt } +// calculateEVMMigrationAmount estimates the migration amount for an EVM chain by subtracting +// the estimated gas fee from the balance. The fee accounts for: +// - evmMigrationGasLimit (100,000): the actual gas limit used by the signer at broadcast +// - evmMigrationGasPriceMultiplier (2.5): zetacore inflates the median gas price by this factor +// when creating the migration CCTX, and the signer broadcasts at that inflated price +func calculateEVMMigrationAmount(ctx context.Context, rpcURL string, balance *big.Int) (migrationAmt, migrationAmtRaw *big.Int) { + client, err := ethclient.Dial(rpcURL) + if err != nil { + // If we can't connect, fall back to full balance + return new(big.Int).Set(balance), new(big.Int).Set(balance) + } + defer client.Close() + + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + return new(big.Int).Set(balance), new(big.Int).Set(balance) + } + + // fee = evmMigrationGasLimit * gasPrice * (num/den) + adjustedGasPrice := new(big.Int).Mul(gasPrice, big.NewInt(evmMigrationGasPriceMultiplierNum)) + adjustedGasPrice.Div(adjustedGasPrice, big.NewInt(evmMigrationGasPriceMultiplierDen)) + + fee := new(big.Int).Mul(big.NewInt(evmMigrationGasLimit), adjustedGasPrice) + + migrationAmt = new(big.Int).Sub(balance, fee) + if migrationAmt.Sign() < 0 { + migrationAmt = big.NewInt(0) + } + + return migrationAmt, migrationAmt +} + // getRPCForChain returns the RPC URL for a given chain from config func getRPCForChain(cfg *config.Config, chain pkgchains.Chain) string { switch chain.Network { @@ -367,14 +444,15 @@ func printTSSBalances( } return } + migrationAmount, migrationAmountRaw := calculateEVMMigrationAmount(ctx, rpcURL, balance) formattedBalance := clients.FormatEVMBalance(balance) results <- chainBalance{ ChainName: c.Name, ChainID: c.ChainId, Address: evmAddr.Hex(), Balance: formattedBalance, - MigrationAmount: formattedBalance, // Same as balance for EVM - MigrationAmountRaw: balance.String(), // Raw wei amount for migration command + MigrationAmount: clients.FormatEVMBalance(migrationAmount), + MigrationAmountRaw: migrationAmountRaw.String(), Symbol: getSymbolForChain(c), VM: c.Vm, } @@ -689,8 +767,7 @@ func printBalanceTable(balances []chainBalance, showMigrationAmounts bool, showN pkgchains.Vm_mvm_sui: "sui", } - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) + t := newTableWriter() // Build header based on flags header := table.Row{"VM", "Chain", "Chain ID", "Address", "Balance"} diff --git a/cmd/zetatool/clients/bitcoin.go b/cmd/zetatool/clients/bitcoin.go index 2b3a678dd3..3e78467e16 100644 --- a/cmd/zetatool/clients/bitcoin.go +++ b/cmd/zetatool/clients/bitcoin.go @@ -25,6 +25,10 @@ const ( mempoolAddressAPITestnet3 = "https://mempool.space/testnet/api/address/%s" mempoolAddressAPISignet = "https://mempool.space/signet/api/address/%s" mempoolAddressAPITestnet4 = "https://mempool.space/testnet4/api/address/%s" + mempoolTxAPIMainnet = "https://mempool.space/api/tx/%s" + mempoolTxAPITestnet3 = "https://mempool.space/testnet/api/tx/%s" + mempoolTxAPISignet = "https://mempool.space/signet/api/tx/%s" + mempoolTxAPITestnet4 = "https://mempool.space/testnet4/api/tx/%s" satoshisPerBitcoin = 100_000_000 httpClientTimeout = 30 * time.Second ) @@ -175,6 +179,60 @@ func getMempoolAddressAPIURL(chainID int64, address string) string { } } +// btcTxStatus represents the status portion of a mempool.space tx response +type btcTxStatus struct { + Status struct { + Confirmed bool `json:"confirmed"` + } `json:"status"` +} + +// IsBTCTxConfirmed checks if a BTC transaction is confirmed using mempool.space API +func IsBTCTxConfirmed(ctx context.Context, txHash string, chainID int64) (bool, error) { + apiURL := getMempoolTxAPIURL(chainID, txHash) + if apiURL == "" { + return false, fmt.Errorf("unsupported Bitcoin chain ID: %d", chainID) + } + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + httpClient := &http.Client{Timeout: httpClientTimeout} + resp, err := httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("failed to fetch tx: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("mempool.space API returned status %d", resp.StatusCode) + } + + var txStatus btcTxStatus + if err := json.NewDecoder(resp.Body).Decode(&txStatus); err != nil { + return false, fmt.Errorf("failed to decode tx status: %w", err) + } + + return txStatus.Status.Confirmed, nil +} + +// getMempoolTxAPIURL returns the mempool.space tx API URL for the given chain ID +func getMempoolTxAPIURL(chainID int64, txHash string) string { + switch chainID { + case 8332: + return fmt.Sprintf(mempoolTxAPIMainnet, txHash) + case 18332: + return fmt.Sprintf(mempoolTxAPITestnet3, txHash) + case 18333: + return fmt.Sprintf(mempoolTxAPISignet, txHash) + case 18334: + return fmt.Sprintf(mempoolTxAPITestnet4, txHash) + default: + return "" + } +} + // GetBTCChainID returns the Bitcoin chain ID for the given network func GetBTCChainID(network string) (int64, error) { switch network { diff --git a/cmd/zetatool/main.go b/cmd/zetatool/main.go index 9375346e92..50274449fd 100644 --- a/cmd/zetatool/main.go +++ b/cmd/zetatool/main.go @@ -8,6 +8,7 @@ import ( "github.com/zeta-chain/node/cmd/zetatool/cli" "github.com/zeta-chain/node/cmd/zetatool/config" + "github.com/zeta-chain/node/pkg/sdkconfig" ) var rootCmd = &cobra.Command{ @@ -16,11 +17,14 @@ var rootCmd = &cobra.Command{ } func init() { + sdkconfig.SetDefault(false) rootCmd.AddCommand(cli.NewGetInboundBallotCMD()) rootCmd.AddCommand(cli.NewTrackCCTXCMD()) rootCmd.AddCommand(cli.NewApplicationDBStatsCMD()) rootCmd.AddCommand(cli.NewTSSBalancesCMD()) rootCmd.AddCommand(cli.NewListChainsCMD()) + rootCmd.AddCommand(cli.NewListObserversCMD()) + rootCmd.AddCommand(cli.NewTrackTSSMigrationCMD()) rootCmd.PersistentFlags().String(config.FlagConfig, "", "custom config file: --config filename.json") rootCmd.PersistentFlags(). Bool(config.FlagDebug, false, "enable debug mode, to show more details on why the command might be failing")