From 3ad6f997c70f1d325a79b3db01481e1274b22c3d Mon Sep 17 00:00:00 2001 From: Charlie Chen <34498985+ws4charlie@users.noreply.github.com> Date: Fri, 19 Jul 2024 11:18:48 -0500 Subject: [PATCH] feat: observation of inbound SOL token deposit (#2465) * add a cmd as an experimental testbed for solana * cmd(solana): sign ECDSA and build withdraw tx * e2e: localnet solana WIP: initialize gateway program * e2e(solana): start deposit test * update solana program with chain id commit in TSS signature * fix the initialize of solana program after update the program * zetaclient(solana): observe and filter and parse deposit instruction * zetaclient(solana): remember last observed tx to save RPC calls * zetaclient(solana): report to zetacored about deposit * localnet: deploy sol zrc20 * use solana chain from merged commits * fix solana rpc localnet config * fix chain params for solana * initialted inbound observation on SOL deposit * Use docker image and add make targets * polish Solana initialize and deposit E2E tests * make Solana inbound e2e test passing * clean up unused files; reduce log prints * added entry to changelog * revert Dockerfile-localnet * remove solana-test in Makefile because Solana e2e tests is ran as part of start-e2e-test * polished e2e tests, solana config and chain parameters * add issue link for TODO * remove panics * move Solana gateway program initialization to the contract setup phase * refactor e2e clients creation; add context to Solana signature query * fix unit tests * added inbound last_scanned_block_number metrics * integrate solana tests into CI * filter at most two events [SOL + SPL] per solana tx to be consistent with EVM chain inbound observation * fix unit test compile * use observer context for Solana RPC calls * better format stack information on panic * move stack print into logError() to avoid duplicate log print * Fix `bg` * fix unit test * rename pdaID as pda to be a more correct Solana terminology --------- Co-authored-by: brewmaster012 <88689859+brewmaster012@users.noreply.github.com> Co-authored-by: Alex Gartner Co-authored-by: Dmitry --- .github/workflows/e2e.yml | 12 +- Makefile | 9 + changelog.md | 1 + cmd/zetaclientd/start_utils.go | 2 + cmd/zetaclientd/utils.go | 40 +- cmd/zetae2e/config/clients.go | 76 ++-- cmd/zetae2e/config/config.go | 42 +- cmd/zetae2e/config/contracts.go | 17 + cmd/zetae2e/config/localnet.yml | 7 + cmd/zetae2e/init.go | 3 +- cmd/zetae2e/local/local.go | 10 + cmd/zetae2e/local/solana.go | 60 +++ contrib/localnet/docker-compose.yml | 14 + contrib/localnet/solana/Dockerfile | 10 + contrib/localnet/solana/gateway-keypair.json | 1 + contrib/localnet/solana/gateway.so | Bin 0 -> 278648 bytes contrib/localnet/solana/start-solana.sh | 18 + e2e/config/config.go | 20 +- e2e/e2etests/e2etests.go | 16 + e2e/e2etests/test_migrate_chain_support.go | 1 + e2e/e2etests/test_solana_deposit.go | 29 ++ e2e/runner/accounting.go | 46 +- e2e/runner/balances.go | 10 +- e2e/runner/runner.go | 35 +- e2e/runner/setup_bitcoin.go | 2 +- e2e/runner/setup_solana.go | 91 ++++ e2e/runner/setup_zeta.go | 20 + e2e/runner/solana.go | 107 +++++ e2e/txserver/zeta_tx_server.go | 15 + go.mod | 17 +- go.sum | 45 +- pkg/bg/bg.go | 26 +- pkg/bg/bg_test.go | 10 +- pkg/chains/chain.go | 7 + pkg/contract/solana/contract.go | 38 ++ pkg/contract/solana/gateway.json | 420 ++++++++++++++++++ pkg/contract/solana/idl.go | 67 +++ pkg/contract/solana/types.go | 44 ++ testutil/sample/crypto.go | 26 ++ testutil/sample/zetaclient.go | 25 ++ .../keeper/grpc_query_cctx_rate_limit_test.go | 15 + x/crosschain/keeper/utils_test.go | 4 + x/observer/genesis.go | 3 + x/observer/genesis_test.go | 6 + x/observer/types/chain_params.go | 20 + zetaclient/chains/base/observer.go | 108 ++++- zetaclient/chains/base/observer_test.go | 193 ++++++-- .../chains/bitcoin/observer/observer.go | 2 +- zetaclient/chains/evm/observer/inbound.go | 33 +- zetaclient/chains/evm/observer/observer.go | 2 +- .../chains/evm/observer/observer_test.go | 1 + zetaclient/chains/interfaces/interfaces.go | 19 + zetaclient/chains/solana/observer/db.go | 30 ++ zetaclient/chains/solana/observer/db_test.go | 103 +++++ zetaclient/chains/solana/observer/inbound.go | 360 +++++++++++++++ .../chains/solana/observer/inbound_test.go | 189 ++++++++ .../chains/solana/observer/inbound_tracker.go | 79 ++++ zetaclient/chains/solana/observer/observer.go | 118 +++++ zetaclient/chains/solana/observer/outbound.go | 23 + zetaclient/chains/solana/rpc/rpc.go | 118 +++++ zetaclient/chains/solana/rpc/rpc_live_test.go | 59 +++ zetaclient/compliance/compliance.go | 24 + zetaclient/config/config_chain.go | 8 + zetaclient/config/types.go | 15 + zetaclient/context/app.go | 49 +- zetaclient/context/app_test.go | 7 + zetaclient/orchestrator/orchestrator_test.go | 1 + ...LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json | 64 +++ zetaclient/testutils/testdata.go | 22 + zetaclient/testutils/testdata_naming.go | 11 + zetaclient/types/event.go | 43 ++ zetaclient/types/sql.go | 41 ++ zetaclient/types/sql_evm.go | 15 - zetaclient/zetacore/client.go | 4 + 74 files changed, 3071 insertions(+), 157 deletions(-) create mode 100644 cmd/zetae2e/local/solana.go create mode 100644 contrib/localnet/solana/Dockerfile create mode 100644 contrib/localnet/solana/gateway-keypair.json create mode 100755 contrib/localnet/solana/gateway.so create mode 100644 contrib/localnet/solana/start-solana.sh create mode 100644 e2e/e2etests/test_solana_deposit.go create mode 100644 e2e/runner/setup_solana.go create mode 100644 e2e/runner/solana.go create mode 100644 pkg/contract/solana/contract.go create mode 100644 pkg/contract/solana/gateway.json create mode 100644 pkg/contract/solana/idl.go create mode 100644 pkg/contract/solana/types.go create mode 100644 testutil/sample/zetaclient.go create mode 100644 zetaclient/chains/solana/observer/db.go create mode 100644 zetaclient/chains/solana/observer/db_test.go create mode 100644 zetaclient/chains/solana/observer/inbound.go create mode 100644 zetaclient/chains/solana/observer/inbound_test.go create mode 100644 zetaclient/chains/solana/observer/inbound_tracker.go create mode 100644 zetaclient/chains/solana/observer/observer.go create mode 100644 zetaclient/chains/solana/observer/outbound.go create mode 100644 zetaclient/chains/solana/rpc/rpc.go create mode 100644 zetaclient/chains/solana/rpc/rpc_live_test.go create mode 100644 zetaclient/testdata/solana/chain_901_inbound_tx_result_5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json create mode 100644 zetaclient/types/event.go create mode 100644 zetaclient/types/sql.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 10534a379b..cbbfc15e6b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,10 @@ on: type: boolean required: false default: false + solana-test: + type: boolean + required: false + default: false concurrency: group: e2e-${{ github.head_ref || github.sha }} @@ -66,7 +70,7 @@ jobs: PERFORMANCE_TESTS: ${{ steps.matrix-conditionals.outputs.PERFORMANCE_TESTS }} STATEFUL_DATA_TESTS: ${{ steps.matrix-conditionals.outputs.STATEFUL_DATA_TESTS }} TSS_MIGRATION_TESTS: ${{ steps.matrix-conditionals.outputs.TSS_MIGRATION_TESTS }} - + SOLANA_TESTS: ${{ steps.matrix-conditionals.outputs.SOLANA_TESTS }} steps: # use api rather than event context to avoid race conditions (label added after push) - id: matrix-conditionals @@ -89,6 +93,7 @@ jobs: core.setOutput('PERFORMANCE_TESTS', labels.includes('PERFORMANCE_TESTS')); core.setOutput('STATEFUL_DATA_TESTS', labels.includes('STATEFUL_DATA_TESTS')); core.setOutput('TSS_MIGRATION_TESTS', labels.includes('TSS_MIGRATION_TESTS')); + core.setOutput('SOLANA_TESTS', labels.includes('SOLANA_TESTS')); } else if (context.eventName === 'merge_group') { core.setOutput('DEFAULT_TESTS', true); } else if (context.eventName === 'push' && context.ref === 'refs/heads/develop') { @@ -109,6 +114,7 @@ jobs: core.setOutput('ADMIN_TESTS', true); core.setOutput('PERFORMANCE_TESTS', true); core.setOutput('STATEFUL_DATA_TESTS', true); + core.setOutput('SOLANA_TESTS', true); } else if (context.eventName === 'workflow_dispatch') { core.setOutput('DEFAULT_TESTS', context.payload.inputs['default-test']); core.setOutput('UPGRADE_TESTS', context.payload.inputs['upgrade-test']); @@ -118,6 +124,7 @@ jobs: core.setOutput('PERFORMANCE_TESTS', context.payload.inputs['performance-test']); core.setOutput('STATEFUL_DATA_TESTS', context.payload.inputs['stateful-data-test']); core.setOutput('TSS_MIGRATION_TESTS', context.payload.inputs['tss-migration-test']); + core.setOutput('SOLANA_TESTS', context.payload.inputs['solana-test']); } e2e: @@ -150,6 +157,9 @@ jobs: - make-target: "start-tss-migration-test" runs-on: ubuntu-20.04 run: ${{ needs.matrix-conditionals.outputs.TSS_MIGRATION_TESTS == 'true' }} + - make-target: "start-solana-test" + runs-on: ubuntu-20.04 + run: ${{ needs.matrix-conditionals.outputs.SOLANA_TESTS == 'true' }} name: ${{ matrix.make-target }} uses: ./.github/workflows/reusable-e2e.yml with: diff --git a/Makefile b/Makefile index 75bd9e9f92..bd338deb61 100644 --- a/Makefile +++ b/Makefile @@ -230,6 +230,10 @@ install-zetae2e: go.sum @go install -mod=readonly $(BUILD_FLAGS) ./cmd/zetae2e .PHONY: install-zetae2e +solana: + @echo "Building solana docker image" + $(DOCKER) build -t solana-local -f contrib/localnet/solana/Dockerfile contrib/localnet/solana/ + start-e2e-test: zetanode @echo "--> Starting e2e test" cd contrib/localnet/ && $(DOCKER) compose up -d @@ -259,6 +263,11 @@ start-tss-migration-test: zetanode export E2E_ARGS="--test-tss-migration" && \ cd contrib/localnet/ && $(DOCKER) compose up -d +start-solana-test: zetanode solana + @echo "--> Starting solana test" + export E2E_ARGS="--skip-regular --test-solana" && \ + cd contrib/localnet/ && $(DOCKER) compose --profile solana -f docker-compose.yml up -d + ############################################################################### ### Upgrade Tests ### ############################################################################### diff --git a/changelog.md b/changelog.md index 5e846f036b..cfa004ea47 100644 --- a/changelog.md +++ b/changelog.md @@ -31,6 +31,7 @@ * [2366](https://github.com/zeta-chain/node/pull/2366) - add migration script for adding authorizations table * [2372](https://github.com/zeta-chain/node/pull/2372) - add queries for tss fund migration info * [2416](https://github.com/zeta-chain/node/pull/2416) - add Solana chain information +* [2465](https://github.com/zeta-chain/node/pull/2465) - add Solana inbound SOL token observation ### Refactor diff --git a/cmd/zetaclientd/start_utils.go b/cmd/zetaclientd/start_utils.go index 10f0b0beea..9692f32b29 100644 --- a/cmd/zetaclientd/start_utils.go +++ b/cmd/zetaclientd/start_utils.go @@ -84,8 +84,10 @@ func maskCfg(cfg config.Config) string { chain.Endpoint = endpointURL.Hostname() } + // mask endpoints maskedCfg.BitcoinConfig.RPCUsername = "" maskedCfg.BitcoinConfig.RPCPassword = "" + maskedCfg.SolanaConfig.Endpoint = "" return maskedCfg.String() } diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index 01021d4e74..a7799eadd4 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -7,6 +7,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + solrpc "github.com/gagliardetto/solana-go/rpc" "github.com/rs/zerolog" "github.com/zeta-chain/zetacore/zetaclient/authz" @@ -17,6 +18,7 @@ import ( evmobserver "github.com/zeta-chain/zetacore/zetaclient/chains/evm/observer" evmsigner "github.com/zeta-chain/zetacore/zetaclient/chains/evm/signer" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + solanaobserver "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" "github.com/zeta-chain/zetacore/zetaclient/config" "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/keys" @@ -168,7 +170,7 @@ func CreateChainObserverMap( } // BTC observer - _, chainParams, found := appContext.GetBTCChainParams() + _, btcChainParams, found := appContext.GetBTCChainParams() if !found { return nil, fmt.Errorf("bitcoin chains params not found") } @@ -184,7 +186,7 @@ func CreateChainObserverMap( observer, err := btcobserver.NewObserver( btcChain, btcClient, - *chainParams, + *btcChainParams, zetacoreClient, tss, dbpath, @@ -199,5 +201,39 @@ func CreateChainObserverMap( } } + // Solana chain params + _, solChainParams, found := appContext.GetSolanaChainParams() + if !found { + logger.Std.Error().Msg("solana chain params not found") + return observerMap, nil + } + + // create Solana chain observer + solChain, solConfig, enabled := appContext.GetSolanaChainAndConfig() + if enabled { + rpcClient := solrpc.New(solConfig.Endpoint) + if rpcClient == nil { + // should never happen + logger.Std.Error().Msg("solana create Solana client error") + return observerMap, nil + } + + observer, err := solanaobserver.NewObserver( + solChain, + rpcClient, + *solChainParams, + zetacoreClient, + tss, + dbpath, + logger, + ts, + ) + if err != nil { + logger.Std.Error().Err(err).Msg("NewObserver error for solana chain") + } else { + observerMap[solChainParams.ChainId] = observer + } + } + return observerMap, nil } diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index 8c6bb37106..db7753aede 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -9,6 +9,7 @@ import ( banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/ethclient" + "github.com/gagliardetto/solana-go/rpc" "google.golang.org/grpc" "github.com/zeta-chain/zetacore/e2e/config" @@ -18,51 +19,72 @@ import ( observertypes "github.com/zeta-chain/zetacore/x/observer/types" ) +// E2EClients contains all the RPC clients and gRPC clients for E2E tests +type E2EClients struct { + // the RPC clients for external chains in the localnet + BtcRPCClient *rpcclient.Client + SolanaClient *rpc.Client + EvmClient *ethclient.Client + EvmAuth *bind.TransactOpts + + // the gRPC clients for ZetaChain + CctxClient crosschaintypes.QueryClient + FungibleClient fungibletypes.QueryClient + AuthClient authtypes.QueryClient + BankClient banktypes.QueryClient + ObserverClient observertypes.QueryClient + LightClient lightclienttypes.QueryClient + + // the RPC clients for ZetaChain + ZevmClient *ethclient.Client + ZevmAuth *bind.TransactOpts +} + // getClientsFromConfig get clients from config func getClientsFromConfig(ctx context.Context, conf config.Config, account config.Account) ( - *rpcclient.Client, - *ethclient.Client, - *bind.TransactOpts, - crosschaintypes.QueryClient, - fungibletypes.QueryClient, - authtypes.QueryClient, - banktypes.QueryClient, - observertypes.QueryClient, - lightclienttypes.QueryClient, - *ethclient.Client, - *bind.TransactOpts, + E2EClients, error, ) { + if conf.RPCs.Solana == "" { + return E2EClients{}, fmt.Errorf("solana rpc is empty") + } + solanaClient := rpc.New(conf.RPCs.Solana) + if solanaClient == nil { + return E2EClients{}, fmt.Errorf("failed to get solana client") + } btcRPCClient, err := getBtcClient(conf.RPCs.Bitcoin) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get btc client: %w", err) + return E2EClients{}, fmt.Errorf("failed to get btc client: %w", err) } evmClient, evmAuth, err := getEVMClient(ctx, conf.RPCs.EVM, account) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get evm client: %w", err) + return E2EClients{}, fmt.Errorf("failed to get evm client: %w", err) } cctxClient, fungibleClient, authClient, bankClient, observerClient, lightclientClient, err := getZetaClients( conf.RPCs.ZetaCoreGRPC, ) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get zeta clients: %w", err) + return E2EClients{}, fmt.Errorf("failed to get zeta clients: %w", err) } zevmClient, zevmAuth, err := getEVMClient(ctx, conf.RPCs.Zevm, account) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get zevm client: %w", err) + return E2EClients{}, fmt.Errorf("failed to get zevm client: %w", err) } - return btcRPCClient, - evmClient, - evmAuth, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightclientClient, - zevmClient, - zevmAuth, - nil + + return E2EClients{ + BtcRPCClient: btcRPCClient, + SolanaClient: solanaClient, + EvmClient: evmClient, + EvmAuth: evmAuth, + CctxClient: cctxClient, + FungibleClient: fungibleClient, + AuthClient: authClient, + BankClient: bankClient, + ObserverClient: observerClient, + LightClient: lightclientClient, + ZevmClient: zevmClient, + ZevmAuth: zevmAuth, + }, nil } // getBtcClient get btc client diff --git a/cmd/zetae2e/config/config.go b/cmd/zetae2e/config/config.go index a0aedef1a6..f8e0fddafb 100644 --- a/cmd/zetae2e/config/config.go +++ b/cmd/zetae2e/config/config.go @@ -18,19 +18,8 @@ func RunnerFromConfig( logger *runner.Logger, opts ...runner.E2ERunnerOption, ) (*runner.E2ERunner, error) { - // initialize clients - btcRPCClient, - evmClient, - evmAuth, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightClient, - zevmClient, - zevmAuth, - err := getClientsFromConfig(ctx, conf, account) + // initialize all clients for E2E tests + e2eClients, err := getClientsFromConfig(ctx, conf, account) if err != nil { return nil, fmt.Errorf("failed to get clients from config: %w", err) } @@ -41,17 +30,19 @@ func RunnerFromConfig( name, ctxCancel, account, - evmClient, - zevmClient, - cctxClient, - fungibleClient, - authClient, - bankClient, - observerClient, - lightClient, - evmAuth, - zevmAuth, - btcRPCClient, + e2eClients.EvmClient, + e2eClients.ZevmClient, + e2eClients.CctxClient, + e2eClients.FungibleClient, + e2eClients.AuthClient, + e2eClients.BankClient, + e2eClients.ObserverClient, + e2eClients.LightClient, + e2eClients.EvmAuth, + e2eClients.ZevmAuth, + e2eClients.BtcRPCClient, + e2eClients.SolanaClient, + logger, opts..., ) @@ -74,6 +65,8 @@ func RunnerFromConfig( // ExportContractsFromRunner export contracts from the runner to config using a source config func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.Config { + conf.Contracts.Solana.GatewayProgramID = r.GatewayProgram.String() + // copy contracts from deployer runner conf.Contracts.EVM.ZetaEthAddr = config.DoubleQuotedString(r.ZetaEthAddr.Hex()) conf.Contracts.EVM.ConnectorEthAddr = config.DoubleQuotedString(r.ConnectorEthAddr.Hex()) @@ -85,6 +78,7 @@ func ExportContractsFromRunner(r *runner.E2ERunner, conf config.Config) config.C conf.Contracts.ZEVM.ETHZRC20Addr = config.DoubleQuotedString(r.ETHZRC20Addr.Hex()) conf.Contracts.ZEVM.ERC20ZRC20Addr = config.DoubleQuotedString(r.ERC20ZRC20Addr.Hex()) conf.Contracts.ZEVM.BTCZRC20Addr = config.DoubleQuotedString(r.BTCZRC20Addr.Hex()) + conf.Contracts.ZEVM.SOLZRC20Addr = config.DoubleQuotedString(r.SOLZRC20Addr.Hex()) conf.Contracts.ZEVM.UniswapFactoryAddr = config.DoubleQuotedString(r.UniswapV2FactoryAddr.Hex()) conf.Contracts.ZEVM.UniswapRouterAddr = config.DoubleQuotedString(r.UniswapV2RouterAddr.Hex()) conf.Contracts.ZEVM.ConnectorZEVMAddr = config.DoubleQuotedString(r.ConnectorZEVMAddr.Hex()) diff --git a/cmd/zetae2e/config/contracts.go b/cmd/zetae2e/config/contracts.go index 110038298a..aa1e541957 100644 --- a/cmd/zetae2e/config/contracts.go +++ b/cmd/zetae2e/config/contracts.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "github.com/gagliardetto/solana-go" "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/erc20custody.sol" zetaeth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zeta.eth.sol" zetaconnectoreth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zetaconnector.eth.sol" @@ -24,6 +25,11 @@ import ( func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { var err error + // set Solana contracts + if c := conf.Contracts.Solana.GatewayProgramID; c != "" { + r.GatewayProgram = solana.MustPublicKeyFromBase58(c) + } + // set EVM contracts if c := conf.Contracts.EVM.ZetaEthAddr; c != "" { r.ZetaEthAddr, err = c.AsEVMAddress() @@ -114,6 +120,17 @@ func setContractsFromConfig(r *runner.E2ERunner, conf config.Config) error { } } + if c := conf.Contracts.ZEVM.SOLZRC20Addr; c != "" { + r.SOLZRC20Addr, err = c.AsEVMAddress() + if err != nil { + return fmt.Errorf("invalid SOLZRC20Addr: %w", err) + } + r.SOLZRC20, err = zrc20.NewZRC20(r.SOLZRC20Addr, r.ZEVMClient) + if err != nil { + return err + } + } + if c := conf.Contracts.ZEVM.UniswapFactoryAddr; c != "" { r.UniswapV2FactoryAddr, err = c.AsEVMAddress() if err != nil { diff --git a/cmd/zetae2e/config/localnet.yml b/cmd/zetae2e/config/localnet.yml index cbd703ba6a..eb6ba8fbb9 100644 --- a/cmd/zetae2e/config/localnet.yml +++ b/cmd/zetae2e/config/localnet.yml @@ -20,6 +20,11 @@ additional_accounts: bech32_address: "zeta19q7czqysah6qg0n4y3l2a08gfzqxydla492v80" evm_address: "0x283d810090EdF4043E75247eAeBcE848806237fD" private_key: "7bb523963ee2c78570fb6113d886a4184d42565e8847f1cb639f5f5e2ef5b37a" + user_solana: + bech32_address: "zeta1zqlajgj0qr8rqylf2c572t0ux8vqt45d4zngpm" + evm_address: "0x103FD9224F00ce3013e95629e52DFc31D805D68d" + private_key: "dd53f191113d18e57bd4a5494a64a020ba7919c815d0a6d34a42ebb2839e9d95" + solana_private_key: "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C" user_ether: bech32_address: "zeta134rakuus43xn63yucgxhn88ywj8ewcv6ezn2ga" evm_address: "0x8D47Db7390AC4D3D449Cc20D799ce4748F97619A" @@ -59,6 +64,8 @@ rpcs: http_post_mode: true disable_tls: true params: regnet + solana: "http://solana:8899" zetacore_grpc: "zetacore0:9090" zetacore_rpc: "http://zetacore0:26657" + # contracts will be populated on first run \ No newline at end of file diff --git a/cmd/zetae2e/init.go b/cmd/zetae2e/init.go index 742b3ebd96..1f26814d24 100644 --- a/cmd/zetae2e/init.go +++ b/cmd/zetae2e/init.go @@ -26,7 +26,8 @@ func NewInitCmd() *cobra.Command { InitCmd.Flags(). StringVar(&initConf.RPCs.Zevm, "zevmURL", "http://zetacore0:8545", "--zevmURL http://zetacore0:8545") InitCmd.Flags().StringVar(&initConf.RPCs.Bitcoin.Host, "btcURL", "bitcoin:18443", "--grpcURL bitcoin:18443") - + InitCmd.Flags(). + StringVar(&initConf.RPCs.Solana, "solanaURL", "http://solana:8899", "--solanaURL http://solana:8899") InitCmd.Flags().StringVar(&initConf.ZetaChainID, "chainID", "athens_101-1", "--chainID athens_101-1") InitCmd.Flags().StringVar(&configFile, local.FlagConfigFile, "e2e.config", "--cfg ./e2e.config") diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 3b935c4791..887a21d43a 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -32,6 +32,7 @@ const ( flagTestAdmin = "test-admin" flagTestPerformance = "test-performance" flagTestCustom = "test-custom" + flagTestSolana = "test-solana" flagSkipRegular = "skip-regular" flagLight = "light" flagSetupOnly = "setup-only" @@ -62,6 +63,7 @@ func NewLocalCmd() *cobra.Command { cmd.Flags().Bool(flagTestAdmin, false, "set to true to run admin tests") cmd.Flags().Bool(flagTestPerformance, false, "set to true to run performance tests") cmd.Flags().Bool(flagTestCustom, false, "set to true to run custom tests") + cmd.Flags().Bool(flagTestSolana, false, "set to true to run solana tests") cmd.Flags().Bool(flagSkipRegular, false, "set to true to skip regular tests") cmd.Flags().Bool(flagLight, false, "run the most basic regular tests, useful for quick checks") cmd.Flags().Bool(flagSetupOnly, false, "set to true to only setup the networks") @@ -84,6 +86,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { testAdmin = must(cmd.Flags().GetBool(flagTestAdmin)) testPerformance = must(cmd.Flags().GetBool(flagTestPerformance)) testCustom = must(cmd.Flags().GetBool(flagTestCustom)) + testSolana = must(cmd.Flags().GetBool(flagTestSolana)) skipRegular = must(cmd.Flags().GetBool(flagSkipRegular)) light = must(cmd.Flags().GetBool(flagLight)) setupOnly = must(cmd.Flags().GetBool(flagSetupOnly)) @@ -176,6 +179,9 @@ func localE2ETest(cmd *cobra.Command, _ []string) { deployerRunner.SetupEVM(contractsDeployed, true) deployerRunner.SetZEVMContracts() + if testSolana { + deployerRunner.SetSolanaContracts(conf.AdditionalAccounts.UserSolana.SolanaPrivateKey.String()) + } noError(deployerRunner.FundEmissionsPool()) deployerRunner.MintERC20OnEvm(10000) @@ -238,6 +244,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestMessagePassingZEVMtoEVMRevertFailName, e2etests.TestMessagePassingEVMtoZEVMRevertFailName, } + bitcoinTests := []string{ e2etests.TestBitcoinDepositName, e2etests.TestBitcoinDepositRefundName, @@ -304,6 +311,9 @@ func localE2ETest(cmd *cobra.Command, _ []string) { if testCustom { eg.Go(miscTestRoutine(conf, deployerRunner, verbose, e2etests.TestMyTestName)) } + if testSolana { + eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, e2etests.TestSolanaDepositName)) + } // while tests are executed, monitor blocks in parallel to check if system txs are on top and they have biggest priority txPriorityErrCh := make(chan error, 1) diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go new file mode 100644 index 0000000000..ef8ffce1ea --- /dev/null +++ b/cmd/zetae2e/local/solana.go @@ -0,0 +1,60 @@ +package local + +import ( + "fmt" + "time" + + "github.com/fatih/color" + + "github.com/zeta-chain/zetacore/e2e/config" + "github.com/zeta-chain/zetacore/e2e/e2etests" + "github.com/zeta-chain/zetacore/e2e/runner" +) + +// solanaTestRoutine runs Solana related e2e tests +func solanaTestRoutine( + conf config.Config, + deployerRunner *runner.E2ERunner, + verbose bool, + testNames ...string, +) func() error { + return func() (err error) { + // initialize runner for solana test + solanaRunner, err := initTestRunner( + "solana", + conf, + deployerRunner, + conf.AdditionalAccounts.UserSolana, + runner.NewLogger(verbose, color.FgCyan, "solana"), + ) + if err != nil { + return err + } + + solanaRunner.Logger.Print("🏃 starting Solana tests") + startTime := time.Now() + solanaRunner.SetupSolanaAccount() + + // run solana test + testsToRun, err := solanaRunner.GetE2ETestsToRunByName( + e2etests.AllE2ETests, + testNames..., + ) + if err != nil { + return fmt.Errorf("solana tests failed: %v", err) + } + + if err := solanaRunner.RunE2ETests(testsToRun); err != nil { + return fmt.Errorf("solana tests failed: %v", err) + } + + // check gateway SOL balance against ZRC20 total supply + if err := solanaRunner.CheckSolanaTSSBalance(); err != nil { + return err + } + + solanaRunner.Logger.Print("🍾 solana tests completed in %s", time.Since(startTime).String()) + + return err + } +} diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index 880b6de8ad..928a9fe5a7 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -223,6 +223,20 @@ services: -rpcauth=smoketest:63acf9b8dccecce914d85ff8c044b78b$$5892f9bbc84f4364e79f0970039f88bdd823f168d4acc76099ab97b14a766a99 -txindex=1 + solana: + image: solana-local:latest + container_name: solana + hostname: solana + profiles: + - solana + - all + ports: + - "8899:8899" + networks: + mynetwork: + ipv4_address: 172.20.0.103 + entrypoint: [ "/usr/bin/start-solana.sh" ] + orchestrator: image: orchestrator:latest tty: true diff --git a/contrib/localnet/solana/Dockerfile b/contrib/localnet/solana/Dockerfile new file mode 100644 index 0000000000..5806947cca --- /dev/null +++ b/contrib/localnet/solana/Dockerfile @@ -0,0 +1,10 @@ +FROM ghcr.io/zeta-chain/solana-docker:1.18.15 + +WORKDIR /data +COPY ./start-solana.sh /usr/bin/start-solana.sh +RUN chmod +x /usr/bin/start-solana.sh +COPY ./gateway.so . +COPY ./gateway-keypair.json . + +ENTRYPOINT [ "bash" ] +CMD [ "/usr/bin/start-solana.sh" ] \ No newline at end of file diff --git a/contrib/localnet/solana/gateway-keypair.json b/contrib/localnet/solana/gateway-keypair.json new file mode 100644 index 0000000000..99c8b61dee --- /dev/null +++ b/contrib/localnet/solana/gateway-keypair.json @@ -0,0 +1 @@ +[148,138,110,3,169,253,42,101,79,110,149,110,112,214,41,163,75,28,36,29,241,151,41,200,135,185,252,180,158,191,166,156,119,192,217,18,69,149,119,145,212,43,144,149,176,111,89,140,102,63,193,127,241,148,51,161,170,62,19,196,239,253,6,192] \ No newline at end of file diff --git a/contrib/localnet/solana/gateway.so b/contrib/localnet/solana/gateway.so new file mode 100755 index 0000000000000000000000000000000000000000..185b938c3ca936bfc13c831b38af4b826e8bef8a GIT binary patch literal 278648 zcmeFa3w&KiaX)?}=f<`I#Ib!H=Mu+~9XpDsKsIS2^e2hzkVFv#E02qfs1w~NCb9yF zj;~3yI<&|E65;;ZDh?2#7OHzCOEUCV+?FWmYe-{P|q-=`r)GCT(+O=n%)Uz~L1T5|TG2Bn42W~<+OZ|FG$+3S! zy=1ze3-v6udexN8?KNw?WV#>K0!tStTv~=7t`0fbs)dqKDj#TTlP8~`2LAlK zAb30V?+5Kc;0ni2atT*FA!)Q0^&`cXBJT0U^{M*FXff`))sh{Q)*F${yst7sO56IkIDVz)Xftg$5>Hz5RD#{e4maEq2q|+J*4S@ zigK+~u2f_!#5dyi0fM>zkKG>x>0qnmm&PRt=2Me&`{Z(>@BQyAx6k9mELu)NRDY

;}2WkwX%am)-WjB$=KLjsrkJ0@_{ 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 z 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 ZRC20 TotalSupply (%d)", int64(tssTotalBalance*1e8), zrc20Supply.Int64()-10000000, @@ -121,6 +131,40 @@ func (r *E2ERunner) CheckBtcTSSBalance() error { return nil } +// CheckSolanaTSSBalance compares the gateway PDA balance with the total supply of the SOL ZRC20 on ZetaChain +func (r *E2ERunner) CheckSolanaTSSBalance() error { + zrc20Supply, err := r.SOLZRC20.TotalSupply(&bind.CallOpts{}) + if err != nil { + return err + } + + // get PDA received amount + pda := r.ComputePdaAddress() + balance, err := r.SolanaClient.GetBalance(r.Ctx, pda, rpc.CommitmentConfirmed) + require.NoError(r, err) + pdaReceivedAmount := balance.Value - SolanaPDAInitialBalance + + // the SOL balance in gateway PDA must not be less than the total supply on ZetaChain + // the amount minted to initialize the pool is subtracted from the total supply + // #nosec G115 test - always in range + if pdaReceivedAmount < (zrc20Supply.Uint64() - ZRC20SOLInitialSupply) { + // #nosec G115 test - always in range + return fmt.Errorf( + "SOL: Gateway PDA Received (%d) < ZRC20 TotalSupply (%d)", + pdaReceivedAmount, + zrc20Supply.Uint64()-ZRC20SOLInitialSupply, + ) + } + // #nosec G115 test - always in range + r.Logger.Info( + "SOL: Gateway PDA Received (%d) >= ZRC20 TotalSupply (%d)", + pdaReceivedAmount, + zrc20Supply.Int64()-ZRC20SOLInitialSupply, + ) + + return nil +} + func (r *E2ERunner) checkERC20TSSBalance() error { erc20Balance, err := r.ERC20.BalanceOf(&bind.CallOpts{}, r.ERC20CustodyAddr) if err != nil { diff --git a/e2e/runner/balances.go b/e2e/runner/balances.go index d1e19d4c61..f7ab0938c1 100644 --- a/e2e/runner/balances.go +++ b/e2e/runner/balances.go @@ -17,6 +17,7 @@ type AccountBalances struct { ZetaWZETA *big.Int ZetaERC20 *big.Int ZetaBTC *big.Int + ZetaSOL *big.Int EvmETH *big.Int EvmZETA *big.Int EvmERC20 *big.Int @@ -53,6 +54,10 @@ func (r *E2ERunner) GetAccountBalances(skipBTC bool) (AccountBalances, error) { if err != nil { return AccountBalances{}, err } + zetaSol, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + if err != nil { + return AccountBalances{}, err + } // evm evmEth, err := r.EVMClient.BalanceAt(r.Ctx, r.EVMAddress(), nil) @@ -82,6 +87,7 @@ func (r *E2ERunner) GetAccountBalances(skipBTC bool) (AccountBalances, error) { ZetaWZETA: zetaWZeta, ZetaERC20: zetaErc20, ZetaBTC: zetaBtc, + ZetaSOL: zetaSol, EvmETH: evmEth, EvmZETA: evmZeta, EvmERC20: evmErc20, @@ -149,7 +155,9 @@ func (r *E2ERunner) PrintAccountBalances(balances AccountBalances) { r.Logger.Print("Bitcoin:") r.Logger.Print("* BTC balance: %s", balances.BtcBTC) - return + // solana + r.Logger.Print("Solana:") + r.Logger.Print("* SOL balance: %s", balances.ZetaSOL.String()) } // PrintTotalDiff shows the difference in the account balances of the accounts used in the e2e test from two balances structs diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index a9c5c11d00..0dcffcb597 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -15,6 +15,8 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/erc20custody.sol" zetaeth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zeta.eth.sol" zetaconnectoreth "github.com/zeta-chain/protocol-contracts/pkg/contracts/evm/zetaconnector.eth.sol" @@ -50,15 +52,17 @@ func WithZetaTxServer(txServer *txserver.ZetaTxServer) E2ERunnerOption { // It also provides some helper functions type E2ERunner struct { // accounts - Account config.Account - TSSAddress ethcommon.Address - BTCTSSAddress btcutil.Address - BTCDeployerAddress *btcutil.AddressWitnessPubKeyHash + Account config.Account + TSSAddress ethcommon.Address + BTCTSSAddress btcutil.Address + BTCDeployerAddress *btcutil.AddressWitnessPubKeyHash + SolanaDeployerAddress solana.PublicKey // rpc clients ZEVMClient *ethclient.Client EVMClient *ethclient.Client BtcRPCClient *rpcclient.Client + SolanaClient *rpc.Client // grpc clients CctxClient crosschaintypes.QueryClient @@ -77,6 +81,9 @@ type E2ERunner struct { EVMAuth *bind.TransactOpts ZEVMAuth *bind.TransactOpts + // programs on Solana + GatewayProgram solana.PublicKey + // contracts evm ZetaEthAddr ethcommon.Address ZetaEth *zetaeth.ZetaEth @@ -95,6 +102,8 @@ type E2ERunner struct { ETHZRC20 *zrc20.ZRC20 BTCZRC20Addr ethcommon.Address BTCZRC20 *zrc20.ZRC20 + SOLZRC20Addr ethcommon.Address + SOLZRC20 *zrc20.ZRC20 UniswapV2FactoryAddr ethcommon.Address UniswapV2Factory *uniswapv2factory.UniswapV2Factory UniswapV2RouterAddr ethcommon.Address @@ -140,6 +149,7 @@ func NewE2ERunner( evmAuth *bind.TransactOpts, zevmAuth *bind.TransactOpts, btcRPCClient *rpcclient.Client, + solanaClient *rpc.Client, logger *Logger, opts ...E2ERunnerOption, ) *E2ERunner { @@ -161,6 +171,7 @@ func NewE2ERunner( EVMAuth: evmAuth, ZEVMAuth: zevmAuth, BtcRPCClient: btcRPCClient, + SolanaClient: solanaClient, Logger: logger, } @@ -188,6 +199,7 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { r.ERC20ZRC20Addr = other.ERC20ZRC20Addr r.ETHZRC20Addr = other.ETHZRC20Addr r.BTCZRC20Addr = other.BTCZRC20Addr + r.SOLZRC20Addr = other.SOLZRC20Addr r.UniswapV2FactoryAddr = other.UniswapV2FactoryAddr r.UniswapV2RouterAddr = other.UniswapV2RouterAddr r.ConnectorZEVMAddr = other.ConnectorZEVMAddr @@ -198,6 +210,8 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { r.SystemContractAddr = other.SystemContractAddr r.ZevmTestDAppAddr = other.ZevmTestDAppAddr + r.GatewayProgram = other.GatewayProgram + // create instances of contracts r.ZetaEth, err = zetaeth.NewZetaEth(r.ZetaEthAddr, r.EVMClient) if err != nil { @@ -227,6 +241,10 @@ func (r *E2ERunner) CopyAddressesFrom(other *E2ERunner) (err error) { if err != nil { return err } + r.SOLZRC20, err = zrc20.NewZRC20(r.SOLZRC20Addr, r.ZEVMClient) + if err != nil { + return err + } r.UniswapV2Factory, err = uniswapv2factory.NewUniswapV2Factory(r.UniswapV2FactoryAddr, r.ZEVMClient) if err != nil { return err @@ -273,12 +291,15 @@ func (r *E2ERunner) Unlock() { // the printed contracts are grouped in a zevm and evm section // there is a padding used to print the addresses at the same position func (r *E2ERunner) PrintContractAddresses() { + r.Logger.Print(" --- 📜Solana addresses ---") + r.Logger.Print("GatewayProgram: %s", r.GatewayProgram.String()) // zevm contracts r.Logger.Print(" --- 📜zEVM contracts ---") r.Logger.Print("SystemContract: %s", r.SystemContractAddr.Hex()) r.Logger.Print("ETHZRC20: %s", r.ETHZRC20Addr.Hex()) r.Logger.Print("ERC20ZRC20: %s", r.ERC20ZRC20Addr.Hex()) r.Logger.Print("BTCZRC20: %s", r.BTCZRC20Addr.Hex()) + r.Logger.Print("SOLZRC20: %s", r.SOLZRC20Addr.Hex()) r.Logger.Print("UniswapFactory: %s", r.UniswapV2FactoryAddr.Hex()) r.Logger.Print("UniswapRouter: %s", r.UniswapV2RouterAddr.Hex()) r.Logger.Print("ConnectorZEVM: %s", r.ConnectorZEVMAddr.Hex()) @@ -286,15 +307,15 @@ func (r *E2ERunner) PrintContractAddresses() { r.Logger.Print("ZEVMSwapApp: %s", r.ZEVMSwapAppAddr.Hex()) r.Logger.Print("ContextApp: %s", r.ContextAppAddr.Hex()) - r.Logger.Print("TestDappZEVM: %s", r.ZevmTestDAppAddr.Hex()) + r.Logger.Print("TestDappZEVM: %s", r.ZevmTestDAppAddr.Hex()) // evm contracts r.Logger.Print(" --- 📜EVM contracts ---") r.Logger.Print("ZetaEth: %s", r.ZetaEthAddr.Hex()) r.Logger.Print("ConnectorEth: %s", r.ConnectorEthAddr.Hex()) r.Logger.Print("ERC20Custody: %s", r.ERC20CustodyAddr.Hex()) - r.Logger.Print("ERC20: %s", r.ERC20Addr.Hex()) - r.Logger.Print("TestDappEVM: %s", r.EvmTestDAppAddr.Hex()) + r.Logger.Print("ERC20: %s", r.ERC20Addr.Hex()) + r.Logger.Print("TestDappEVM: %s", r.EvmTestDAppAddr.Hex()) } // Errorf logs an error message. Mimics the behavior of testing.T.Errorf diff --git a/e2e/runner/setup_bitcoin.go b/e2e/runner/setup_bitcoin.go index 0a0e2c0e09..ebca4d95a7 100644 --- a/e2e/runner/setup_bitcoin.go +++ b/e2e/runner/setup_bitcoin.go @@ -30,7 +30,7 @@ func (r *E2ERunner) SetupBitcoinAccount(initNetwork bool) { r.Logger.Print("⚙️ setting up Bitcoin account") startTime := time.Now() defer func() { - r.Logger.Print("✅ Bitcoin account setup in %s\n", time.Since(startTime)) + r.Logger.Print("✅ Bitcoin account setup in %s", time.Since(startTime)) }() _, err := r.BtcRPCClient.CreateWallet(r.Name, rpcclient.WithCreateWalletBlank()) diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go new file mode 100644 index 0000000000..c9663dc3b8 --- /dev/null +++ b/e2e/runner/setup_solana.go @@ -0,0 +1,91 @@ +package runner + +import ( + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" +) + +func (r *E2ERunner) SetupSolanaAccount() { + r.Logger.Print("⚙️ setting up Solana account") + startTime := time.Now() + defer func() { + r.Logger.Print("✅ Solana account setup in %s", time.Since(startTime)) + }() + + r.SetSolanaAddress() +} + +// SetSolanaAddress imports the deployer's private key +func (r *E2ERunner) SetSolanaAddress() { + privateKey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + r.SolanaDeployerAddress = privateKey.PublicKey() + + r.Logger.Info("SolanaDeployerAddress: %s", r.SolanaDeployerAddress) +} + +// SetSolanaContracts set Solana contracts +func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { + r.Logger.Print("⚙️ setting up Solana contracts") + + // set Solana contracts + r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) + + // get deployer account balance + privkey := solana.MustPrivateKeyFromBase58(deployerPrivateKey) + bal, err := r.SolanaClient.GetBalance(r.Ctx, privkey.PublicKey(), rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Info("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) + + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + + // create 'initialize' instruction + var inst solana.GenericInstruction + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(r.GatewayProgram)) + inst.ProgID = r.GatewayProgram + inst.AccountValues = accountSlice + + inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ + Discriminator: solanacontract.DiscriminatorInitialize(), + TssAddress: r.TSSAddress, + ChainID: uint64(chains.SolanaLocalnet.ChainId), + }) + require.NoError(r, err) + + // create and sign the transaction + signedTx := r.CreateSignedTransaction([]solana.Instruction{&inst}, privkey) + + // broadcast the transaction and wait for finalization + _, out := r.BroadcastTxSync(signedTx) + r.Logger.Info("initialize logs: %v", out.Meta.LogMessages) + + // retrieve the PDA account info + pdaInfo, err := r.SolanaClient.GetAccountInfo(r.Ctx, pdaComputed) + require.NoError(r, err) + + // deserialize the PDA info + pda := solanacontract.PdaInfo{} + err = borsh.Deserialize(&pda, pdaInfo.Bytes()) + require.NoError(r, err) + tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) + + // check the TSS address + require.Equal(r, r.TSSAddress, tssAddress, "TSS address mismatch") + + // show the PDA balance + balance, err := r.SolanaClient.GetBalance(r.Ctx, pdaComputed, rpc.CommitmentConfirmed) + require.NoError(r, err) + r.Logger.Info("initial PDA balance: %d lamports", balance.Value) +} diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index 859f83d3e4..7007d60484 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -125,6 +125,7 @@ func (r *E2ERunner) SetZEVMContracts() { // set ZRC20 contracts r.SetupETHZRC20() r.SetupBTCZRC20() + r.SetupSOLZRC20() // deploy TestDApp contract on zEVM appAddr, txApp, _, err := testdapp.DeployTestDApp( @@ -205,6 +206,25 @@ func (r *E2ERunner) SetupBTCZRC20() { r.BTCZRC20 = BTCZRC20 } +// SetupSOLZRC20 sets up the SOL ZRC20 in the runner from the values queried from the chain +func (r *E2ERunner) SetupSOLZRC20() { + // set SOLZRC20 address by chain ID + SOLZRC20Addr, err := r.SystemContract.GasCoinZRC20ByChainId( + &bind.CallOpts{}, + big.NewInt(chains.SolanaLocalnet.ChainId), + ) + require.NoError(r, err) + + // set SOLZRC20 address + r.SOLZRC20Addr = SOLZRC20Addr + r.Logger.Info("SOLZRC20Addr: %s", SOLZRC20Addr.Hex()) + + // set SOLZRC20 contract + SOLZRC20, err := zrc20.NewZRC20(SOLZRC20Addr, r.ZEVMClient) + require.NoError(r, err) + r.SOLZRC20 = SOLZRC20 +} + // EnableHeaderVerification enables the header verification for the given chain IDs func (r *E2ERunner) EnableHeaderVerification(chainIDList []int64) error { r.Logger.Print("⚙️ enabling verification flags for block headers") diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go new file mode 100644 index 0000000000..3338eaa6f4 --- /dev/null +++ b/e2e/runner/solana.go @@ -0,0 +1,107 @@ +package runner + +import ( + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/stretchr/testify/require" + + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" +) + +// ComputePdaAddress computes the PDA address for the gateway program +func (r *E2ERunner) ComputePdaAddress() solana.PublicKey { + seed := []byte(solanacontract.PDASeed) + GatewayProgramID := solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, GatewayProgramID) + require.NoError(r, err) + + r.Logger.Info("computed pda: %s, bump %d\n", pdaComputed, bump) + + return pdaComputed +} + +// CreateDepositInstruction creates a 'deposit' instruction +func (r *E2ERunner) CreateDepositInstruction( + signer solana.PublicKey, + receiver ethcommon.Address, + amount uint64, +) solana.Instruction { + // compute the gateway PDA address + pdaComputed := r.ComputePdaAddress() + programID := r.GatewayProgram + + // create 'deposit' instruction + inst := &solana.GenericInstruction{} + accountSlice := []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID + inst.AccountValues = accountSlice + + var err error + inst.DataBytes, err = borsh.Serialize(solanacontract.DepositInstructionParams{ + Discriminator: solanacontract.DiscriminatorDeposit(), + Amount: amount, + Memo: receiver.Bytes(), + }) + require.NoError(r, err) + + return inst +} + +// CreateSignedTransaction creates a signed transaction from instructions +func (r *E2ERunner) CreateSignedTransaction( + instructions []solana.Instruction, + privateKey solana.PrivateKey, +) *solana.Transaction { + // get a recent blockhash + recent, err := r.SolanaClient.GetRecentBlockhash(r.Ctx, rpc.CommitmentFinalized) + require.NoError(r, err) + + // create the initialize transaction + tx, err := solana.NewTransaction( + instructions, + recent.Value.Blockhash, + solana.TransactionPayer(privateKey.PublicKey()), + ) + require.NoError(r, err) + + // sign the initialize transaction + _, err = tx.Sign( + func(key solana.PublicKey) *solana.PrivateKey { + if privateKey.PublicKey().Equals(key) { + return &privateKey + } + return nil + }, + ) + require.NoError(r, err) + + return tx +} + +// BroadcastTxSync broadcasts a transaction and waits for it to be finalized +func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, *rpc.GetTransactionResult) { + // broadcast the transaction + sig, err := r.SolanaClient.SendTransactionWithOpts(r.Ctx, tx, rpc.TransactionOpts{}) + require.NoError(r, err) + r.Logger.Info("broadcast success! tx sig %s; waiting for confirmation...", sig) + + // wait for the transaction to be finalized + var out *rpc.GetTransactionResult + for { + time.Sleep(1 * time.Second) + out, err = r.SolanaClient.GetTransaction(r.Ctx, sig, &rpc.GetTransactionOpts{}) + if err == nil { + break + } + } + + return sig, out +} diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 0af460607f..23ae24da4f 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -382,6 +382,21 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) } + // deploy sol zrc20 + _, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( + addr.String(), + "", + chains.SolanaLocalnet.ChainId, + 9, + "Solana", + "SOL", + coin.CoinType_Gas, + 100000, + )) + if err != nil { + return "", "", "", "", "", fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) + } + // deploy erc20 zrc20 res, err = zts.BroadcastTx(account, fungibletypes.NewMsgDeployFungibleCoinZRC20( addr.String(), diff --git a/go.mod b/go.mod index c730cd2222..8bd9e6992e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/cosmos/cosmos-sdk v0.47.10 github.com/cosmos/gogoproto v1.4.10 github.com/ethereum/go-ethereum v1.10.26 + github.com/gagliardetto/solana-go v1.10.0 github.com/gogo/protobuf v1.3.3 // indirect github.com/golang/protobuf v1.5.3 github.com/gorilla/mux v1.8.0 @@ -22,7 +23,6 @@ require ( google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 // indirect google.golang.org/grpc v1.60.1 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c - ) require ( @@ -65,6 +65,7 @@ require ( github.com/golang/mock v1.6.0 github.com/huandu/skiplist v1.2.0 github.com/nanmu42/etherscan-api v1.10.0 + github.com/near/borsh-go v0.3.1 github.com/onrik/ethrpc v1.2.0 go.nhat.io/grpcmock v0.25.0 ) @@ -77,6 +78,8 @@ require ( github.com/DataDog/zstd v1.5.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect + github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect github.com/bool64/shared v0.1.5 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect @@ -91,6 +94,8 @@ require ( github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect github.com/getsentry/sentry-go v0.23.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -100,13 +105,19 @@ require ( github.com/iancoleman/orderedmap v0.3.0 // indirect github.com/ipfs/boxo v0.10.0 // indirect github.com/jmhodges/levigo v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/libp2p/go-yamux/v4 v4.0.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect github.com/onsi/gomega v1.27.7 // indirect github.com/prometheus/tsdb v0.7.1 // indirect github.com/rjeczalik/notify v0.9.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect github.com/swaggest/assertjson v1.9.0 // indirect github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect github.com/thales-e-security/pool v0.0.2 // indirect @@ -117,6 +128,7 @@ require ( github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.etcd.io/bbolt v1.3.7 // indirect + go.mongodb.org/mongo-driver v1.11.0 // indirect go.nhat.io/matcher/v2 v2.0.0 // indirect go.nhat.io/wait v0.1.0 // indirect go.opentelemetry.io/otel v1.19.0 // indirect @@ -124,6 +136,7 @@ require ( go.opentelemetry.io/otel/trace v1.19.0 // indirect go.uber.org/dig v1.17.0 // indirect go.uber.org/fx v1.19.2 // indirect + go.uber.org/ratelimit v0.2.0 // indirect golang.org/x/time v0.5.0 // indirect gonum.org/v1/gonum v0.13.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 // indirect @@ -196,7 +209,7 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/orderedcode v0.0.1 // indirect - github.com/google/uuid v1.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect diff --git a/go.sum b/go.sum index 7003b25c27..9cb5c7d214 100644 --- a/go.sum +++ b/go.sum @@ -217,6 +217,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN github.com/99designs/keyring v1.1.6/go.mod h1:16e0ds7LGQQcT59QqkTg72Hh5ShM51Byv5PEmW6uoRU= github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= +github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= @@ -287,6 +289,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= @@ -337,6 +341,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= @@ -639,6 +645,12 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.10.0 h1:lDuHGC+XLxw9j8fCHBZM9tv4trI0PVhev1m9NAMaIdM= +github.com/gagliardetto/solana-go v1.10.0/go.mod h1:afBEcIRrDLJst3lvAahTr63m6W2Ns6dajZxe2irF7Jg= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= @@ -849,8 +861,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= @@ -1088,8 +1100,10 @@ github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -1158,6 +1172,8 @@ github.com/libp2p/go-yamux/v4 v4.0.0 h1:+Y80dV2Yx/kv7Y7JKu0LECyVdMXm1VUoko+VQ9rB github.com/libp2p/go-yamux/v4 v4.0.0/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasjones/reggen v0.0.0-20180717132126-cdb49ff09d77/go.mod h1:5ELEyG+X8f+meRWHuqUOewBOhvHkl7M76pdGEansxW4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -1261,6 +1277,9 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -1309,6 +1328,8 @@ github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7 github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/near/borsh-go v0.3.1 h1:ukNbhJlPKxfua0/nIuMZhggSU8zvtRP/VyC25LLqPUA= +github.com/near/borsh-go v0.3.1/go.mod h1:NeMochZp7jN/pYFuxLkrZtmLqbADmnp/y1+/dL+AsyQ= github.com/neilotoole/errgroup v0.1.5/go.mod h1:Q2nLGf+594h0CLBs/Mbg6qOr7GtqDK7C2S41udRnToE= github.com/neilotoole/errgroup v0.1.6/go.mod h1:Q2nLGf+594h0CLBs/Mbg6qOr7GtqDK7C2S41udRnToE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -1512,6 +1533,8 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/shirou/gopsutil v2.20.5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -1568,6 +1591,8 @@ github.com/steakknife/hamming v0.0.0-20180906055917-c99c65617cd3/go.mod h1:hpGUW github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -1611,6 +1636,8 @@ github.com/tendermint/tendermint v0.34.12/go.mod h1:aeHL7alPh4uTBIJQ8mgFEE8VwJLX github.com/tendermint/tm-db v0.6.2/go.mod h1:GYtQ67SUvATOcoY8/+x6ylk8Qo02BQyLrAs+yAcLvGI= github.com/tendermint/tm-db v0.6.3/go.mod h1:lfA1dL9/Y/Y8wwyPp2NMLyn5P5Ptr/gvDFNWtrCWSf8= github.com/tendermint/tm-db v0.6.4/go.mod h1:dptYhIpJ2M5kUuenLr+Yyf3zQOv1SgBZcl8/BmWlMBw= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg= @@ -1624,6 +1651,7 @@ github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vl github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -1679,6 +1707,9 @@ github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 h1:EKhdz github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h1:8UvriyWtv5Q5EOgjHaSseUEdkQfvwFv1I/In/O2M9gc= github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/wsddn/go-ecdh v0.0.0-20161211032359-48726bab9208/go.mod h1:IotVbo4F+mw0EzQ08zFqg7pK3FebNXpaMsRy2RT+Ees= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= @@ -1689,6 +1720,7 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRT github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/ybbus/jsonrpc v2.1.2+incompatible/go.mod h1:XJrh1eMSzdIYFbM08flv0wp5G35eRniyeGut1z+LSiE= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= @@ -1727,6 +1759,8 @@ go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mI go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.mongodb.org/mongo-driver v1.11.0 h1:FZKhBSTydeuffHj9CBjXlR8vQLee1cQyTWYPA6/tqiE= +go.mongodb.org/mongo-driver v1.11.0/go.mod h1:s7p5vEtfbeR1gYi6pnj3c3/urpbLv2T5Sfd6Rp2HBB8= go.nhat.io/aferomock v0.4.0 h1:gs3nJzIqAezglUuaPfautAmZwulwRWLcfSSzdK4YCC0= go.nhat.io/aferomock v0.4.0/go.mod h1:msi5MDOtJ/AroUa/lDc3jVGOILM4SKP//4yBRImOvkI= go.nhat.io/grpcmock v0.25.0 h1:zk03vvA60w7UrnurZbqL4wxnjmJz1Kuyb7ig2MF+n4c= @@ -1768,6 +1802,7 @@ go.uber.org/fx v1.19.2 h1:SyFgYQFr1Wl0AYstE8vyYIzP4bFz2URrScjwC4cwUvY= go.uber.org/fx v1.19.2/go.mod h1:43G1VcqSzbIv77y00p1DRAsyZS8WdzuYdhZXmEUkMyQ= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= @@ -1776,6 +1811,8 @@ go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKY go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA= +go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= @@ -1784,6 +1821,7 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1812,6 +1850,8 @@ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1922,6 +1962,7 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= diff --git a/pkg/bg/bg.go b/pkg/bg/bg.go index 85d85964cf..3fd6a74fc3 100644 --- a/pkg/bg/bg.go +++ b/pkg/bg/bg.go @@ -4,6 +4,7 @@ package bg import ( "context" "fmt" + "runtime" "github.com/rs/zerolog" ) @@ -38,25 +39,42 @@ func Work(ctx context.Context, f func(context.Context) error, opts ...Opt) { defer func() { if r := recover(); r != nil { err := fmt.Errorf("recovered from PANIC in background task: %v", r) - logError(err, cfg) + logError(err, cfg, true) } }() if err := f(ctx); err != nil { - logError(err, cfg) + logError(err, cfg, false) } }() } -func logError(err error, cfg config) { +func logError(err error, cfg config, isPanic bool) { if err == nil { return } + evt := cfg.logger.Error().Err(err) + + // print stack trace when a panic occurs + if isPanic { + buf := make([]byte, 1024) + for { + n := runtime.Stack(buf, false) + if n < len(buf) { + buf = buf[:n] + break + } + buf = make([]byte, 2*len(buf)) + } + + evt.Bytes("stack_trace", buf) + } + name := cfg.name if name == "" { name = "unknown" } - cfg.logger.Error().Err(err).Str("worker.name", name).Msgf("Background task failed") + evt.Str("worker.name", name).Msg("Background task failed") } diff --git a/pkg/bg/bg_test.go b/pkg/bg/bg_test.go index c55b6287f9..6bbcffd003 100644 --- a/pkg/bg/bg_test.go +++ b/pkg/bg/bg_test.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWork(t *testing.T) { @@ -69,9 +70,12 @@ func TestWork(t *testing.T) { time.Sleep(100 * time.Millisecond) // Check the log output - const expected = `{"level":"error","error":"recovered from PANIC in background task: press F",` + - `"worker.name":"unknown","message":"Background task failed"}` - assert.JSONEq(t, expected, out.String()) + const expectedError = "recovered from PANIC in background task: press F" + const expectedWorker = "unknown" + const expectedMessage = "Background task failed" + require.Contains(t, out.String(), expectedError) + require.Contains(t, out.String(), expectedWorker) + require.Contains(t, out.String(), expectedMessage) }) } diff --git a/pkg/chains/chain.go b/pkg/chains/chain.go index 46ead5f9f2..6e2ea1d500 100644 --- a/pkg/chains/chain.go +++ b/pkg/chains/chain.go @@ -93,6 +93,8 @@ func DecodeAddressFromChainID(chainID int64, addr string, additionalChains []Cha return ethcommon.HexToAddress(addr).Bytes(), nil case IsBitcoinChain(chainID, additionalChains): return []byte(addr), nil + case IsSolanaChain(chainID, additionalChains): + return []byte(addr), nil default: return nil, fmt.Errorf("chain (%d) not supported", chainID) } @@ -112,6 +114,11 @@ func IsBitcoinChain(chainID int64, additionalChains []Chain) bool { return ChainIDInChainList(chainID, ChainListByConsensus(Consensus_bitcoin, additionalChains)) } +// IsSolanaChain returns true if the chain is a Solana chain +func IsSolanaChain(chainID int64, additionalChains []Chain) bool { + return ChainIDInChainList(chainID, ChainListByNetwork(Network_solana, additionalChains)) +} + // IsEthereumChain returns true if the chain is an Ethereum chain // additionalChains is a list of additional chains to search from // in practice, it is used in the protocol to dynamically support new chains without doing an upgrade diff --git a/pkg/contract/solana/contract.go b/pkg/contract/solana/contract.go new file mode 100644 index 0000000000..2e2acd7ad6 --- /dev/null +++ b/pkg/contract/solana/contract.go @@ -0,0 +1,38 @@ +package solana + +const ( + // SolanaGatewayProgramID is the program ID of the Solana gateway program + SolanaGatewayProgramID = "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + + // PDASeed is the seed for the Solana gateway program derived address + PDASeed = "meta" + + // AccountsNumberOfDeposit is the number of accounts required for Solana gateway deposit instruction + // [signer, pda, system_program, gateway_program] + AccountsNumDeposit = 4 +) + +// DiscriminatorInitialize returns the discriminator for Solana gateway 'initialize' instruction +func DiscriminatorInitialize() [8]byte { + return [8]byte{175, 175, 109, 31, 13, 152, 155, 237} +} + +// DiscriminatorDeposit returns the discriminator for Solana gateway 'deposit' instruction +func DiscriminatorDeposit() [8]byte { + return [8]byte{242, 35, 198, 137, 82, 225, 242, 182} +} + +// DiscriminatorDepositSPL returns the discriminator for Solana gateway 'deposit_spl_token' instruction +func DiscriminatorDepositSPL() [8]byte { + return [8]byte{86, 172, 212, 121, 63, 233, 96, 144} +} + +// DiscriminatorWithdraw returns the discriminator for Solana gateway 'withdraw' instruction +func DiscriminatorWithdraw() [8]byte { + return [8]byte{183, 18, 70, 156, 148, 109, 161, 34} +} + +// DiscriminatorWithdrawSPL returns the discriminator for Solana gateway 'withdraw_spl_token' instruction +func DiscriminatorWithdrawSPL() [8]byte { + return [8]byte{156, 234, 11, 89, 235, 246, 32} +} diff --git a/pkg/contract/solana/gateway.json b/pkg/contract/solana/gateway.json new file mode 100644 index 0000000000..8747c2ca0f --- /dev/null +++ b/pkg/contract/solana/gateway.json @@ -0,0 +1,420 @@ +{ + "address": "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", + "metadata": { + "name": "gateway", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "deposit", + "discriminator": [ + 242, + 35, + 198, + 137, + 82, + 225, + 242, + 182 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "memo", + "type": "bytes" + } + ] + }, + { + "name": "deposit_spl_token", + "discriminator": [ + 86, + 172, + 212, + 121, + 63, + 233, + 96, + 144 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "from", + "writable": true + }, + { + "name": "to", + "writable": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "memo", + "type": "bytes" + } + ] + }, + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "update_tss", + "discriminator": [ + 227, + 136, + 3, + 242, + 177, + 168, + 10, + 160 + ], + "accounts": [ + { + "name": "pda", + "writable": true + }, + { + "name": "signer", + "writable": true, + "signer": true + } + ], + "args": [ + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "withdraw", + "discriminator": [ + 183, + 18, + 70, + 156, + 148, + 109, + 161, + 34 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true + }, + { + "name": "to", + "writable": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + }, + { + "name": "withdraw_spl_token", + "discriminator": [ + 219, + 156, + 234, + 11, + 89, + 235, + 246, + 32 + ], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 109, + 101, + 116, + 97 + ] + } + ] + } + }, + { + "name": "from", + "writable": true + }, + { + "name": "to", + "writable": true + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "recovery_id", + "type": "u8" + }, + { + "name": "message_hash", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "nonce", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Pda", + "discriminator": [ + 169, + 245, + 0, + 205, + 225, + 36, + 43, + 94 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "SignerIsNotAuthority", + "msg": "SignerIsNotAuthority" + }, + { + "code": 6001, + "name": "InsufficientPoints", + "msg": "InsufficientPoints" + }, + { + "code": 6002, + "name": "NonceMismatch", + "msg": "NonceMismatch" + }, + { + "code": 6003, + "name": "TSSAuthenticationFailed", + "msg": "TSSAuthenticationFailed" + }, + { + "code": 6004, + "name": "DepositToAddressMismatch", + "msg": "DepositToAddressMismatch" + }, + { + "code": 6005, + "name": "MessageHashMismatch", + "msg": "MessageHashMismatch" + }, + { + "code": 6006, + "name": "MemoLengthExceeded", + "msg": "MemoLengthExceeded" + }, + { + "code": 6007, + "name": "MemoLengthTooShort", + "msg": "MemoLengthTooShort" + } + ], + "types": [ + { + "name": "Pda", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nonce", + "type": "u64" + }, + { + "name": "tss_address", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "authority", + "type": "pubkey" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/contract/solana/idl.go b/pkg/contract/solana/idl.go new file mode 100644 index 0000000000..425d2af779 --- /dev/null +++ b/pkg/contract/solana/idl.go @@ -0,0 +1,67 @@ +package solana + +type IDL struct { + Address string `json:"address"` + Metadata Metadata `json:"metadata"` + Instructions []Instruction `json:"instructions"` + Accounts []Account `json:"accounts"` + Errors []Error `json:"errors"` + Types []Type `json:"types"` +} + +type Metadata struct { + Name string `json:"name"` + Version string `json:"version"` + Spec string `json:"spec"` + Description string `json:"description"` +} + +type Instruction struct { + Name string `json:"name"` + Discriminator []byte `json:"discriminator"` + Accounts []Account `json:"accounts"` + Args []Arg `json:"args"` +} + +type Account struct { + Name string `json:"name"` + Writable bool `json:"writable,omitempty"` + Signer bool `json:"signer,omitempty"` + Address string `json:"address,omitempty"` + PDA *PDA `json:"pda,omitempty"` +} + +type PDA struct { + Seeds []Seed `json:"seeds"` +} + +type Seed struct { + Kind string `json:"kind"` + Value []byte `json:"value,omitempty"` +} + +type Arg struct { + Name string `json:"name"` + Type interface{} `json:"type"` +} + +type Error struct { + Code int `json:"code"` + Name string `json:"name"` + Msg string `json:"msg"` +} + +type Type struct { + Name string `json:"name"` + Type TypeField `json:"type"` +} + +type TypeField struct { + Kind string `json:"kind"` + Fields []Field `json:"fields"` +} + +type Field struct { + Name string `json:"name"` + Type interface{} `json:"type"` +} diff --git a/pkg/contract/solana/types.go b/pkg/contract/solana/types.go new file mode 100644 index 0000000000..eede621c06 --- /dev/null +++ b/pkg/contract/solana/types.go @@ -0,0 +1,44 @@ +package solana + +// PdaInfo represents the PDA for the gateway program +type PdaInfo struct { + // Discriminator is the unique identifier for the PDA + Discriminator [8]byte + + // Nonce is the current nonce for the PDA + Nonce uint64 + + // TssAddress is the TSS address for the PDA + TssAddress [20]byte + + // Authority is the authority for the PDA + Authority [32]byte + + // ChainId is the chain ID for the gateway program + // Note: this field exists in latest version of gateway program, but not in the current e2e test program + // ChainId uint64 +} + +// InitializeParams contains the parameters for a gateway initialize instruction +type InitializeParams struct { + // Discriminator is the unique identifier for the initialize instruction + Discriminator [8]byte + + // TssAddress is the TSS address + TssAddress [20]byte + + // ChainID is the chain ID for the gateway program + ChainID uint64 +} + +// DepositInstructionParams contains the parameters for a gateway deposit instruction +type DepositInstructionParams struct { + // Discriminator is the unique identifier for the deposit instruction + Discriminator [8]byte + + // Amount is the lamports amount for the deposit + Amount uint64 + + // Memo is the memo for the deposit + Memo []byte +} diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index a5b62d7154..a46310fb25 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -15,6 +15,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" ethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/require" "github.com/zeta-chain/zetacore/pkg/cosmos" @@ -56,6 +57,31 @@ func EthAddress() ethcommon.Address { return ethcommon.BytesToAddress(sdk.AccAddress(ed25519.GenPrivKey().PubKey().Address()).Bytes()) } +// SolanaAddress returns a sample solana address +func SolanaAddress(t *testing.T) string { + keypair, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + return keypair.PublicKey().String() +} + +// SolanaSignature returns a sample solana signature +func SolanaSignature(t *testing.T) solana.Signature { + // Generate a random keypair + keypair, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + // Generate a random message to sign + // #nosec G404 test purpose - weak randomness is not an issue here + r := rand.New(rand.NewSource(900)) + message := StringRandom(r, 64) + + // Sign the message with the private key + signature, err := keypair.Sign([]byte(message)) + require.NoError(t, err) + + return signature +} + // Hash returns a sample hash func Hash() ethcommon.Hash { return EthAddress().Hash() diff --git a/testutil/sample/zetaclient.go b/testutil/sample/zetaclient.go new file mode 100644 index 0000000000..36f9c7292c --- /dev/null +++ b/testutil/sample/zetaclient.go @@ -0,0 +1,25 @@ +package sample + +import ( + "github.com/zeta-chain/zetacore/pkg/coin" + "github.com/zeta-chain/zetacore/zetaclient/types" +) + +// InboundEvent returns a sample InboundEvent. +func InboundEvent(chainID int64, sender string, receiver string, amount uint64, memo []byte) *types.InboundEvent { + r := newRandFromSeed(chainID) + + return &types.InboundEvent{ + SenderChainID: chainID, + Sender: sender, + Receiver: receiver, + TxOrigin: sender, + Amount: amount, + Memo: memo, + BlockNumber: r.Uint64(), + TxHash: StringRandom(r, 32), + Index: 0, + CoinType: coin.CoinType(r.Intn(100)), + Asset: StringRandom(r, 32), + } +} diff --git a/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go b/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go index 8dedb591e2..f08dcadadc 100644 --- a/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go +++ b/x/crosschain/keeper/grpc_query_cctx_rate_limit_test.go @@ -22,6 +22,9 @@ var ( // local btc chain ID btcChainID = getValidBtcChainID() + + // local solana chain ID + solanaChainID = getValidSolanaChainID() ) // createTestRateLimiterFlags creates a custom rate limiter flags @@ -458,6 +461,12 @@ func TestKeeper_RateLimiterInput(t *testing.T) { setCctxsInKeeper(ctx, *k, zk, tss, tt.btcPendingCctxs) zk.ObserverKeeper.SetPendingNonces(ctx, tt.btcPendingNonces) + // Set Solana chain pending nonce as zeros (to avoid error on ListPendingCctxWithinRateLimit) + zk.ObserverKeeper.SetPendingNonces(ctx, observertypes.PendingNonces{ + ChainId: solanaChainID, + Tss: tss.TssPubkey, + }) + // Set current block height ctx = ctx.WithBlockHeight(tt.currentHeight) @@ -1015,6 +1024,12 @@ func TestKeeper_ListPendingCctxWithinRateLimit(t *testing.T) { setCctxsInKeeper(ctx, *k, zk, tss, tt.btcPendingCctxs) zk.ObserverKeeper.SetPendingNonces(ctx, tt.btcPendingNonces) + // Set Solana chain pending nonce as zeros (to avoid error on ListPendingCctxWithinRateLimit) + zk.ObserverKeeper.SetPendingNonces(ctx, observertypes.PendingNonces{ + ChainId: solanaChainID, + Tss: tss.TssPubkey, + }) + // Set current block height ctx = ctx.WithBlockHeight(tt.currentHeight) diff --git a/x/crosschain/keeper/utils_test.go b/x/crosschain/keeper/utils_test.go index 6da6a501a2..ce5aa82447 100644 --- a/x/crosschain/keeper/utils_test.go +++ b/x/crosschain/keeper/utils_test.go @@ -41,6 +41,10 @@ func getValidBtcChainID() int64 { return getValidBTCChain().ChainId } +func getValidSolanaChainID() int64 { + return chains.SolanaLocalnet.ChainId +} + // getValidEthChainIDWithIndex get a valid eth chain id with index func getValidEthChainIDWithIndex(t *testing.T, index int) int64 { switch index { diff --git a/x/observer/genesis.go b/x/observer/genesis.go index d39a771535..366424a062 100644 --- a/x/observer/genesis.go +++ b/x/observer/genesis.go @@ -26,12 +26,15 @@ func InitGenesis(ctx sdk.Context, k keeper.Keeper, genState types.GenesisState) btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true k.SetChainParamsList(ctx, types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, }) diff --git a/x/observer/genesis_test.go b/x/observer/genesis_test.go index 73edbc1946..78f0bea2c1 100644 --- a/x/observer/genesis_test.go +++ b/x/observer/genesis_test.go @@ -68,12 +68,15 @@ func TestGenesis(t *testing.T) { btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true localnetChainParams := types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, } @@ -104,12 +107,15 @@ func TestGenesis(t *testing.T) { btcChainParams.IsSupported = true goerliChainParams := types.GetDefaultGoerliLocalnetChainParams() goerliChainParams.IsSupported = true + solanaChainParams := types.GetDefaultSolanaLocalnetChainParams() + solanaChainParams.IsSupported = true zetaPrivnetChainParams := types.GetDefaultZetaPrivnetChainParams() zetaPrivnetChainParams.IsSupported = true localnetChainParams := types.ChainParamsList{ ChainParams: []*types.ChainParams{ btcChainParams, goerliChainParams, + solanaChainParams, zetaPrivnetChainParams, }, } diff --git a/x/observer/types/chain_params.go b/x/observer/types/chain_params.go index 665be3de98..b2cbb63c32 100644 --- a/x/observer/types/chain_params.go +++ b/x/observer/types/chain_params.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" ) const ( @@ -313,6 +314,25 @@ func GetDefaultBtcRegtestChainParams() *ChainParams { IsSupported: false, } } +func GetDefaultSolanaLocalnetChainParams() *ChainParams { + return &ChainParams{ + ChainId: chains.SolanaLocalnet.ChainId, + ConfirmationCount: 32, + ZetaTokenContractAddress: zeroAddress, + ConnectorContractAddress: zeroAddress, + Erc20CustodyContractAddress: zeroAddress, + GasPriceTicker: 100, + WatchUtxoTicker: 0, + InboundTicker: 5, + OutboundTicker: 5, + OutboundScheduleInterval: 10, + OutboundScheduleLookahead: 10, + BallotThreshold: DefaultBallotThreshold, + MinObserverDelegation: DefaultMinObserverDelegation, + IsSupported: false, + GatewayAddress: solanacontract.SolanaGatewayProgramID, + } +} func GetDefaultGoerliLocalnetChainParams() *ChainParams { return &ChainParams{ ChainId: chains.GoerliLocalnet.ChainId, diff --git a/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index e4cd8eb65f..b3eb554a96 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -1,6 +1,7 @@ package base import ( + "context" "fmt" "os" "strconv" @@ -16,10 +17,12 @@ import ( "gorm.io/gorm/logger" "github.com/zeta-chain/zetacore/pkg/chains" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" "github.com/zeta-chain/zetacore/zetaclient/metrics" clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" + "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) const ( @@ -56,6 +59,9 @@ type Observer struct { // lastBlockScanned is the last block height scanned by the observer lastBlockScanned uint64 + // lastTxScanned is the last transaction hash scanned by the observer + lastTxScanned string + // blockCache is the cache for blocks blockCache *lru.Cache @@ -97,6 +103,7 @@ func NewObserver( tss: tss, lastBlock: 0, lastBlockScanned: 0, + lastTxScanned: "", ts: ts, mu: &sync.Mutex{}, stop: make(chan struct{}), @@ -204,6 +211,17 @@ func (ob *Observer) WithLastBlockScanned(blockNumber uint64) *Observer { return ob } +// LastTxScanned get last transaction scanned. +func (ob *Observer) LastTxScanned() string { + return ob.lastTxScanned +} + +// WithLastTxScanned set last transaction scanned. +func (ob *Observer) WithLastTxScanned(txHash string) *Observer { + ob.lastTxScanned = txHash + return ob +} + // BlockCache returns the block cache for the observer. func (ob *Observer) BlockCache() *lru.Cache { return ob.blockCache @@ -299,7 +317,10 @@ func (ob *Observer) OpenDB(dbPath string, dbName string) error { } // migrate db - err = db.AutoMigrate(&clienttypes.LastBlockSQLType{}) + err = db.AutoMigrate( + &clienttypes.LastBlockSQLType{}, + &clienttypes.LastTransactionSQLType{}, + ) if err != nil { return errors.Wrap(err, "error migrating db") } @@ -350,8 +371,6 @@ func (ob *Observer) LoadLastBlockScanned(logger zerolog.Logger) error { return nil } ob.WithLastBlockScanned(blockNumber) - logger.Info(). - Msgf("LoadLastBlockScanned: chain %d starts scanning from block %d", ob.chain.ChainId, ob.LastBlockScanned()) return nil } @@ -377,7 +396,86 @@ func (ob *Observer) ReadLastBlockScannedFromDB() (uint64, error) { return lastBlock.Num, nil } -// EnvVarLatestBlock returns the environment variable for the latest block by chain. +// LoadLastTxScanned loads last scanned tx from environment variable or from database. +// The last scanned tx is the tx hash from which the observer should continue scanning. +func (ob *Observer) LoadLastTxScanned() { + // get environment variable + envvar := EnvVarLatestTxByChain(ob.chain) + scanFromTx := os.Getenv(envvar) + + // load from environment variable if set + if scanFromTx != "" { + ob.logger.Chain.Info().Msgf("LoadLastTxScanned: envvar %s is set; scan from tx %s", envvar, scanFromTx) + ob.WithLastTxScanned(scanFromTx) + return + } + + // load from DB otherwise. + txHash, err := ob.ReadLastTxScannedFromDB() + if err != nil { + // If not found, let the concrete chain observer decide where to start + ob.logger.Chain.Info().Msgf("LoadLastTxScanned: last scanned tx not found in db for chain %d", ob.chain.ChainId) + return + } + ob.WithLastTxScanned(txHash) +} + +// SaveLastTxScanned saves the last scanned tx hash to memory and database. +func (ob *Observer) SaveLastTxScanned(txHash string, slot uint64) error { + // save last scanned tx to memory + ob.WithLastTxScanned(txHash) + + // update last_scanned_block_number metrics + ob.WithLastBlockScanned(slot) + + return ob.WriteLastTxScannedToDB(txHash) +} + +// WriteLastTxScannedToDB saves the last scanned tx hash to the database. +func (ob *Observer) WriteLastTxScannedToDB(txHash string) error { + return ob.db.Save(clienttypes.ToLastTxHashSQLType(txHash)).Error +} + +// ReadLastTxScannedFromDB reads the last scanned tx hash from the database. +func (ob *Observer) ReadLastTxScannedFromDB() (string, error) { + var lastTx clienttypes.LastTransactionSQLType + if err := ob.db.First(&lastTx, clienttypes.LastTxHashID).Error; err != nil { + // record not found + return "", err + } + return lastTx.Hash, nil +} + +// PostVoteInbound posts a vote for the given vote message +func (ob *Observer) PostVoteInbound( + ctx context.Context, + msg *crosschaintypes.MsgVoteInbound, + retryGasLimit uint64, +) (string, error) { + txHash := msg.InboundHash + coinType := msg.CoinType + chainID := ob.Chain().ChainId + zetaHash, ballot, err := ob.ZetacoreClient(). + PostVoteInbound(ctx, zetacore.PostVoteInboundGasLimit, retryGasLimit, msg) + if err != nil { + ob.logger.Inbound.Err(err). + Msgf("inbound detected: error posting vote for chain %d token %s inbound %s", chainID, coinType, txHash) + return "", err + } else if zetaHash != "" { + ob.logger.Inbound.Info().Msgf("inbound detected: chain %d token %s inbound %s vote %s ballot %s", chainID, coinType, txHash, zetaHash, ballot) + } else { + ob.logger.Inbound.Info().Msgf("inbound detected: chain %d token %s inbound %s already voted on ballot %s", chainID, coinType, txHash, ballot) + } + + return ballot, err +} + +// EnvVarLatestBlockByChain returns the environment variable for the last block by chain. func EnvVarLatestBlockByChain(chain chains.Chain) string { - return fmt.Sprintf("CHAIN_%d_SCAN_FROM", chain.ChainId) + return fmt.Sprintf("CHAIN_%d_SCAN_FROM_BLOCK", chain.ChainId) +} + +// EnvVarLatestTxByChain returns the environment variable for the last tx by chain. +func EnvVarLatestTxByChain(chain chains.Chain) string { + return fmt.Sprintf("CHAIN_%d_SCAN_FROM_TX", chain.ChainId) } diff --git a/zetaclient/chains/base/observer_test.go b/zetaclient/chains/base/observer_test.go index 923c4481a1..cd3ce26374 100644 --- a/zetaclient/chains/base/observer_test.go +++ b/zetaclient/chains/base/observer_test.go @@ -1,6 +1,7 @@ package base_test import ( + "context" "os" "testing" @@ -11,20 +12,20 @@ import ( "github.com/zeta-chain/zetacore/zetaclient/testutils" "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/coin" "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/config" - "github.com/zeta-chain/zetacore/zetaclient/context" + zctx "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" "github.com/zeta-chain/zetacore/zetaclient/testutils/mocks" ) // createObserver creates a new observer for testing -func createObserver(t *testing.T) *base.Observer { +func createObserver(t *testing.T, chain chains.Chain) *base.Observer { // constructor parameters - chain := chains.Ethereum chainParams := *sample.ChainParams(chain.ChainId) zetacoreClient := mocks.NewZetacoreClient(t) tss := mocks.NewTSSMainnet() @@ -50,7 +51,7 @@ func TestNewObserver(t *testing.T) { // constructor parameters chain := chains.Ethereum chainParams := *sample.ChainParams(chain.ChainId) - appContext := context.New(config.New(false), zerolog.Nop()) + appContext := zctx.New(config.New(false), zerolog.Nop()) zetacoreClient := mocks.NewZetacoreClient(t) tss := mocks.NewTSSMainnet() blockCacheSize := base.DefaultBlockCacheSize @@ -61,7 +62,7 @@ func TestNewObserver(t *testing.T) { name string chain chains.Chain chainParams observertypes.ChainParams - appContext *context.AppContext + appContext *zctx.AppContext zetacoreClient interfaces.ZetacoreClient tss interfaces.TSSSigner blockCacheSize int @@ -134,7 +135,7 @@ func TestNewObserver(t *testing.T) { func TestStop(t *testing.T) { t.Run("should be able to stop observer", func(t *testing.T) { // create observer and initialize db - ob := createObserver(t) + ob := createObserver(t, chains.Ethereum) ob.OpenDB(sample.CreateTempDir(t), "") // stop observer @@ -143,8 +144,9 @@ func TestStop(t *testing.T) { } func TestObserverGetterAndSetter(t *testing.T) { + chain := chains.Ethereum t.Run("should be able to update chain", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update chain newChain := chains.BscMainnet @@ -152,7 +154,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newChain, ob.Chain()) }) t.Run("should be able to update chain params", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update chain params newChainParams := *sample.ChainParams(chains.BscMainnet.ChainId) @@ -160,7 +162,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.True(t, observertypes.ChainParamsEqual(newChainParams, ob.ChainParams())) }) t.Run("should be able to update zetacore client", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update zetacore client newZetacoreClient := mocks.NewZetacoreClient(t) @@ -168,7 +170,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newZetacoreClient, ob.ZetacoreClient()) }) t.Run("should be able to update tss", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update tss newTSS := mocks.NewTSSAthens3() @@ -176,7 +178,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newTSS, ob.TSS()) }) t.Run("should be able to update last block", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update last block newLastBlock := uint64(100) @@ -184,15 +186,23 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newLastBlock, ob.LastBlock()) }) t.Run("should be able to update last block scanned", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update last block scanned newLastBlockScanned := uint64(100) ob = ob.WithLastBlockScanned(newLastBlockScanned) require.Equal(t, newLastBlockScanned, ob.LastBlockScanned()) }) + t.Run("should be able to update last tx scanned", func(t *testing.T) { + ob := createObserver(t, chain) + + // update last tx scanned + newLastTxScanned := sample.EthAddress().String() + ob = ob.WithLastTxScanned(newLastTxScanned) + require.Equal(t, newLastTxScanned, ob.LastTxScanned()) + }) t.Run("should be able to replace block cache", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update block cache newBlockCache, err := lru.New(200) @@ -202,7 +212,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newBlockCache, ob.BlockCache()) }) t.Run("should be able to replace header cache", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update headers cache newHeadersCache, err := lru.New(200) @@ -214,14 +224,14 @@ func TestObserverGetterAndSetter(t *testing.T) { t.Run("should be able to get database", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) ob.OpenDB(dbPath, "") db := ob.DB() require.NotNil(t, db) }) t.Run("should be able to update telemetry server", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) // update telemetry server newServer := metrics.NewTelemetryServer() @@ -229,7 +239,7 @@ func TestObserverGetterAndSetter(t *testing.T) { require.Equal(t, newServer, ob.TelemetryServer()) }) t.Run("should be able to get logger", func(t *testing.T) { - ob := createObserver(t) + ob := createObserver(t, chain) logger := ob.Logger() // should be able to print log @@ -244,7 +254,7 @@ func TestObserverGetterAndSetter(t *testing.T) { func TestOpenCloseDB(t *testing.T) { dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chains.Ethereum) t.Run("should be able to open/close db", func(t *testing.T) { // open db @@ -277,7 +287,7 @@ func TestLoadLastBlockScanned(t *testing.T) { t.Run("should be able to load last block scanned", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -292,7 +302,7 @@ func TestLoadLastBlockScanned(t *testing.T) { t.Run("latest block scanned should be 0 if not found in db", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -304,7 +314,7 @@ func TestLoadLastBlockScanned(t *testing.T) { t.Run("should overwrite last block scanned if env var is set", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -322,7 +332,7 @@ func TestLoadLastBlockScanned(t *testing.T) { t.Run("last block scanned should remain 0 if env var is set to latest", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -340,7 +350,7 @@ func TestLoadLastBlockScanned(t *testing.T) { t.Run("should return error on invalid env var", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -357,7 +367,7 @@ func TestSaveLastBlockScanned(t *testing.T) { t.Run("should be able to save last block scanned", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chains.Ethereum) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -375,11 +385,12 @@ func TestSaveLastBlockScanned(t *testing.T) { }) } -func TestReadWriteLastBlockScannedToDB(t *testing.T) { +func TestReadWriteDBLastBlockScanned(t *testing.T) { + chain := chains.Ethereum t.Run("should be able to write and read last block scanned to db", func(t *testing.T) { // create observer and open db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -394,7 +405,7 @@ func TestReadWriteLastBlockScannedToDB(t *testing.T) { t.Run("should return error when last block scanned not found in db", func(t *testing.T) { // create empty db dbPath := sample.CreateTempDir(t) - ob := createObserver(t) + ob := createObserver(t, chain) err := ob.OpenDB(dbPath, "") require.NoError(t, err) @@ -403,3 +414,131 @@ func TestReadWriteLastBlockScannedToDB(t *testing.T) { require.Zero(t, lastScannedBlock) }) } + +func TestLoadLastTxScanned(t *testing.T) { + chain := chains.SolanaDevnet + envvar := base.EnvVarLatestTxByChain(chain) + lastTx := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + + t.Run("should be able to load last tx scanned", func(t *testing.T) { + // create observer and open db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + // create db and write sample hash as last tx scanned + ob.WriteLastTxScannedToDB(lastTx) + + // read last tx scanned + ob.LoadLastTxScanned() + require.NoError(t, err) + require.EqualValues(t, lastTx, ob.LastTxScanned()) + }) + t.Run("latest tx scanned should be empty if not found in db", func(t *testing.T) { + // create observer and open db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + // read last tx scanned + ob.LoadLastTxScanned() + require.NoError(t, err) + require.Empty(t, ob.LastTxScanned()) + }) + t.Run("should overwrite last tx scanned if env var is set", func(t *testing.T) { + // create observer and open db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + // create db and write sample hash as last tx scanned + ob.WriteLastTxScannedToDB(lastTx) + + // set env var to other tx + otherTx := "4Q27KQqJU1gJQavNtkvhH6cGR14fZoBdzqWdWiFd9KPeJxFpYsDRiKAwsQDpKMPtyRhppdncyURTPZyokrFiVHrx" + os.Setenv(envvar, otherTx) + + // read last block scanned + ob.LoadLastTxScanned() + require.NoError(t, err) + require.EqualValues(t, otherTx, ob.LastTxScanned()) + }) +} + +func TestSaveLastTxScanned(t *testing.T) { + chain := chains.SolanaDevnet + t.Run("should be able to save last tx scanned", func(t *testing.T) { + // create observer and open db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + // save random tx hash + lastSlot := uint64(100) + lastTx := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + err = ob.SaveLastTxScanned(lastTx, lastSlot) + require.NoError(t, err) + + // check last tx and slot scanned in memory + require.EqualValues(t, lastTx, ob.LastTxScanned()) + require.EqualValues(t, lastSlot, ob.LastBlockScanned()) + + // read last tx scanned from db + lastTxScanned, err := ob.ReadLastTxScannedFromDB() + require.NoError(t, err) + require.EqualValues(t, lastTx, lastTxScanned) + }) +} + +func TestReadWriteDBLastTxScanned(t *testing.T) { + chain := chains.SolanaDevnet + t.Run("should be able to write and read last tx scanned to db", func(t *testing.T) { + // create observer and open db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + // write last tx scanned + lastTx := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + err = ob.WriteLastTxScannedToDB(lastTx) + require.NoError(t, err) + + lastTxScanned, err := ob.ReadLastTxScannedFromDB() + require.NoError(t, err) + require.EqualValues(t, lastTx, lastTxScanned) + }) + t.Run("should return error when last tx scanned not found in db", func(t *testing.T) { + // create empty db + dbPath := sample.CreateTempDir(t) + ob := createObserver(t, chain) + err := ob.OpenDB(dbPath, "") + require.NoError(t, err) + + lastTxScanned, err := ob.ReadLastTxScannedFromDB() + require.Error(t, err) + require.Empty(t, lastTxScanned) + }) +} + +func TestPostVoteInbound(t *testing.T) { + t.Run("should be able to post vote inbound", func(t *testing.T) { + // create observer + ob := createObserver(t, chains.Ethereum) + + // create mock zetacore client + zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithPostVoteInbound("", "sampleBallotIndex") + ob = ob.WithZetacoreClient(zetacoreClient) + + // post vote inbound + msg := sample.InboundVote(coin.CoinType_Gas, chains.Ethereum.ChainId, chains.ZetaChainMainnet.ChainId) + ballot, err := ob.PostVoteInbound(context.TODO(), &msg, 100000) + require.NoError(t, err) + require.Equal(t, "sampleBallotIndex", ballot) + }) +} diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index c9d8350781..2aea6e450f 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -45,7 +45,7 @@ const ( BigValueConfirmationCount = 6 ) -var _ interfaces.ChainObserver = &Observer{} +var _ interfaces.ChainObserver = (*Observer)(nil) // Logger contains list of loggers used by Bitcoin chain observer type Logger struct { diff --git a/zetaclient/chains/evm/observer/inbound.go b/zetaclient/chains/evm/observer/inbound.go index b0034f048e..3dba2aa62e 100644 --- a/zetaclient/chains/evm/observer/inbound.go +++ b/zetaclient/chains/evm/observer/inbound.go @@ -315,7 +315,6 @@ func (ob *Observer) ObserveZetaSent(ctx context.Context, startBlock, toBlock uin _, err = ob.PostVoteInbound( ctx, msg, - coin.CoinType_Zeta, zetacore.PostVoteInboundMessagePassingExecutionGasLimit, ) if err != nil { @@ -402,7 +401,7 @@ func (ob *Observer) ObserveERC20Deposited(ctx context.Context, startBlock, toBlo msg := ob.BuildInboundVoteMsgForDepositedEvent(event, sender) if msg != nil { - _, err = ob.PostVoteInbound(ctx, msg, coin.CoinType_ERC20, zetacore.PostVoteInboundExecutionGasLimit) + _, err = ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) if err != nil { return beingScanned - 1 // we have to re-scan from this block next time } @@ -510,7 +509,7 @@ func (ob *Observer) CheckAndVoteInboundTokenZeta( return "", nil } if vote { - return ob.PostVoteInbound(ctx, msg, coin.CoinType_Zeta, zetacore.PostVoteInboundMessagePassingExecutionGasLimit) + return ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundMessagePassingExecutionGasLimit) } return msg.Digest(), nil @@ -561,7 +560,7 @@ func (ob *Observer) CheckAndVoteInboundTokenERC20( return "", nil } if vote { - return ob.PostVoteInbound(ctx, msg, coin.CoinType_ERC20, zetacore.PostVoteInboundExecutionGasLimit) + return ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) } return msg.Digest(), nil @@ -600,36 +599,12 @@ func (ob *Observer) CheckAndVoteInboundTokenGas( return "", nil } if vote { - return ob.PostVoteInbound(ctx, msg, coin.CoinType_Gas, zetacore.PostVoteInboundExecutionGasLimit) + return ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) } return msg.Digest(), nil } -// PostVoteInbound posts a vote for the given vote message -func (ob *Observer) PostVoteInbound( - ctx context.Context, - msg *types.MsgVoteInbound, - coinType coin.CoinType, - retryGasLimit uint64, -) (string, error) { - txHash := msg.InboundHash - chainID := ob.Chain().ChainId - zetaHash, ballot, err := ob.ZetacoreClient(). - PostVoteInbound(ctx, zetacore.PostVoteInboundGasLimit, retryGasLimit, msg) - if err != nil { - ob.Logger().Inbound.Err(err). - Msgf("inbound detected: error posting vote for chain %d token %s inbound %s", chainID, coinType, txHash) - return "", err - } else if zetaHash != "" { - ob.Logger().Inbound.Info().Msgf("inbound detected: chain %d token %s inbound %s vote %s ballot %s", chainID, coinType, txHash, zetaHash, ballot) - } else { - ob.Logger().Inbound.Info().Msgf("inbound detected: chain %d token %s inbound %s already voted on ballot %s", chainID, coinType, txHash, ballot) - } - - return ballot, err -} - // HasEnoughConfirmations checks if the given receipt has enough confirmations func (ob *Observer) HasEnoughConfirmations(receipt *ethtypes.Receipt, lastHeight uint64) bool { confHeight := receipt.BlockNumber.Uint64() + ob.GetChainParams().ConfirmationCount diff --git a/zetaclient/chains/evm/observer/observer.go b/zetaclient/chains/evm/observer/observer.go index 126c8baf65..a6b69a78b1 100644 --- a/zetaclient/chains/evm/observer/observer.go +++ b/zetaclient/chains/evm/observer/observer.go @@ -30,7 +30,7 @@ import ( clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" ) -var _ interfaces.ChainObserver = &Observer{} +var _ interfaces.ChainObserver = (*Observer)(nil) // Observer is the observer for evm chains type Observer struct { diff --git a/zetaclient/chains/evm/observer/observer_test.go b/zetaclient/chains/evm/observer/observer_test.go index cc7c9f0a68..c24b5da8f7 100644 --- a/zetaclient/chains/evm/observer/observer_test.go +++ b/zetaclient/chains/evm/observer/observer_test.go @@ -62,6 +62,7 @@ func getZetacoreContext( []chains.Chain{evmChain}, evmChainParamsMap, nil, + nil, "", *sample.CrosschainFlags(), []chains.Chain{}, diff --git a/zetaclient/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 5d6fa4f56f..6964aac3b4 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -14,6 +14,8 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/gagliardetto/solana-go" + solrpc "github.com/gagliardetto/solana-go/rpc" "github.com/onrik/ethrpc" "github.com/rs/zerolog" "github.com/zeta-chain/go-tss/blame" @@ -187,6 +189,23 @@ type EVMRPCClient interface { ) (ethcommon.Address, error) } +// SolanaRPCClient is the interface for Solana RPC client +type SolanaRPCClient interface { + GetVersion(ctx context.Context) (out *solrpc.GetVersionResult, err error) + GetHealth(ctx context.Context) (out string, err error) + GetAccountInfo(ctx context.Context, account solana.PublicKey) (out *solrpc.GetAccountInfoResult, err error) + GetTransaction( + ctx context.Context, + txSig solana.Signature, // transaction signature + opts *solrpc.GetTransactionOpts, + ) (out *solrpc.GetTransactionResult, err error) + GetSignaturesForAddressWithOpts( + ctx context.Context, + account solana.PublicKey, + opts *solrpc.GetSignaturesForAddressOpts, + ) (out []*solrpc.TransactionSignature, err error) +} + // EVMJSONRPCClient is the interface for EVM JSON RPC client type EVMJSONRPCClient interface { EthGetBlockByNumber(number int, withTransactions bool) (*ethrpc.Block, error) diff --git a/zetaclient/chains/solana/observer/db.go b/zetaclient/chains/solana/observer/db.go new file mode 100644 index 0000000000..91757910d7 --- /dev/null +++ b/zetaclient/chains/solana/observer/db.go @@ -0,0 +1,30 @@ +package observer + +import ( + "github.com/pkg/errors" +) + +// LoadDB open sql database and load data into Solana observer +func (ob *Observer) LoadDB(dbPath string) error { + if dbPath == "" { + return errors.New("empty db path") + } + + // open database + err := ob.OpenDB(dbPath, "") + if err != nil { + return errors.Wrapf(err, "error OpenDB for chain %d", ob.Chain().ChainId) + } + + ob.Observer.LoadLastTxScanned() + + return nil +} + +// LoadLastTxScanned loads the last scanned tx from the database. +func (ob *Observer) LoadLastTxScanned() error { + ob.Observer.LoadLastTxScanned() + ob.Logger().Chain.Info().Msgf("chain %d starts scanning from tx %s", ob.Chain().ChainId, ob.LastTxScanned()) + + return nil +} diff --git a/zetaclient/chains/solana/observer/db_test.go b/zetaclient/chains/solana/observer/db_test.go new file mode 100644 index 0000000000..f6fdee1b73 --- /dev/null +++ b/zetaclient/chains/solana/observer/db_test.go @@ -0,0 +1,103 @@ +package observer_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + observertypes "github.com/zeta-chain/zetacore/x/observer/types" + "github.com/zeta-chain/zetacore/zetaclient/keys" + + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/testutil/sample" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" + "github.com/zeta-chain/zetacore/zetaclient/testutils/mocks" +) + +// MockSolanaObserver creates a mock Solana observer with custom chain, TSS, params etc +func MockSolanaObserver( + t *testing.T, + chain chains.Chain, + solClient interfaces.SolanaRPCClient, + chainParams observertypes.ChainParams, + zetacoreClient interfaces.ZetacoreClient, + tss interfaces.TSSSigner, + dbpath string, +) *observer.Observer { + // use mock zetacore client if not provided + if zetacoreClient == nil { + zetacoreClient = mocks.NewZetacoreClient(t).WithKeys(&keys.Keys{}) + } + // use mock tss if not provided + if tss == nil { + tss = mocks.NewTSSMainnet() + } + + // create observer + ob, err := observer.NewObserver( + chain, + solClient, + chainParams, + zetacoreClient, + tss, + dbpath, + base.DefaultLogger(), + nil, + ) + require.NoError(t, err) + + return ob +} + +func Test_LoadDB(t *testing.T) { + // parepare params + chain := chains.SolanaDevnet + params := sample.ChainParams(chain.ChainId) + params.GatewayAddress = sample.SolanaAddress(t) + dbpath := sample.CreateTempDir(t) + + // create observer + ob := MockSolanaObserver(t, chain, nil, *params, nil, nil, dbpath) + + // write last tx to db + lastTx := sample.SolanaSignature(t).String() + ob.WriteLastTxScannedToDB(lastTx) + + t.Run("should load db successfully", func(t *testing.T) { + err := ob.LoadDB(dbpath) + require.NoError(t, err) + require.Equal(t, lastTx, ob.LastTxScanned()) + }) + t.Run("should fail on invalid dbpath", func(t *testing.T) { + // load db with empty dbpath + err := ob.LoadDB("") + require.ErrorContains(t, err, "empty db path") + + // load db with invalid dbpath + err = ob.LoadDB("/invalid/dbpath") + require.ErrorContains(t, err, "error OpenDB") + }) +} + +func Test_LoadLastTxScanned(t *testing.T) { + // parepare params + chain := chains.SolanaDevnet + params := sample.ChainParams(chain.ChainId) + params.GatewayAddress = sample.SolanaAddress(t) + dbpath := sample.CreateTempDir(t) + + // create observer + ob := MockSolanaObserver(t, chain, nil, *params, nil, nil, dbpath) + + t.Run("should load last block scanned", func(t *testing.T) { + // write sample last tx to db + lastTx := sample.SolanaSignature(t).String() + ob.WriteLastTxScannedToDB(lastTx) + + // load last tx scanned + err := ob.LoadLastTxScanned() + require.NoError(t, err) + require.Equal(t, lastTx, ob.LastTxScanned()) + }) +} diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go new file mode 100644 index 0000000000..4a819ce9cb --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound.go @@ -0,0 +1,360 @@ +package observer + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + + cosmosmath "cosmossdk.io/math" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/zetacore/pkg/coin" + "github.com/zeta-chain/zetacore/pkg/constant" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" + solanarpc "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" + "github.com/zeta-chain/zetacore/zetaclient/compliance" + zctx "github.com/zeta-chain/zetacore/zetaclient/context" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" + "github.com/zeta-chain/zetacore/zetaclient/zetacore" +) + +const ( + // MaxSignaturesPerTicker is the maximum number of signatures to process on a ticker + MaxSignaturesPerTicker = 100 +) + +// WatchInbound watches Solana chain for inbounds on a ticker. +func (ob *Observer) WatchInbound(ctx context.Context) error { + app, err := zctx.FromContext(ctx) + if err != nil { + return err + } + + ticker, err := clienttypes.NewDynamicTicker( + fmt.Sprintf("Solana_WatchInbound_%d", ob.Chain().ChainId), + ob.GetChainParams().InboundTicker, + ) + if err != nil { + ob.Logger().Inbound.Error().Err(err).Msg("error creating ticker") + return err + } + defer ticker.Stop() + + ob.Logger().Inbound.Info().Msgf("WatchInbound started for chain %d", ob.Chain().ChainId) + sampledLogger := ob.Logger().Inbound.Sample(&zerolog.BasicSampler{N: 10}) + + for { + select { + case <-ticker.C(): + if !app.IsInboundObservationEnabled(ob.GetChainParams()) { + sampledLogger.Info(). + Msgf("WatchInbound: inbound observation is disabled for chain %d", ob.Chain().ChainId) + continue + } + err := ob.ObserveInbound(ctx) + if err != nil { + ob.Logger().Inbound.Err(err).Msg("WatchInbound: observeInbound error") + } + case <-ob.StopChannel(): + ob.Logger().Inbound.Info().Msgf("WatchInbound stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// ObserveInbound observes the Bitcoin chain for inbounds and post votes to zetacore. +func (ob *Observer) ObserveInbound(ctx context.Context) error { + chainID := ob.Chain().ChainId + pageLimit := solanarpc.DefaultPageLimit + + // scan from gateway 1st signature if last scanned tx is absent in the database + // the 1st gateway signature is typically the program initialization + if ob.LastTxScanned() == "" { + lastSig, err := solanarpc.GetFirstSignatureForAddress(ctx, ob.solClient, ob.gatewayID, pageLimit) + if err != nil { + return errors.Wrapf(err, "error GetFirstSignatureForAddress for chain %d address %s", chainID, ob.gatewayID) + } + ob.WithLastTxScanned(lastSig.String()) + } + + // get all signatures for the gateway address since last scanned signature + lastSig := solana.MustSignatureFromBase58(ob.LastTxScanned()) + signatures, err := solanarpc.GetSignaturesForAddressUntil(ctx, ob.solClient, ob.gatewayID, lastSig, pageLimit) + if err != nil { + ob.Logger().Inbound.Err(err).Msg("error GetSignaturesForAddressUntil") + return err + } + if len(signatures) > 0 { + ob.Logger().Inbound.Info().Msgf("ObserveInbound: got %d signatures for chain %d", len(signatures), chainID) + } + + // loop signature from oldest to latest to filter inbound events + for i := len(signatures) - 1; i >= 0; i-- { + sig := signatures[i] + sigString := sig.Signature.String() + + // process successfully signature only + if sig.Err == nil { + txResult, err := ob.solClient.GetTransaction(ctx, sig.Signature, &rpc.GetTransactionOpts{}) + if err != nil { + // we have to re-scan this signature on next ticker + return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, sigString) + } + + // filter inbound events and vote + err = ob.FilterInboundEventsAndVote(ctx, txResult) + if err != nil { + // we have to re-scan this signature on next ticker + return errors.Wrapf(err, "error FilterInboundEventAndVote for chain %d sig %s", chainID, sigString) + } + } + + // signature scanned; save last scanned signature to both memory and db, ignore db error + if err := ob.SaveLastTxScanned(sigString, sig.Slot); err != nil { + ob.Logger(). + Inbound.Error(). + Err(err). + Msgf("ObserveInbound: error saving last sig %s for chain %d", sigString, chainID) + } + ob.Logger(). + Inbound.Info(). + Msgf("ObserveInbound: last scanned sig is %s for chain %d in slot %d", sigString, chainID, sig.Slot) + + // take a rest if max signatures per ticker is reached + if len(signatures)-i >= MaxSignaturesPerTicker { + break + } + } + + return nil +} + +// FilterInboundEventsAndVote filters inbound events from a txResult and post a vote. +func (ob *Observer) FilterInboundEventsAndVote(ctx context.Context, txResult *rpc.GetTransactionResult) error { + // filter inbound events from txResult + events, err := ob.FilterInboundEvents(txResult) + if err != nil { + return errors.Wrapf(err, "error FilterInboundEvent") + } + + // build inbound vote message from events and post to zetacore + for _, event := range events { + msg := ob.BuildInboundVoteMsgFromEvent(event) + if msg != nil { + _, err = ob.PostVoteInbound(ctx, msg, zetacore.PostVoteInboundExecutionGasLimit) + if err != nil { + return errors.Wrapf(err, "error PostVoteInbound") + } + } + } + + return nil +} + +// FilterInboundEvents filters inbound events from a tx result. +// Note: for consistency with EVM chains, this method +// - takes at one event (the first) per token (SOL or SPL) per transaction. +// - takes at most two events (one SOL + one SPL) per transaction. +// - ignores exceeding events. +func (ob *Observer) FilterInboundEvents(txResult *rpc.GetTransactionResult) ([]*clienttypes.InboundEvent, error) { + // unmarshal transaction + tx, err := txResult.Transaction.GetTransaction() + if err != nil { + return nil, errors.Wrap(err, "error unmarshaling transaction") + } + + // there should be at least one instruction and one account, otherwise skip + if len(tx.Message.Instructions) <= 0 { + return nil, nil + } + + // create event array to collect all events in the transaction + seenDeposit := false + seenDepositSPL := false + events := make([]*clienttypes.InboundEvent, 0) + + // loop through instruction list to filter the 1st valid event + for i, instruction := range tx.Message.Instructions { + // get the program ID + programPk, err := tx.Message.Program(instruction.ProgramIDIndex) + if err != nil { + ob.Logger(). + Inbound.Err(err). + Msgf("no program found at index %d for sig %s", instruction.ProgramIDIndex, tx.Signatures[0]) + continue + } + + // skip instructions that are irrelevant to the gateway program invocation + if !programPk.Equals(ob.gatewayID) { + continue + } + + // try parsing the instruction as a 'deposit' if not seen yet + if !seenDeposit { + event, err := ob.ParseInboundAsDeposit(tx, i, txResult.Slot) + if err != nil { + return nil, errors.Wrap(err, "error ParseInboundAsDeposit") + } else if event != nil { + seenDeposit = true + events = append(events, event) + ob.Logger().Inbound.Info(). + Msgf("FilterInboundEvents: deposit detected in sig %s instruction %d", tx.Signatures[0], i) + } + } else { + ob.Logger().Inbound.Warn(). + Msgf("FilterInboundEvents: multiple deposits detected in sig %s instruction %d", tx.Signatures[0], i) + } + + // try parsing the instruction as a 'deposit_spl_token' if not seen yet + if !seenDepositSPL { + event, err := ob.ParseInboundAsDepositSPL(tx, i, txResult.Slot) + if err != nil { + return nil, errors.Wrap(err, "error ParseInboundAsDepositSPL") + } else if event != nil { + seenDepositSPL = true + events = append(events, event) + ob.Logger().Inbound.Info(). + Msgf("FilterInboundEvents: SPL deposit detected in sig %s instruction %d", tx.Signatures[0], i) + } + } else { + ob.Logger().Inbound.Warn(). + Msgf("FilterInboundEvents: multiple SPL deposits detected in sig %s instruction %d", tx.Signatures[0], i) + } + } + + return events, nil +} + +// BuildInboundVoteMsgFromEvent builds a MsgVoteInbound from an inbound event +func (ob *Observer) BuildInboundVoteMsgFromEvent(event *clienttypes.InboundEvent) *crosschaintypes.MsgVoteInbound { + // compliance check. Return nil if the inbound contains restricted addresses + if compliance.DoesInboundContainsRestrictedAddress(event, ob.Logger()) { + return nil + } + + // donation check + if bytes.Equal(event.Memo, []byte(constant.DonationMessage)) { + ob.Logger().Inbound.Info(). + Msgf("thank you rich folk for your donation! tx %s chain %d", event.TxHash, event.SenderChainID) + return nil + } + + return zetacore.GetInboundVoteMessage( + event.Sender, + event.SenderChainID, + event.Sender, + event.Sender, + ob.ZetacoreClient().Chain().ChainId, + cosmosmath.NewUint(event.Amount), + hex.EncodeToString(event.Memo), + event.TxHash, + event.BlockNumber, + 0, + event.CoinType, + event.Asset, + ob.ZetacoreClient().GetKeys().GetOperatorAddress().String(), + 0, // not a smart contract call + ) +} + +// ParseInboundAsDeposit tries to parse an instruction as a 'deposit'. +// It returns nil if the instruction can't be parsed as a 'deposit'. +func (ob *Observer) ParseInboundAsDeposit( + tx *solana.Transaction, + instructionIndex int, + slot uint64, +) (*clienttypes.InboundEvent, error) { + // get instruction by index + instruction := tx.Message.Instructions[instructionIndex] + + // try deserializing instruction as a 'deposit' + var inst solanacontract.DepositInstructionParams + err := borsh.Deserialize(&inst, instruction.Data) + if err != nil { + return nil, nil + } + + // check if the instruction is a deposit or not + if inst.Discriminator != solanacontract.DiscriminatorDeposit() { + return nil, nil + } + + // get the sender address (the signer must exist) + sender, err := ob.GetSignerDeposit(tx, &instruction) + if err != nil { + return nil, errors.Wrap(err, "error GetSignerDeposit") + } + + // build inbound event + event := &clienttypes.InboundEvent{ + SenderChainID: ob.Chain().ChainId, + Sender: sender, + Receiver: sender, + TxOrigin: sender, + Amount: inst.Amount, + Memo: inst.Memo, + BlockNumber: slot, // instead of using block, Solana explorer uses slot for indexing + TxHash: tx.Signatures[0].String(), + Index: 0, // hardcode to 0 for Solana, not a EVM smart contract call + CoinType: coin.CoinType_Gas, + Asset: "", // no asset for gas token SOL + } + + return event, nil +} + +// ParseInboundAsDepositSPL tries to parse an instruction as a 'deposit_spl_token'. +// It returns nil if the instruction can't be parsed as a 'deposit_spl_token'. +func (ob *Observer) ParseInboundAsDepositSPL( + _ *solana.Transaction, + _ int, + _ uint64, +) (*clienttypes.InboundEvent, error) { + // not implemented yet + return nil, nil +} + +// GetSignerDeposit returns the signer address of the deposit instruction +// Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. +func (ob *Observer) GetSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { + // there should be 4 accounts for a deposit instruction + if len(inst.Accounts) != solanacontract.AccountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", solanacontract.AccountsNumDeposit, len(inst.Accounts)) + } + + // the accounts are [signer, pda, system_program, gateway_program] + signerIndex, pdaIndex, systemIndex, gatewayIndex := -1, -1, -1, -1 + + // try to find the indexes of all above accounts + for _, accIndex := range inst.Accounts { + // #nosec G701 always in range + accIndexInt := int(accIndex) + accKey := tx.Message.AccountKeys[accIndexInt] + + switch accKey { + case ob.pda: + pdaIndex = accIndexInt + case ob.gatewayID: + gatewayIndex = accIndexInt + case solana.SystemProgramID: + systemIndex = accIndexInt + default: + // the last remaining account is the signer + signerIndex = accIndexInt + } + } + + // all above accounts must be found + if signerIndex == -1 || pdaIndex == -1 || systemIndex == -1 || gatewayIndex == -1 { + return "", fmt.Errorf("invalid accounts for deposit instruction") + } + + // sender is the signer account + return tx.Message.AccountKeys[signerIndex].String(), nil +} diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go new file mode 100644 index 0000000000..2b3692ad62 --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -0,0 +1,189 @@ +package observer_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/coin" + "github.com/zeta-chain/zetacore/pkg/constant" + "github.com/zeta-chain/zetacore/testutil/sample" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" + "github.com/zeta-chain/zetacore/zetaclient/config" + "github.com/zeta-chain/zetacore/zetaclient/keys" + "github.com/zeta-chain/zetacore/zetaclient/testutils" + "github.com/zeta-chain/zetacore/zetaclient/testutils/mocks" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" +) + +var ( + // the relative path to the testdata directory + TestDataDir = "../../../" +) + +func Test_FilterInboundEventAndVote(t *testing.T) { + // load archived inbound vote tx result + // https://explorer.solana.com/tx/5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk?cluster=devnet + txHash := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + chain := chains.SolanaDevnet + txResult := testutils.LoadSolanaInboundTxResult(t, TestDataDir, chain.ChainId, txHash, false) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithKeys(&keys.Keys{}).WithZetaChain().WithPostVoteInbound("", "") + + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) + require.NoError(t, err) + + t.Run("should filter inbound events and vote", func(t *testing.T) { + err := ob.FilterInboundEventsAndVote(context.TODO(), txResult) + require.NoError(t, err) + }) +} + +func Test_FilterInboundEvents(t *testing.T) { + // load archived inbound deposit tx result + // https://explorer.solana.com/tx/5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk?cluster=devnet + txHash := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + chain := chains.SolanaDevnet + txResult := testutils.LoadSolanaInboundTxResult(t, TestDataDir, chain.ChainId, txHash, false) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, dbpath, base.DefaultLogger(), nil) + require.NoError(t, err) + + // expected result + sender := "AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L" + eventExpected := &clienttypes.InboundEvent{ + SenderChainID: chain.ChainId, + Sender: sender, + Receiver: sender, + TxOrigin: sender, + Amount: 1280, + Memo: []byte("hello this is a good memo for you to enjoy"), + BlockNumber: txResult.Slot, + TxHash: txHash, + Index: 0, // not a EVM smart contract call + CoinType: coin.CoinType_Gas, + Asset: "", // no asset for gas token SOL + } + + t.Run("should filter inbound event deposit SOL", func(t *testing.T) { + events, err := ob.FilterInboundEvents(txResult) + require.NoError(t, err) + + // check result + require.Len(t, events, 1) + require.EqualValues(t, eventExpected, events[0]) + }) +} + +func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { + // create test observer + chain := chains.SolanaDevnet + params := sample.ChainParams(chain.ChainId) + params.GatewayAddress = sample.SolanaAddress(t) + zetacoreClient := mocks.NewZetacoreClient(t) + zetacoreClient.WithKeys(&keys.Keys{}).WithZetaChain().WithPostVoteInbound("", "") + + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *params, zetacoreClient, nil, dbpath, base.DefaultLogger(), nil) + require.NoError(t, err) + + // create test compliance config + cfg := config.Config{ + ComplianceConfig: config.ComplianceConfig{}, + } + + t.Run("should return vote msg for valid event", func(t *testing.T) { + sender := sample.SolanaAddress(t) + memo := sample.EthAddress().Bytes() + event := sample.InboundEvent(chain.ChainId, sender, sender, 1280, []byte(memo)) + + msg := ob.BuildInboundVoteMsgFromEvent(event) + require.NotNil(t, msg) + }) + t.Run("should return nil msg if sender is restricted", func(t *testing.T) { + sender := sample.SolanaAddress(t) + receiver := sample.SolanaAddress(t) + event := sample.InboundEvent(chain.ChainId, sender, receiver, 1280, nil) + + // restrict sender + cfg.ComplianceConfig.RestrictedAddresses = []string{sender} + config.LoadComplianceConfig(cfg) + + msg := ob.BuildInboundVoteMsgFromEvent(event) + require.Nil(t, msg) + }) + t.Run("should return nil msg if receiver is restricted", func(t *testing.T) { + sender := sample.SolanaAddress(t) + receiver := sample.SolanaAddress(t) + memo := sample.EthAddress().Bytes() + event := sample.InboundEvent(chain.ChainId, sender, receiver, 1280, []byte(memo)) + + // restrict receiver + cfg.ComplianceConfig.RestrictedAddresses = []string{receiver} + config.LoadComplianceConfig(cfg) + + msg := ob.BuildInboundVoteMsgFromEvent(event) + require.Nil(t, msg) + }) + t.Run("should return nil msg on donation transaction", func(t *testing.T) { + // create event with donation memo + sender := sample.SolanaAddress(t) + event := sample.InboundEvent(chain.ChainId, sender, sender, 1280, []byte(constant.DonationMessage)) + + msg := ob.BuildInboundVoteMsgFromEvent(event) + require.Nil(t, msg) + }) +} + +func Test_ParseInboundAsDeposit(t *testing.T) { + // load archived inbound deposit tx result + // https://explorer.solana.com/tx/5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk?cluster=devnet + txHash := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + chain := chains.SolanaDevnet + + txResult := testutils.LoadSolanaInboundTxResult(t, TestDataDir, chain.ChainId, txHash, false) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + dbpath := sample.CreateTempDir(t) + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, dbpath, base.DefaultLogger(), nil) + require.NoError(t, err) + + // expected result + sender := "AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L" + eventExpected := &clienttypes.InboundEvent{ + SenderChainID: chain.ChainId, + Sender: sender, + Receiver: sender, + TxOrigin: sender, + Amount: 1280, + Memo: []byte("hello this is a good memo for you to enjoy"), + BlockNumber: txResult.Slot, + TxHash: txHash, + Index: 0, // not a EVM smart contract call + CoinType: coin.CoinType_Gas, + Asset: "", // no asset for gas token SOL + } + + t.Run("should parse inbound event deposit SOL", func(t *testing.T) { + event, err := ob.ParseInboundAsDeposit(tx, 0, txResult.Slot) + require.NoError(t, err) + + // check result + require.EqualValues(t, eventExpected, event) + }) +} diff --git a/zetaclient/chains/solana/observer/inbound_tracker.go b/zetaclient/chains/solana/observer/inbound_tracker.go new file mode 100644 index 0000000000..7665359949 --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound_tracker.go @@ -0,0 +1,79 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/pkg/errors" + + zctx "github.com/zeta-chain/zetacore/zetaclient/context" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" +) + +// WatchInboundTracker watches zetacore for Solana inbound trackers +func (ob *Observer) WatchInboundTracker(ctx context.Context) error { + app, err := zctx.FromContext(ctx) + if err != nil { + return err + } + + ticker, err := clienttypes.NewDynamicTicker( + fmt.Sprintf("Solana_WatchInboundTracker_%d", ob.Chain().ChainId), + ob.GetChainParams().InboundTicker, + ) + if err != nil { + ob.Logger().Inbound.Err(err).Msg("error creating ticker") + return err + } + defer ticker.Stop() + + ob.Logger().Inbound.Info().Msgf("WatchInboundTracker started for chain %d", ob.Chain().ChainId) + for { + select { + case <-ticker.C(): + if !app.IsInboundObservationEnabled(ob.GetChainParams()) { + continue + } + err := ob.ProcessInboundTrackers(ctx) + if err != nil { + ob.Logger().Inbound.Error(). + Err(err). + Msgf("WatchInboundTracker: error ProcessInboundTrackers for chain %d", ob.Chain().ChainId) + } + ticker.UpdateInterval(ob.GetChainParams().InboundTicker, ob.Logger().Inbound) + case <-ob.StopChannel(): + ob.Logger().Inbound.Info().Msgf("WatchInboundTracker stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// ProcessInboundTrackers processes inbound trackers +func (ob *Observer) ProcessInboundTrackers(ctx context.Context) error { + chainID := ob.Chain().ChainId + trackers, err := ob.ZetacoreClient().GetInboundTrackersForChain(ctx, chainID) + if err != nil { + return err + } + + // process inbound trackers + for _, tracker := range trackers { + signature := solana.MustSignatureFromBase58(tracker.TxHash) + txResult, err := ob.solClient.GetTransaction(ctx, signature, &rpc.GetTransactionOpts{ + Commitment: rpc.CommitmentFinalized, + }) + if err != nil { + return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, signature) + } + + // filter inbound events and vote + err = ob.FilterInboundEventsAndVote(ctx, txResult) + if err != nil { + return errors.Wrapf(err, "error FilterInboundEventAndVote for chain %d sig %s", chainID, signature) + } + } + + return nil +} diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go new file mode 100644 index 0000000000..ec818732ca --- /dev/null +++ b/zetaclient/chains/solana/observer/observer.go @@ -0,0 +1,118 @@ +package observer + +import ( + "context" + + "github.com/gagliardetto/solana-go" + + "github.com/zeta-chain/zetacore/pkg/bg" + "github.com/zeta-chain/zetacore/pkg/chains" + solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + 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/metrics" +) + +var _ interfaces.ChainObserver = (*Observer)(nil) + +// Observer is the observer for the Solana chain +type Observer struct { + // base.Observer implements the base chain observer + base.Observer + + // solClient is the Solana RPC client that interacts with the Solana chain + solClient interfaces.SolanaRPCClient + + // gatewayID is the program ID of gateway program on Solana chain + gatewayID solana.PublicKey + + // pda is the program derived address of the gateway program + pda solana.PublicKey +} + +// NewObserver returns a new Solana chain observer +func NewObserver( + chain chains.Chain, + solClient interfaces.SolanaRPCClient, + chainParams observertypes.ChainParams, + zetacoreClient interfaces.ZetacoreClient, + tss interfaces.TSSSigner, + dbpath string, + logger base.Logger, + ts *metrics.TelemetryServer, +) (*Observer, error) { + // create base observer + baseObserver, err := base.NewObserver( + chain, + chainParams, + zetacoreClient, + tss, + base.DefaultBlockCacheSize, + base.DefaultHeaderCacheSize, + ts, + logger, + ) + if err != nil { + return nil, err + } + + // create solana observer + ob := Observer{ + Observer: *baseObserver, + solClient: solClient, + gatewayID: solana.MustPublicKeyFromBase58(chainParams.GatewayAddress), + } + + // compute gateway PDA + seed := []byte(solanacontract.PDASeed) + ob.pda, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) + if err != nil { + return nil, err + } + + // load btc chain observer DB + err = ob.LoadDB(dbpath) + if err != nil { + return nil, err + } + + return &ob, nil +} + +// SolClient returns the solana rpc client +func (ob *Observer) SolClient() interfaces.SolanaRPCClient { + return ob.solClient +} + +// WithSolClient attaches a new solana rpc client to the observer +func (ob *Observer) WithSolClient(client interfaces.SolanaRPCClient) { + ob.solClient = client +} + +// SetChainParams sets the chain params for the observer +// Note: chain params is accessed concurrently +func (ob *Observer) SetChainParams(params observertypes.ChainParams) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.WithChainParams(params) +} + +// GetChainParams returns the chain params for the observer +// Note: chain params is accessed concurrently +func (ob *Observer) GetChainParams() observertypes.ChainParams { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.ChainParams() +} + +// Start starts the Go routine processes to observe the Solana chain +func (ob *Observer) Start(ctx context.Context) { + ob.Logger().Chain.Info().Msgf("observer is starting for chain %d", ob.Chain().ChainId) + + // watch Solana chain for incoming txs and post votes to zetacore + bg.Work(ctx, ob.WatchInbound, bg.WithName("WatchInbound"), bg.WithLogger(ob.Logger().Inbound)) + + // watch zetacore for Solana inbound trackers + bg.Work(ctx, ob.WatchInboundTracker, bg.WithName("WatchInboundTracker"), bg.WithLogger(ob.Logger().Inbound)) +} diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go new file mode 100644 index 0000000000..a75641e072 --- /dev/null +++ b/zetaclient/chains/solana/observer/outbound.go @@ -0,0 +1,23 @@ +package observer + +import ( + "context" + + "github.com/rs/zerolog" + + "github.com/zeta-chain/zetacore/x/crosschain/types" +) + +// GetTxID returns a unique id for Solana outbound +func (ob *Observer) GetTxID(_ uint64) string { + return "" +} + +// IsOutboundProcessed checks outbound status and returns (isIncluded, isConfirmed, error) +func (ob *Observer) IsOutboundProcessed( + _ context.Context, + _ *types.CrossChainTx, + _ zerolog.Logger, +) (bool, bool, error) { + return false, false, nil +} diff --git a/zetaclient/chains/solana/rpc/rpc.go b/zetaclient/chains/solana/rpc/rpc.go new file mode 100644 index 0000000000..d83846f1e1 --- /dev/null +++ b/zetaclient/chains/solana/rpc/rpc.go @@ -0,0 +1,118 @@ +package rpc + +import ( + "context" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/pkg/errors" + + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" +) + +const ( + // defaultPageLimit is the default number of signatures to fetch in one GetSignaturesForAddressWithOpts call + DefaultPageLimit = 1000 +) + +// GetFirstSignatureForAddress searches the first signature for the given address. +// Note: make sure that the rpc provider used has enough transaction history. +func GetFirstSignatureForAddress( + ctx context.Context, + client interfaces.SolanaRPCClient, + address solana.PublicKey, + pageLimit int, +) (solana.Signature, error) { + // search backwards until we find the first signature + var lastSignature solana.Signature + for { + fetchedSignatures, err := client.GetSignaturesForAddressWithOpts( + ctx, + address, + &rpc.GetSignaturesForAddressOpts{ + Limit: &pageLimit, + Before: lastSignature, // exclusive + Commitment: rpc.CommitmentFinalized, + }, + ) + if err != nil { + return solana.Signature{}, errors.Wrapf( + err, + "error GetSignaturesForAddressWithOpts for address %s", + address, + ) + } + + // no more signatures, stop searching + if len(fetchedSignatures) == 0 { + break + } + + // update last signature for next search + lastSignature = fetchedSignatures[len(fetchedSignatures)-1].Signature + } + + // there is no signature for the given address + if lastSignature.IsZero() { + return lastSignature, errors.Errorf("no signatures found for address %s", address) + } + + return lastSignature, nil +} + +// GetSignaturesForAddressUntil searches for signatures for the given address until the given signature (exclusive). +// Note: make sure that the rpc provider used has enough transaction history. +func GetSignaturesForAddressUntil( + ctx context.Context, + client interfaces.SolanaRPCClient, + address solana.PublicKey, + untilSig solana.Signature, + pageLimit int, +) ([]*rpc.TransactionSignature, error) { + var lastSignature solana.Signature + var allSignatures []*rpc.TransactionSignature + + // make sure that the 'untilSig' exists to prevent undefined behavior on GetSignaturesForAddressWithOpts + _, err := client.GetTransaction( + ctx, + untilSig, + &rpc.GetTransactionOpts{Commitment: rpc.CommitmentFinalized}, + ) + if err != nil { + return nil, errors.Wrapf(err, "error GetTransaction for untilSig %s", untilSig) + } + + // search backwards until we hit the 'untilSig' signature + for { + fetchedSignatures, err := client.GetSignaturesForAddressWithOpts( + ctx, + address, + &rpc.GetSignaturesForAddressOpts{ + Limit: &pageLimit, + Before: lastSignature, // exclusive + Until: untilSig, // exclusive + Commitment: rpc.CommitmentFinalized, + }, + ) + if err != nil { + return nil, errors.Wrapf( + err, + "error GetSignaturesForAddressWithOpts for address %s", + address, + ) + } + + // no more signatures, stop searching + if len(fetchedSignatures) == 0 { + break + } + + // update last signature for next search + lastSignature = fetchedSignatures[len(fetchedSignatures)-1].Signature + + // append fetched signatures + allSignatures = append(allSignatures, fetchedSignatures...) + } + + return allSignatures, nil +} diff --git a/zetaclient/chains/solana/rpc/rpc_live_test.go b/zetaclient/chains/solana/rpc/rpc_live_test.go new file mode 100644 index 0000000000..c8455588ff --- /dev/null +++ b/zetaclient/chains/solana/rpc/rpc_live_test.go @@ -0,0 +1,59 @@ +package rpc_test + +import ( + "context" + "os" + "testing" + + "github.com/gagliardetto/solana-go" + solanarpc "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" +) + +// Test_SolanaRPCLive is a phony test to run all live tests +func Test_SolanaRPCLive(t *testing.T) { + // LiveTest_GetFirstSignatureForAddress(t) + // LiveTest_GetSignaturesForAddressUntil(t) +} + +func LiveTest_GetFirstSignatureForAddress(t *testing.T) { + // create a Solana devnet RPC client + urlDevnet := os.Getenv("TEST_SOL_URL_DEVNET") + client := solanarpc.New(urlDevnet) + + // program address + address := solana.MustPublicKeyFromBase58("2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s") + + // get the first signature for the address (one by one) + sig, err := rpc.GetFirstSignatureForAddress(context.TODO(), client, address, 1) + require.NoError(t, err) + + // assert + actualSig := "2tUQtcrXxtNFtV9kZ4kQsmY7snnEoEEArmu9pUptr4UCy8UdbtjPD6UtfEtPJ2qk5CTzZTmLwsbmZdLymcwSUcHu" + require.Equal(t, actualSig, sig.String()) +} + +func LiveTest_GetSignaturesForAddressUntil(t *testing.T) { + // create a Solana devnet RPC client + urlDevnet := os.Getenv("TEST_SOL_URL_DEVNET") + client := solanarpc.New(urlDevnet) + + // program address + address := solana.MustPublicKeyFromBase58("2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s") + untilSig := solana.MustSignatureFromBase58( + "2tUQtcrXxtNFtV9kZ4kQsmY7snnEoEEArmu9pUptr4UCy8UdbtjPD6UtfEtPJ2qk5CTzZTmLwsbmZdLymcwSUcHu", + ) + + // get all signatures for the address until the first signature (one by one) + sigs, err := rpc.GetSignaturesForAddressUntil(context.TODO(), client, address, untilSig, 1) + require.NoError(t, err) + + // assert + require.Greater(t, len(sigs), 0) + + // untilSig should not be in the list + for _, sig := range sigs { + require.NotEqual(t, untilSig, sig.Signature) + } +} diff --git a/zetaclient/compliance/compliance.go b/zetaclient/compliance/compliance.go index 849d56742b..71bd250b9e 100644 --- a/zetaclient/compliance/compliance.go +++ b/zetaclient/compliance/compliance.go @@ -2,10 +2,16 @@ package compliance import ( + "encoding/hex" + + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/rs/zerolog" + "github.com/zeta-chain/zetacore/pkg/chains" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/config" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" ) // IsCctxRestricted returns true if the cctx involves restricted addresses @@ -55,3 +61,21 @@ func PrintComplianceLog( inboundLoggerWithFields.Warn().Msg(logMsg) complianceLoggerWithFields.Warn().Msg(logMsg) } + +// DoesInboundContainsRestrictedAddress returns true if the inbound event contains restricted addresses +func DoesInboundContainsRestrictedAddress(event *clienttypes.InboundEvent, logger *base.ObserverLogger) bool { + // parse memo-specified receiver + receiver := "" + parsedAddress, _, err := chains.ParseAddressAndData(hex.EncodeToString(event.Memo)) + if err == nil && parsedAddress != (ethcommon.Address{}) { + receiver = parsedAddress.Hex() + } + + // check restricted addresses + if config.ContainRestrictedAddress(event.Sender, event.Receiver, receiver) { + PrintComplianceLog(logger.Inbound, logger.Compliance, + false, event.SenderChainID, event.TxHash, event.Sender, receiver, event.CoinType.String()) + return true + } + return false +} diff --git a/zetaclient/config/config_chain.go b/zetaclient/config/config_chain.go index 2da362d19f..342574764f 100644 --- a/zetaclient/config/config_chain.go +++ b/zetaclient/config/config_chain.go @@ -43,6 +43,7 @@ func New(setDefaults bool) Config { if setDefaults { cfg.BitcoinConfig = bitcoinConfigRegnet() + cfg.SolanaConfig = solanaConfigLocalnet() cfg.EVMChainConfigs = evmChainsConfigs() } @@ -59,6 +60,13 @@ func bitcoinConfigRegnet() BTCConfig { } } +// solanaConfigLocalnet contains config for Solana localnet +func solanaConfigLocalnet() SolanaConfig { + return SolanaConfig{ + Endpoint: "http://solana:8899", + } +} + // evmChainsConfigs contains EVM chain configs // it contains list of EVM chains with empty endpoint except for localnet func evmChainsConfigs() map[int64]EVMConfig { diff --git a/zetaclient/config/types.go b/zetaclient/config/types.go index 2ff5f1a657..b9535f3937 100644 --- a/zetaclient/config/types.go +++ b/zetaclient/config/types.go @@ -47,6 +47,11 @@ type BTCConfig struct { RPCParams string // "regtest", "mainnet", "testnet3" } +// SolanaConfig is the config for Solana chain +type SolanaConfig struct { + Endpoint string +} + // ComplianceConfig is the config for compliance type ComplianceConfig struct { LogPath string `json:"LogPath"` @@ -77,8 +82,10 @@ type Config struct { HsmMode bool `json:"HsmMode"` HsmHotKey string `json:"HsmHotKey"` + // chain configs EVMChainConfigs map[int64]EVMConfig `json:"EVMChainConfigs"` BitcoinConfig BTCConfig `json:"BitcoinConfig"` + SolanaConfig SolanaConfig `json:"SolanaConfig"` // compliance config ComplianceConfig ComplianceConfig `json:"ComplianceConfig"` @@ -115,6 +122,14 @@ func (c Config) GetBTCConfig() (BTCConfig, bool) { return c.BitcoinConfig, c.BitcoinConfig != (BTCConfig{}) } +// GetSolanaConfig returns the Solana config +func (c Config) GetSolanaConfig() (SolanaConfig, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.SolanaConfig, c.SolanaConfig != (SolanaConfig{}) +} + // String returns the string representation of the config func (c Config) String() string { s, err := json.MarshalIndent(c, "", "\t") diff --git a/zetaclient/context/app.go b/zetaclient/context/app.go index 4888443ea9..139d7c5bef 100644 --- a/zetaclient/context/app.go +++ b/zetaclient/context/app.go @@ -2,6 +2,7 @@ package context import ( + "fmt" "sort" "sync" @@ -22,6 +23,7 @@ type AppContext struct { chainsEnabled []chains.Chain evmChainParams map[int64]*observertypes.ChainParams bitcoinChainParams *observertypes.ChainParams + solanaChainParams *observertypes.ChainParams currentTssPubkey string crosschainFlags observertypes.CrosschainFlags @@ -49,6 +51,12 @@ func New(cfg config.Config, logger zerolog.Logger) *AppContext { bitcoinChainParams = &observertypes.ChainParams{} } + var solanaChainParams *observertypes.ChainParams + _, found = cfg.GetSolanaConfig() + if found { + solanaChainParams = &observertypes.ChainParams{} + } + return &AppContext{ config: cfg, logger: logger.With().Str("module", "appcontext").Logger(), @@ -56,6 +64,7 @@ func New(cfg config.Config, logger zerolog.Logger) *AppContext { chainsEnabled: []chains.Chain{}, evmChainParams: evmChainParams, bitcoinChainParams: bitcoinChainParams, + solanaChainParams: solanaChainParams, crosschainFlags: observertypes.CrosschainFlags{}, blockHeaderEnabledChains: []lightclienttypes.HeaderSupportedChain{}, @@ -82,6 +91,18 @@ func (a *AppContext) GetBTCChainAndConfig() (chains.Chain, config.BTCConfig, boo return btcChain, btcConfig, true } +// GetSolanaChainAndConfig returns solana chain and config if enabled +func (a *AppContext) GetSolanaChainAndConfig() (chains.Chain, config.SolanaConfig, bool) { + solConfig, configEnabled := a.Config().GetSolanaConfig() + solChain, _, paramsEnabled := a.GetSolanaChainParams() + + if !configEnabled || !paramsEnabled { + return chains.Chain{}, config.SolanaConfig{}, false + } + + return solChain, solConfig, true +} + // IsOutboundObservationEnabled returns true if the chain is supported and outbound flag is enabled func (a *AppContext) IsOutboundObservationEnabled(chainParams observertypes.ChainParams) bool { flags := a.GetCrossChainFlags() @@ -173,7 +194,8 @@ func (a *AppContext) GetBTCChainParams() (chains.Chain, *observertypes.ChainPara a.mu.RLock() defer a.mu.RUnlock() - if a.bitcoinChainParams == nil { // bitcoin is not enabled + // bitcoin is not enabled + if a.bitcoinChainParams == nil { return chains.Chain{}, nil, false } @@ -185,6 +207,25 @@ func (a *AppContext) GetBTCChainParams() (chains.Chain, *observertypes.ChainPara return chain, a.bitcoinChainParams, true } +// GetSolanaChainParams returns (chain, chain params, found) for solana chain +func (a *AppContext) GetSolanaChainParams() (chains.Chain, *observertypes.ChainParams, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + + // solana is not enabled + if a.solanaChainParams == nil { + return chains.Chain{}, nil, false + } + + chain, found := chains.GetChainFromChainID(a.solanaChainParams.ChainId, a.additionalChain) + if !found { + fmt.Printf("solana Chain %d not found", a.solanaChainParams.ChainId) + return chains.Chain{}, nil, false + } + + return chain, a.solanaChainParams, true +} + // GetCrossChainFlags returns crosschain flags func (a *AppContext) GetCrossChainFlags() observertypes.CrosschainFlags { a.mu.RLock() @@ -229,6 +270,7 @@ func (a *AppContext) Update( newChains []chains.Chain, evmChainParams map[int64]*observertypes.ChainParams, btcChainParams *observertypes.ChainParams, + solChainParams *observertypes.ChainParams, tssPubKey string, crosschainFlags observertypes.CrosschainFlags, additionalChains []chains.Chain, @@ -269,6 +311,11 @@ func (a *AppContext) Update( a.bitcoinChainParams = btcChainParams } + // update chain params for solana if it has config in file + if a.solanaChainParams != nil && solChainParams != nil { + a.solanaChainParams = solChainParams + } + // update core params for evm chains we have configs in file for _, params := range evmChainParams { _, found := a.evmChainParams[params.ChainId] diff --git a/zetaclient/context/app_test.go b/zetaclient/context/app_test.go index 39847e2097..1786242b20 100644 --- a/zetaclient/context/app_test.go +++ b/zetaclient/context/app_test.go @@ -163,6 +163,7 @@ func TestAppContextUpdate(t *testing.T) { enabledChainsToUpdate, evmChainParamsToUpdate, btcChainParamsToUpdate, + nil, tssPubKeyToUpdate, *crosschainFlags, []chains.Chain{}, @@ -264,6 +265,7 @@ func TestAppContextUpdate(t *testing.T) { enabledChainsToUpdate, evmChainParamsToUpdate, btcChainParamsToUpdate, + nil, tssPubKeyToUpdate, *crosschainFlags, []chains.Chain{}, @@ -409,6 +411,7 @@ func TestGetBTCChainAndConfig(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: 123}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -427,6 +430,7 @@ func TestGetBTCChainAndConfig(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: chains.BitcoinMainnet.ChainId}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -471,6 +475,7 @@ func TestGetBlockHeaderEnabledChains(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{ChainId: chains.BitcoinMainnet.ChainId}, + nil, "", observertypes.CrosschainFlags{}, []chains.Chain{}, @@ -513,6 +518,7 @@ func TestGetAdditionalChains(t *testing.T) { []chains.Chain{}, nil, &observertypes.ChainParams{}, + nil, "", observertypes.CrosschainFlags{}, additionalChains, @@ -553,6 +559,7 @@ func makeAppContext( []chains.Chain{evmChain}, evmChainParamsMap, nil, + nil, "", ccFlags, []chains.Chain{}, diff --git a/zetaclient/orchestrator/orchestrator_test.go b/zetaclient/orchestrator/orchestrator_test.go index 8637834a6e..06e7aaf625 100644 --- a/zetaclient/orchestrator/orchestrator_test.go +++ b/zetaclient/orchestrator/orchestrator_test.go @@ -80,6 +80,7 @@ func CreateAppContext( []chains.Chain{evmChain, btcChain}, evmChainParamsMap, btcChainParams, + nil, "", *ccFlags, []chains.Chain{}, diff --git a/zetaclient/testdata/solana/chain_901_inbound_tx_result_5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json b/zetaclient/testdata/solana/chain_901_inbound_tx_result_5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json new file mode 100644 index 0000000000..4e5b8bdb98 --- /dev/null +++ b/zetaclient/testdata/solana/chain_901_inbound_tx_result_5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk.json @@ -0,0 +1,64 @@ +{ + "slot": 309926562, + "blockTime": 1720328277, + "transaction": { + "signatures": [ + "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + ], + "message": { + "accountKeys": [ + "AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L", + "4pA5vqGeo4ipLoJzH3rdvguhifj1tCzoNM8vDRc4Xbmq", + "11111111111111111111111111111111", + "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 2 + }, + "recentBlockhash": "9BYDuzjYhac5AqhsV3H3wNtj3tK1aT6k2oFLpTo1h3nL", + "instructions": [ + { + "programIdIndex": 3, + "accounts": [0, 1, 2, 3], + "data": "FQx87VJVvGQu6jGz7VmavZREFcSxTNNuB5hWd7npbi5M9CzWRjjcAaW9woj8WpxPcB9C9gmQYeYXTEsJ1mZ7W" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [3171104080, 1447680, 1, 1141440], + "postBalances": [3171097800, 1448960, 1, 1141440], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 2, + "accounts": [0, 1], + "data": "3Bxs3zrrEsuzMyc3" + } + ] + } + ], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s invoke [1]", + "Program log: Instruction: Deposit", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program log: AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L deposits 1280 lamports to PDA", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s consumed 16968 of 200000 compute units", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s success" + ], + "status": { "Ok": null }, + "rewards": [], + "loadedAddresses": { "readonly": [], "writable": [] }, + "computeUnitsConsumed": 16968 + }, + "version": 0 +} diff --git a/zetaclient/testutils/testdata.go b/zetaclient/testutils/testdata.go index 620eb16b17..ec97d6d1c4 100644 --- a/zetaclient/testutils/testdata.go +++ b/zetaclient/testutils/testdata.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcjson" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/gagliardetto/solana-go/rpc" "github.com/onrik/ethrpc" "github.com/stretchr/testify/require" @@ -21,6 +22,7 @@ import ( const ( TestDataPathEVM = "testdata/evm" TestDataPathBTC = "testdata/btc" + TestDataPathSolana = "testdata/solana" TestDataPathCctx = "testdata/cctx" RestrictedEVMAddressTest = "0x8a81Ba8eCF2c418CAe624be726F505332DF119C6" RestrictedBtcAddressTest = "bcrt1qzp4gt6fc7zkds09kfzaf9ln9c5rvrzxmy6qmpp" @@ -290,6 +292,26 @@ func LoadEVMCctxNOutboundNReceipt( return cctx, outbound, receipt } +//============================================================================== +// Solana chain + +// LoadSolanaInboundTxResult loads archived Solana inbound tx result from file +func LoadSolanaInboundTxResult( + t *testing.T, + dir string, + chainID int64, + txHash string, + donation bool, +) *rpc.GetTransactionResult { + name := path.Join(dir, TestDataPathSolana, FileNameSolanaInbound(chainID, txHash, donation)) + txResult := &rpc.GetTransactionResult{} + LoadObjectFromJSONFile(t, txResult, name) + return txResult +} + +//============================================================================== +// other helpers methods + // SaveObjectToJSONFile saves an object to a file in JSON format // NOTE: this function is not used in the tests but used when creating test data func SaveObjectToJSONFile(obj interface{}, filename string) error { diff --git a/zetaclient/testutils/testdata_naming.go b/zetaclient/testutils/testdata_naming.go index f0345e347c..940d475780 100644 --- a/zetaclient/testutils/testdata_naming.go +++ b/zetaclient/testutils/testdata_naming.go @@ -77,3 +77,14 @@ func FileNameEVMOutboundReceipt(chainID int64, txHash string, coinType coin.Coin } return fmt.Sprintf("chain_%d_outbound_receipt_%s_%s_%s.json", chainID, coinType, eventName, txHash) } + +//================================================================================================= +// Solana chain + +// FileNameSolanaInbound returns archive file name for inbound tx result +func FileNameSolanaInbound(chainID int64, inboundHash string, donation bool) string { + if !donation { + return fmt.Sprintf("chain_%d_inbound_tx_result_%s.json", chainID, inboundHash) + } + return fmt.Sprintf("chain_%d_inbound_tx_result_donation_%s.json", chainID, inboundHash) +} diff --git a/zetaclient/types/event.go b/zetaclient/types/event.go new file mode 100644 index 0000000000..1f498af67f --- /dev/null +++ b/zetaclient/types/event.go @@ -0,0 +1,43 @@ +package types + +import ( + "github.com/zeta-chain/zetacore/pkg/coin" +) + +// InboundEvent represents an inbound event +// TODO: we should consider using this generic struct when it applies (e.g. for Bitcoin, Solana, etc.) +// https://github.com/zeta-chain/node/issues/2495 +type InboundEvent struct { + // SenderChainID is the chain ID of the sender + SenderChainID int64 + + // Sender is the sender address + Sender string + + // Receiver is the receiver address + Receiver string + + // TxOrigin is the origin of the transaction + TxOrigin string + + // Value is the amount of token + Amount uint64 + + // Memo is the memo attached to the inbound + Memo []byte + + // BlockNumber is the block number of the inbound + BlockNumber uint64 + + // TxHash is the hash of the inbound + TxHash string + + // Index is the index of the event + Index uint32 + + // CoinType is the coin type of the inbound + CoinType coin.CoinType + + // Asset is the asset of the inbound + Asset string +} diff --git a/zetaclient/types/sql.go b/zetaclient/types/sql.go new file mode 100644 index 0000000000..1a47c3f9ea --- /dev/null +++ b/zetaclient/types/sql.go @@ -0,0 +1,41 @@ +package types + +import ( + "gorm.io/gorm" +) + +const ( + // LastBlockNumID is the identifier to access the last block number in the database + LastBlockNumID = 0xBEEF + + // LastTxHashID is the identifier to access the last transaction hash in the database + LastTxHashID = 0xBEF0 +) + +// LastBlockSQLType is a model for storing the last block number +type LastBlockSQLType struct { + gorm.Model + Num uint64 +} + +// LastTransactionSQLType is a model for storing the last transaction hash +type LastTransactionSQLType struct { + gorm.Model + Hash string +} + +// ToLastBlockSQLType converts a last block number to a LastBlockSQLType +func ToLastBlockSQLType(lastBlock uint64) *LastBlockSQLType { + return &LastBlockSQLType{ + Model: gorm.Model{ID: LastBlockNumID}, + Num: lastBlock, + } +} + +// ToLastTxHashSQLType converts a last transaction hash to a LastTransactionSQLType +func ToLastTxHashSQLType(lastTx string) *LastTransactionSQLType { + return &LastTransactionSQLType{ + Model: gorm.Model{ID: LastTxHashID}, + Hash: lastTx, + } +} diff --git a/zetaclient/types/sql_evm.go b/zetaclient/types/sql_evm.go index 398a968a60..7679237ae7 100644 --- a/zetaclient/types/sql_evm.go +++ b/zetaclient/types/sql_evm.go @@ -11,8 +11,6 @@ import ( // EVM Chain observer types -----------------------------------> -const LastBlockNumID = 0xBEEF - // ReceiptDB : A modified receipt struct that the relational mapping can translate type ReceiptDB struct { // Consensus fields: These fields are defined by the Yellow Paper @@ -64,11 +62,6 @@ type TransactionSQLType struct { Transaction TransactionDB `gorm:"embedded"` } -type LastBlockSQLType struct { - gorm.Model - Num uint64 -} - // Type translation functions: // ToReceiptDBType : Converts an Ethereum receipt to a ReceiptDB type @@ -159,11 +152,3 @@ func ToTransactionSQLType(transaction *ethtypes.Transaction, index string) (*Tra Transaction: trans, }, nil } - -// ToLastBlockSQLType : Converts a last block number to a LastBlockSQLType -func ToLastBlockSQLType(lastBlock uint64) *LastBlockSQLType { - return &LastBlockSQLType{ - Model: gorm.Model{ID: LastBlockNumID}, - Num: lastBlock, - } -} diff --git a/zetaclient/zetacore/client.go b/zetaclient/zetacore/client.go index 2874408451..9ab37e231e 100644 --- a/zetaclient/zetacore/client.go +++ b/zetaclient/zetacore/client.go @@ -381,6 +381,7 @@ func (c *Client) UpdateZetacoreContext( newEVMParams := make(map[int64]*observertypes.ChainParams) var newBTCParams *observertypes.ChainParams + var newSolanaParams *observertypes.ChainParams // check and update chain params for each chain for _, chainParam := range chainParams { @@ -391,6 +392,8 @@ func (c *Client) UpdateZetacoreContext( } if chains.IsBitcoinChain(chainParam.ChainId, additionalChains) { newBTCParams = chainParam + } else if chains.IsSolanaChain(chainParam.ChainId, additionalChains) { + newSolanaParams = chainParam } else if chains.IsEVMChain(chainParam.ChainId, additionalChains) { newEVMParams[chainParam.ChainId] = chainParam } @@ -436,6 +439,7 @@ func (c *Client) UpdateZetacoreContext( newChains, newEVMParams, newBTCParams, + newSolanaParams, tssPubKey, crosschainFlags, additionalChains,