diff --git a/pkg/crypto/privkey.go b/pkg/crypto/privkey.go new file mode 100644 index 0000000000..2acbf1c609 --- /dev/null +++ b/pkg/crypto/privkey.go @@ -0,0 +1,23 @@ +package crypto + +import ( + fmt "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/pkg/errors" +) + +// SolanaPrivateKeyFromString converts a base58 encoded private key to a solana.PrivateKey +func SolanaPrivateKeyFromString(privKeyBase58 string) (*solana.PrivateKey, error) { + privateKey, err := solana.PrivateKeyFromBase58(privKeyBase58) + if err != nil { + return nil, errors.Wrap(err, "invalid base58 private key") + } + + // Solana private keys are 64 bytes long + if len(privateKey) != 64 { + return nil, fmt.Errorf("invalid private key length: %d", len(privateKey)) + } + + return &privateKey, nil +} diff --git a/pkg/crypto/privkey_test.go b/pkg/crypto/privkey_test.go new file mode 100644 index 0000000000..4a0182b134 --- /dev/null +++ b/pkg/crypto/privkey_test.go @@ -0,0 +1,67 @@ +package crypto_test + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/pkg/crypto" +) + +func Test_IsValidSolanaPrivateKey(t *testing.T) { + tests := []struct { + name string + input string + output *solana.PrivateKey + errMsg string + }{ + { + name: "valid private key", + input: "3EMjCcCJg53fMEGVj13UPQpo6py9AKKyLE2qroR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQ", + output: func() *solana.PrivateKey { + privKey, _ := solana.PrivateKeyFromBase58( + "3EMjCcCJg53fMEGVj13UPQpo6py9AKKyLE2qroR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQ", + ) + return &privKey + }(), + }, + { + name: "invalid private key - too short", + input: "oR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQ", + output: nil, + errMsg: "invalid private key length: 38", + }, + { + name: "invalid private key - too long", + input: "3EMjCcCJg53fMEGVj13UPQpo6py9AKKyLE2qroR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQdJ", + output: nil, + errMsg: "invalid private key length: 66", + }, + { + name: "invalid private key - bad base58 encoding", + input: "!!!InvalidBase58!!!", + output: nil, + errMsg: "invalid base58 private key", + }, + { + name: "invalid private key - empty string", + input: "", + output: nil, + errMsg: "invalid base58 private key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := crypto.SolanaPrivateKeyFromString(tt.input) + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + require.Nil(t, result) + return + } + + require.NoError(t, err) + require.Equal(t, tt.output.String(), result.String()) + }) + } +} diff --git a/zetaclient/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 8377d2ba33..edd656979b 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -193,6 +193,11 @@ type SolanaRPCClient interface { GetHealth(ctx context.Context) (string, error) GetSlot(ctx context.Context, commitment solrpc.CommitmentType) (uint64, error) GetAccountInfo(ctx context.Context, account solana.PublicKey) (*solrpc.GetAccountInfoResult, error) + GetBalance( + ctx context.Context, + account solana.PublicKey, + commitment solrpc.CommitmentType, + ) (*solrpc.GetBalanceResult, error) GetRecentBlockhash(ctx context.Context, commitment solrpc.CommitmentType) (*solrpc.GetRecentBlockhashResult, error) GetRecentPrioritizationFees( ctx context.Context, diff --git a/zetaclient/chains/solana/signer/signer.go b/zetaclient/chains/solana/signer/signer.go index cc9b6e14f1..ca3dfb0acb 100644 --- a/zetaclient/chains/solana/signer/signer.go +++ b/zetaclient/chains/solana/signer/signer.go @@ -11,6 +11,7 @@ import ( "github.com/zeta-chain/zetacore/pkg/chains" "github.com/zeta-chain/zetacore/pkg/coin" contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" + "github.com/zeta-chain/zetacore/pkg/crypto" "github.com/zeta-chain/zetacore/x/crosschain/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/base" @@ -69,12 +70,11 @@ func NewSigner( // construct Solana private key if present if relayerKey != nil { - privKey, err := solana.PrivateKeyFromBase58(relayerKey.PrivateKey) + signer.relayerKey, err = crypto.SolanaPrivateKeyFromString(relayerKey.PrivateKey) if err != nil { return nil, errors.Wrap(err, "unable to construct solana private key") } - signer.relayerKey = &privKey - logger.Std.Info().Msgf("Solana relayer address: %s", privKey.PublicKey()) + logger.Std.Info().Msgf("Solana relayer address: %s", signer.relayerKey.PublicKey()) } else { logger.Std.Info().Msg("Solana relayer key is not provided") } @@ -138,6 +138,9 @@ func (signer *Signer) TryProcessOutbound( return } + // set relayer balance metrics + signer.SetRelayerBalanceMetrics(ctx) + // sign the withdraw transaction by relayer key tx, err := signer.SignWithdrawTx(ctx, *msg) if err != nil { @@ -191,6 +194,19 @@ func (signer *Signer) GetGatewayAddress() string { return signer.gatewayID.String() } +// SetRelayerBalanceMetrics sets the relayer balance metrics +func (signer *Signer) SetRelayerBalanceMetrics(ctx context.Context) { + if signer.HasRelayerKey() { + result, err := signer.client.GetBalance(ctx, signer.relayerKey.PublicKey(), rpc.CommitmentFinalized) + if err != nil { + signer.Logger().Std.Error().Err(err).Msg("GetBalance error") + return + } + solBalance := float64(result.Value) / float64(solana.LAMPORTS_PER_SOL) + metrics.RelayerKeyBalance.WithLabelValues(signer.Chain().Name).Set(solBalance) + } +} + // TODO: get rid of below four functions for Solana and Bitcoin // https://github.com/zeta-chain/node/issues/2532 func (signer *Signer) SetZetaConnectorAddress(_ ethcommon.Address) { diff --git a/zetaclient/chains/solana/signer/signer_test.go b/zetaclient/chains/solana/signer/signer_test.go new file mode 100644 index 0000000000..f3e1799d61 --- /dev/null +++ b/zetaclient/chains/solana/signer/signer_test.go @@ -0,0 +1,142 @@ +package signer_test + +import ( + "context" + "errors" + "testing" + + "github.com/gagliardetto/solana-go/rpc" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/testutil/sample" + observertypes "github.com/zeta-chain/zetacore/x/observer/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana/signer" + "github.com/zeta-chain/zetacore/zetaclient/keys" + "github.com/zeta-chain/zetacore/zetaclient/metrics" + "github.com/zeta-chain/zetacore/zetaclient/testutils" + "github.com/zeta-chain/zetacore/zetaclient/testutils/mocks" +) + +func Test_NewSigner(t *testing.T) { + // test parameters + chain := chains.SolanaDevnet + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = testutils.GatewayAddresses[chain.ChainId] + + tests := []struct { + name string + chain chains.Chain + chainParams observertypes.ChainParams + solClient interfaces.SolanaRPCClient + tss interfaces.TSSSigner + relayerKey *keys.RelayerKey + ts *metrics.TelemetryServer + logger base.Logger + errMessage string + }{ + { + name: "should create solana signer successfully with relayer key", + chain: chain, + chainParams: *chainParams, + solClient: nil, + tss: nil, + relayerKey: &keys.RelayerKey{ + PrivateKey: "3EMjCcCJg53fMEGVj13UPQpo6py9AKKyLE2qroR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQ", + }, + ts: nil, + logger: base.DefaultLogger(), + }, + { + name: "should create solana signer successfully without relayer key", + chainParams: *chainParams, + solClient: nil, + tss: nil, + relayerKey: nil, + ts: nil, + logger: base.DefaultLogger(), + }, + { + name: "should fail to create solana signer with invalid gateway address", + chainParams: func() observertypes.ChainParams { + cp := *chainParams + cp.GatewayAddress = "invalid" + return cp + }(), + solClient: nil, + tss: nil, + relayerKey: nil, + ts: nil, + logger: base.DefaultLogger(), + errMessage: "cannot parse gateway address", + }, + { + name: "should fail to create solana signer with invalid relayer key", + chainParams: *chainParams, + solClient: nil, + tss: nil, + relayerKey: &keys.RelayerKey{ + PrivateKey: "3EMjCcCJg53fMEGVj13", // too short + }, + ts: nil, + logger: base.DefaultLogger(), + errMessage: "unable to construct solana private key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := signer.NewSigner(tt.chain, tt.chainParams, tt.solClient, tt.tss, tt.relayerKey, tt.ts, tt.logger) + if tt.errMessage != "" { + require.ErrorContains(t, err, tt.errMessage) + require.Nil(t, s) + return + } + + require.NoError(t, err) + require.NotNil(t, s) + }) + } +} + +func Test_SetRelayerBalanceMetrics(t *testing.T) { + // test parameters + chain := chains.SolanaDevnet + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = testutils.GatewayAddresses[chain.ChainId] + relayerKey := &keys.RelayerKey{ + PrivateKey: "3EMjCcCJg53fMEGVj13UPQpo6py9AKKyLE2qroR4yL1SvAN2tUznBvDKRYjntw7m6Jof1R2CSqjTddL27rEb6sFQ", + } + ctx := context.Background() + + // mock solana client with RPC error + mckClient := mocks.NewSolanaRPCClient(t) + mckClient.On("GetBalance", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("rpc error")) + + // create signer and set relayer balance metrics + s, err := signer.NewSigner(chain, *chainParams, mckClient, nil, relayerKey, nil, base.DefaultLogger()) + require.NoError(t, err) + s.SetRelayerBalanceMetrics(ctx) + + // assert that relayer key balance metrics is not set (due to RPC error) + balance := testutil.ToFloat64(metrics.RelayerKeyBalance.WithLabelValues(chain.Name)) + require.Equal(t, 0.0, balance) + + // mock solana client with balance + mckClient = mocks.NewSolanaRPCClient(t) + mckClient.On("GetBalance", mock.Anything, mock.Anything, mock.Anything).Return(&rpc.GetBalanceResult{ + Value: 123400000, + }, nil) + + // create signer and set relayer balance metrics again + s, err = signer.NewSigner(chain, *chainParams, mckClient, nil, relayerKey, nil, base.DefaultLogger()) + require.NoError(t, err) + s.SetRelayerBalanceMetrics(ctx) + + // assert that relayer key balance metrics is set correctly + balance = testutil.ToFloat64(metrics.RelayerKeyBalance.WithLabelValues(chain.Name)) + require.Equal(t, 0.1234, balance) +} diff --git a/zetaclient/keys/relayer_key.go b/zetaclient/keys/relayer_key.go index 8de6cc9853..9eb0cb3205 100644 --- a/zetaclient/keys/relayer_key.go +++ b/zetaclient/keys/relayer_key.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" - "github.com/gagliardetto/solana-go" "github.com/pkg/errors" "github.com/zeta-chain/zetacore/pkg/chains" @@ -29,7 +28,7 @@ func (rk RelayerKey) ResolveAddress(network chains.Network) (string, string, err switch network { case chains.Network_solana: - privKey, err := solana.PrivateKeyFromBase58(rk.PrivateKey) + privKey, err := crypto.SolanaPrivateKeyFromString(rk.PrivateKey) if err != nil { return "", "", errors.Wrap(err, "unable to construct solana private key") } diff --git a/zetaclient/metrics/metrics.go b/zetaclient/metrics/metrics.go index df956daa90..a0a7341f94 100644 --- a/zetaclient/metrics/metrics.go +++ b/zetaclient/metrics/metrics.go @@ -50,6 +50,13 @@ var ( Help: "Tss node blame counter per pubkey", }, []string{"pubkey"}) + // RelayerKeyBalance is a gauge that contains the relayer key balance of the chain + RelayerKeyBalance = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: ZetaClientNamespace, + Name: "relayer_key_balance", + Help: "Relayer key balance of the chain", + }, []string{"chain"}) + // HotKeyBurnRate is a gauge that contains the fee burn rate of the hotkey HotKeyBurnRate = promauto.NewGauge(prometheus.GaugeOpts{ Namespace: ZetaClientNamespace, diff --git a/zetaclient/metrics/metrics_test.go b/zetaclient/metrics/metrics_test.go index 6be8bc30c0..239a6391c4 100644 --- a/zetaclient/metrics/metrics_test.go +++ b/zetaclient/metrics/metrics_test.go @@ -10,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/zeta-chain/zetacore/pkg/chains" . "gopkg.in/check.v1" ) @@ -39,7 +40,7 @@ func (ms *MetricsSuite) TestCurryWith(c *C) { RPCCount.Reset() } -func (ms *MetricsSuite) TestMetrics(c *C) { +func (ms *MetricsSuite) Test_RPCCount(c *C) { GetFilterLogsPerChain.WithLabelValues("chain1").Inc() GetFilterLogsPerChain.WithLabelValues("chain2").Inc() GetFilterLogsPerChain.WithLabelValues("chain2").Inc() @@ -77,3 +78,11 @@ func (ms *MetricsSuite) TestMetrics(c *C) { rpcCount = testutil.ToFloat64(RPCCount.With(prometheus.Labels{"host": "127.0.0.1:8886", "code": "502"})) c.Assert(rpcCount, Equals, 0.0) } + +func (ms *MetricsSuite) Test_RelayerKeyBalance(c *C) { + RelayerKeyBalance.WithLabelValues(chains.SolanaDevnet.Name).Set(2.1564) + + // assert that relayer key balance is being set correctly + balance := testutil.ToFloat64(RelayerKeyBalance.WithLabelValues(chains.SolanaDevnet.Name)) + c.Assert(balance, Equals, 2.1564) +} diff --git a/zetaclient/testutils/constant.go b/zetaclient/testutils/constant.go index ad8302577d..3036035db4 100644 --- a/zetaclient/testutils/constant.go +++ b/zetaclient/testutils/constant.go @@ -1,6 +1,10 @@ package testutils -import ethcommon "github.com/ethereum/go-ethereum/common" +import ( + ethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/zeta-chain/zetacore/pkg/chains" +) const ( // TSSAddressEVMMainnet the EVM TSS address for test purposes @@ -29,33 +33,39 @@ const ( EventERC20Withdraw = "Withdrawn" ) +// GatewayAddresses contains constants gateway addresses for testing +var GatewayAddresses = map[int64]string{ + // Gateway address on Solana devnet + chains.SolanaDevnet.ChainId: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", +} + // ConnectorAddresses contains constants ERC20 connector addresses for testing var ConnectorAddresses = map[int64]ethcommon.Address{ // Connector address on Ethereum mainnet - 1: ethcommon.HexToAddress("0x000007Cf399229b2f5A4D043F20E90C9C98B7C6a"), + chains.Ethereum.ChainId: ethcommon.HexToAddress("0x000007Cf399229b2f5A4D043F20E90C9C98B7C6a"), // Connector address on Binance Smart Chain mainnet - 56: ethcommon.HexToAddress("0x000063A6e758D9e2f438d430108377564cf4077D"), + chains.BscMainnet.ChainId: ethcommon.HexToAddress("0x000063A6e758D9e2f438d430108377564cf4077D"), // testnet - 5: ethcommon.HexToAddress("0x00005E3125aBA53C5652f9F0CE1a4Cf91D8B15eA"), - 97: ethcommon.HexToAddress("0x0000ecb8cdd25a18F12DAA23f6422e07fBf8B9E1"), - 11155111: ethcommon.HexToAddress("0x3963341dad121c9CD33046089395D66eBF20Fb03"), + chains.Goerli.ChainId: ethcommon.HexToAddress("0x00005E3125aBA53C5652f9F0CE1a4Cf91D8B15eA"), + chains.BscTestnet.ChainId: ethcommon.HexToAddress("0x0000ecb8cdd25a18F12DAA23f6422e07fBf8B9E1"), + chains.Sepolia.ChainId: ethcommon.HexToAddress("0x3963341dad121c9CD33046089395D66eBF20Fb03"), // localnet - 1337: ethcommon.HexToAddress("0xD28D6A0b8189305551a0A8bd247a6ECa9CE781Ca"), + chains.GoerliLocalnet.ChainId: ethcommon.HexToAddress("0xD28D6A0b8189305551a0A8bd247a6ECa9CE781Ca"), } // CustodyAddresses contains constants ERC20 custody addresses for testing var CustodyAddresses = map[int64]ethcommon.Address{ // ERC20 custody address on Ethereum mainnet - 1: ethcommon.HexToAddress("0x0000030Ec64DF25301d8414eE5a29588C4B0dE10"), + chains.Ethereum.ChainId: ethcommon.HexToAddress("0x0000030Ec64DF25301d8414eE5a29588C4B0dE10"), // ERC20 custody address on Binance Smart Chain mainnet - 56: ethcommon.HexToAddress("0x00000fF8fA992424957F97688015814e707A0115"), + chains.BscMainnet.ChainId: ethcommon.HexToAddress("0x00000fF8fA992424957F97688015814e707A0115"), // testnet - 5: ethcommon.HexToAddress("0x000047f11C6E42293F433C82473532E869Ce4Ec5"), - 97: ethcommon.HexToAddress("0x0000a7Db254145767262C6A81a7eE1650684258e"), - 11155111: ethcommon.HexToAddress("0x84725b70a239d3Faa7C6EF0C6C8E8b6c8e28338b"), + chains.Goerli.ChainId: ethcommon.HexToAddress("0x000047f11C6E42293F433C82473532E869Ce4Ec5"), + chains.BscTestnet.ChainId: ethcommon.HexToAddress("0x0000a7Db254145767262C6A81a7eE1650684258e"), + chains.Sepolia.ChainId: ethcommon.HexToAddress("0x84725b70a239d3Faa7C6EF0C6C8E8b6c8e28338b"), } diff --git a/zetaclient/testutils/mocks/solana_rpc.go b/zetaclient/testutils/mocks/solana_rpc.go index 953bde87e5..fad147037c 100644 --- a/zetaclient/testutils/mocks/solana_rpc.go +++ b/zetaclient/testutils/mocks/solana_rpc.go @@ -47,6 +47,36 @@ func (_m *SolanaRPCClient) GetAccountInfo(ctx context.Context, account solana.Pu return r0, r1 } +// GetBalance provides a mock function with given fields: ctx, account, commitment +func (_m *SolanaRPCClient) GetBalance(ctx context.Context, account solana.PublicKey, commitment rpc.CommitmentType) (*rpc.GetBalanceResult, error) { + ret := _m.Called(ctx, account, commitment) + + if len(ret) == 0 { + panic("no return value specified for GetBalance") + } + + var r0 *rpc.GetBalanceResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey, rpc.CommitmentType) (*rpc.GetBalanceResult, error)); ok { + return rf(ctx, account, commitment) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey, rpc.CommitmentType) *rpc.GetBalanceResult); ok { + r0 = rf(ctx, account, commitment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.GetBalanceResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.PublicKey, rpc.CommitmentType) error); ok { + r1 = rf(ctx, account, commitment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetConfirmedTransactionWithOpts provides a mock function with given fields: ctx, signature, opts func (_m *SolanaRPCClient) GetConfirmedTransactionWithOpts(ctx context.Context, signature solana.Signature, opts *rpc.GetTransactionOpts) (*rpc.TransactionWithMeta, error) { ret := _m.Called(ctx, signature, opts)