diff --git a/cmd/solana/main.go b/cmd/solana/main.go index d89c4f1a17..fcf19d011b 100644 --- a/cmd/solana/main.go +++ b/cmd/solana/main.go @@ -18,7 +18,7 @@ import ( ) const ( - PYTH_PROGRAM_DEVNET = "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s" // this program has many many txs + pythProgramDevnet = "gSbePebfvPy7tRqimPoVecS2UsBvYv46ynrzWocc92s" // this program has many many txs ) //go:embed gateway.json @@ -31,11 +31,15 @@ func main() { limit := 10 out, err := client.GetSignaturesForAddressWithOpts( context.TODO(), - solana.MustPublicKeyFromBase58(PYTH_PROGRAM_DEVNET), + solana.MustPublicKeyFromBase58(pythProgramDevnet), &rpc.GetSignaturesForAddressOpts{ - Limit: &limit, - Before: solana.MustSignatureFromBase58("5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL"), - Until: solana.MustSignatureFromBase58("2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e"), + Limit: &limit, + Before: solana.MustSignatureFromBase58( + "5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL", + ), + Until: solana.MustSignatureFromBase58( + "2coX9CckSmJWeHVqJNANeD7m4J7pctpSomxMon3h36droxCVB3JDbLyWQKMjnf85ntuFGxMLySykEMaRd5MDw35e", + ), }, ) @@ -65,11 +69,11 @@ func main() { // Parsing a Deposit Instruction // devnet tx: deposit with memo // https://solana.fm/tx/51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg - const DEPOSIT_TX = "51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg" + const depositTx = "51746triQeve21zP1bcVEPvvsoXt94B57TU5exBvoy938bhGCfzBtsvKJbLpS1zRc2dmb3S3HBHnhTfbtKCBpmqg" tx, err := client.GetTransaction( context.TODO(), - solana.MustSignatureFromBase58(DEPOSIT_TX), + solana.MustSignatureFromBase58(depositTx), &rpc.GetTransactionOpts{}) if err != nil { log.Fatalf("Error getting transaction: %v", err) @@ -111,11 +115,13 @@ func main() { { // explore failed transaction //https://explorer.solana.com/tx/2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv - tx_sig := solana.MustSignatureFromBase58("2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv") + txSig := solana.MustSignatureFromBase58( + "2LbBdmCkuVyQhHAvsZhZ1HLdH12jQbHY7brwH6xUBsZKKPuV8fomyz1Qh9CaCZSqo8FNefaR8ir7ngo7H3H2VfWv", + ) client2 := rpc.New("https://solana-mainnet.g.allthatnode.com/archive/json_rpc/842c667c947e42e2a9995ac2ec75026d") tx, err := client2.GetTransaction( context.TODO(), - tx_sig, + txSig, &rpc.GetTransactionOpts{}) if err != nil { log.Fatalf("Error getting transaction: %v", err) @@ -163,9 +169,9 @@ func main() { } fmt.Println("recent blockhash:", recent.Value.Blockhash) - programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") + programID := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) if err != nil { panic(err) } @@ -177,8 +183,8 @@ func main() { 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(programId)) - inst.ProgID = programId + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID inst.AccountValues = accountSlice type DepositInstructionParams struct { @@ -255,9 +261,9 @@ func main() { Nonce uint64 } // fetch PDA account - programId := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") + programID := solana.MustPublicKeyFromBase58("4Nt8tsYWQj3qC1TbunmmmDbzRXE4UQuzcGcqqgwy9bvX") seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) if err != nil { panic(err) } @@ -272,8 +278,13 @@ func main() { if err != nil { panic(err) } + + // deserialize PDA account var pda PdaInfo - borsh.Deserialize(&pda, pdaInfo.Bytes()) + err = borsh.Deserialize(&pda, pdaInfo.Bytes()) + if err != nil { + panic(err) + } //spew.Dump(pda) // building the transaction @@ -316,13 +327,16 @@ func main() { MessageHash: messageHash, Nonce: nonce, }) + if err != nil { + panic(err) + } var accountSlice []*solana.AccountMeta accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) accountSlice = append(accountSlice, solana.Meta(pdaComputed).WRITE()) accountSlice = append(accountSlice, solana.Meta(to).WRITE()) - accountSlice = append(accountSlice, solana.Meta(programId)) - inst.ProgID = programId + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID inst.AccountValues = accountSlice tx, err := solana.NewTransaction( []solana.Instruction{&inst}, @@ -355,7 +369,5 @@ func main() { panic(err) } spew.Dump(txsig) - } - } diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index fb18c3e5b0..2660639da8 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -6,9 +6,9 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" - "github.com/zeta-chain/zetacore/pkg/chains" - observertypes "github.com/zeta-chain/zetacore/x/observer/types" + solrpc "github.com/gagliardetto/solana-go/rpc" + observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/authz" "github.com/zeta-chain/zetacore/zetaclient/chains/base" btcobserver "github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin/observer" @@ -193,18 +193,39 @@ func CreateChainObserverMap( } } - // FIXME: config this + // FIXME_SOLANA: config chain params + solChain, solConfig, enabled := appContext.GetSolanaChainAndConfig() solChainParams := observertypes.ChainParams{ GatewayAddress: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d", IsSupported: true, - ChainId: chains.SolanaLocalnet.ChainId, + ChainId: solChain.ChainId, + InboundTicker: 10, } - co, err := solanaobserver.NewObserver(appContext, zetacoreClient, solChainParams, tss, dbpath, ts) - if err != nil { - logger.Std.Error().Err(err).Msg("NewObserver error for solana chain") - } else { - // TODO: config this - observerMap[solChainParams.ChainId] = co + + // create Solana chain observer if enabled + if enabled { + rpcClient := solrpc.New(solConfig.Endpoint) + if rpcClient == nil { + // should never happen + return nil, fmt.Errorf("solana create Solana client error") + } + + // create Solana chain observer + co, err := solanaobserver.NewObserver( + solChain, + rpcClient, + solChainParams, + appContext, + zetacoreClient, + tss, + logger, + ts, + ) + if err != nil { + logger.Std.Error().Err(err).Msg("NewObserver error for solana chain") + } else { + observerMap[solChainParams.ChainId] = co + } } return observerMap, nil diff --git a/cmd/zetae2e/config/clients.go b/cmd/zetae2e/config/clients.go index c26774a330..7ebbee4b83 100644 --- a/cmd/zetae2e/config/clients.go +++ b/cmd/zetae2e/config/clients.go @@ -44,21 +44,33 @@ func getClientsFromConfig(ctx context.Context, conf config.Config, account confi } btcRPCClient, err := getBtcClient(conf.RPCs.Bitcoin) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get btc client: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, 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, nil, fmt.Errorf("failed to get evm client: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, 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, nil, fmt.Errorf("failed to get zeta clients: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, 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, nil, fmt.Errorf("failed to get zevm client: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf( + "failed to get zevm client: %w", + err, + ) } return btcRPCClient, solanaClient, diff --git a/cmd/zetae2e/init.go b/cmd/zetae2e/init.go index f0526188be..bb2c2b5457 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.SolanaRPC, "solanaURL", "http://solana:8899", "--solanaURL http://solana:8899") + InitCmd.Flags(). + StringVar(&initConf.RPCs.SolanaRPC, "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 c5626646d6..61afe60207 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -274,7 +274,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { eg.Go(zetaTestRoutine(conf, deployerRunner, verbose, zetaTests...)) eg.Go(zevmMPTestRoutine(conf, deployerRunner, verbose, zevmMPTests...)) eg.Go(bitcoinTestRoutine(conf, deployerRunner, verbose, !skipBitcoinSetup, testHeader, bitcoinTests...)) - eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, !skipBitcoinSetup, testHeader, solanaTests...)) + eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, testHeader, solanaTests...)) eg.Go(ethereumTestRoutine(conf, deployerRunner, verbose, testHeader, ethereumTests...)) } if testAdmin { diff --git a/cmd/zetae2e/local/solana.go b/cmd/zetae2e/local/solana.go index e31a75dfb9..405e397480 100644 --- a/cmd/zetae2e/local/solana.go +++ b/cmd/zetae2e/local/solana.go @@ -17,8 +17,7 @@ func solanaTestRoutine( conf config.Config, deployerRunner *runner.E2ERunner, verbose bool, - initBitcoinNetwork bool, - testHeader bool, + _ bool, testNames ...string, ) func() error { return func() (err error) { diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index 67bedaaaef..9dd953bc6e 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -8,6 +8,7 @@ import ( "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/near/borsh-go" + "github.com/zeta-chain/zetacore/e2e/runner" "github.com/zeta-chain/zetacore/pkg/chains" ) @@ -39,15 +40,17 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { } r.Logger.Print("recent blockhash: %s", recent.Value.Blockhash) - programId := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + programID := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) if err != nil { panic(err) } r.Logger.Print("computed pda: %s, bump %d\n", pdaComputed, bump) - privkey := solana.MustPrivateKeyFromBase58("4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C") + privkey := solana.MustPrivateKeyFromBase58( + "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C", + ) r.Logger.Print("user pubkey: %s", privkey.PublicKey().String()) bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) if err != nil { @@ -60,21 +63,21 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { 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(programId)) - inst.ProgID = programId + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID inst.AccountValues = accountSlice type InitializeParams struct { Discriminator [8]byte TssAddress [20]byte - ChainId uint64 + ChainID uint64 } r.Logger.Print("TSS EthAddress: %s", r.TSSAddress) inst.DataBytes, err = borsh.Serialize(InitializeParams{ Discriminator: [8]byte{175, 175, 109, 31, 13, 152, 155, 237}, TssAddress: r.TSSAddress, - ChainId: uint64(chains.SolanaLocalnet.ChainId), + ChainID: uint64(chains.SolanaLocalnet.ChainId), }) if err != nil { panic(err) @@ -120,17 +123,24 @@ func TestSolanaInitializeGateway(r *runner.E2ERunner, args []string) { r.Logger.Print("error getting PDA info: %v", err) panic(err) } + + // deserialize the PDA info var pda PdaInfo - borsh.Deserialize(&pda, pdaInfo.Bytes()) + err = borsh.Deserialize(&pda, pdaInfo.Bytes()) + if err != nil { + r.Logger.Print("error deserializing PDA info: %v", err) + panic(err) + } r.Logger.Print("PDA info Tss: %v", pda.TssAddress) - } -func TestSolanaDeposit(r *runner.E2ERunner, args []string) { +func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { client := r.SolanaClient - privkey := solana.MustPrivateKeyFromBase58("4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C") + privkey := solana.MustPrivateKeyFromBase58( + "4yqSQxDeTBvn86BuxcN5jmZb2gaobFXrBqu8kiE9rZxNkVMe3LfXmFigRsU4sRp7vk4vVP1ZCFiejDKiXBNWvs2C", + ) // build & bcast a Depsosit tx bal, err := client.GetBalance(context.TODO(), privkey.PublicKey(), rpc.CommitmentFinalized) @@ -146,11 +156,11 @@ func TestSolanaDeposit(r *runner.E2ERunner, args []string) { r.Logger.Error("Error getting recent blockhash: %v", err) panic(err) } - r.Logger.Print("recent blockhash:", recent.Value.Blockhash) + r.Logger.Print("recent blockhash: %s", recent.Value.Blockhash) - programId := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + programID := solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") seed := []byte("meta") - pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programId) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, programID) if err != nil { r.Logger.Error("Error finding program address: %v", err) panic(err) @@ -163,8 +173,8 @@ func TestSolanaDeposit(r *runner.E2ERunner, args []string) { 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(programId)) - inst.ProgID = programId + accountSlice = append(accountSlice, solana.Meta(programID)) + inst.ProgID = programID inst.AccountValues = accountSlice type DepositInstructionParams struct { @@ -238,5 +248,4 @@ func TestSolanaDeposit(r *runner.E2ERunner, args []string) { // cctx.CctxStatus.StatusMessage), // ) //} - } diff --git a/e2e/runner/setup_zeta.go b/e2e/runner/setup_zeta.go index 3dc01dc51f..57aa875cd7 100644 --- a/e2e/runner/setup_zeta.go +++ b/e2e/runner/setup_zeta.go @@ -67,12 +67,12 @@ func (r *E2ERunner) SetTSSAddresses() error { } // SetSolanaContracts set Solana contracts -func (runner *E2ERunner) SetSolanaContracts() { - runner.Logger.Print("⚙️ setting up Solana contracts") +func (r *E2ERunner) SetSolanaContracts() { + r.Logger.Print("⚙️ setting up Solana contracts") // set Solana contracts // TODO: remove this hardcoded stuff for localnet - runner.GatewayProgram = solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") + r.GatewayProgram = solana.MustPublicKeyFromBase58("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d") } // SetZEVMContracts set contracts for the ZEVM diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 27b2d4eddc..a90f8c83e3 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -4,15 +4,16 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" + zetabitcoin "github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin" ) // DepositSolWithAmount deposits Sol on ZetaChain with a specific amount -func (runner *E2ERunner) DepositSolWithAmount(amount float64) (txHash *chainhash.Hash) { - runner.Logger.Print("⏳ depositing Sol into ZEVM") +func (r *E2ERunner) DepositSolWithAmount(amount float64) (txHash *chainhash.Hash) { + r.Logger.Print("⏳ depositing Sol into ZEVM") // list deployer utxos - utxos, err := runner.ListDeployerUTXOs() + utxos, err := r.ListDeployerUTXOs() if err != nil { panic(err) } @@ -34,17 +35,17 @@ func (runner *E2ERunner) DepositSolWithAmount(amount float64) (txHash *chainhash )) } - runner.Logger.Info("ListUnspent:") - runner.Logger.Info(" spendableAmount: %f", spendableAmount) - runner.Logger.Info(" spendableUTXOs: %d", spendableUTXOs) - runner.Logger.Info("Now sending two txs to TSS address...") + r.Logger.Info("ListUnspent:") + r.Logger.Info(" spendableAmount: %f", spendableAmount) + r.Logger.Info(" spendableUTXOs: %d", spendableUTXOs) + r.Logger.Info("Now sending two txs to TSS address...") amount = amount + zetabitcoin.DefaultDepositorFee - txHash, err = runner.SendToTSSFromDeployerToDeposit(amount, utxos) + txHash, err = r.SendToTSSFromDeployerToDeposit(amount, utxos) if err != nil { panic(err) } - runner.Logger.Info("send BTC to TSS txHash: %s", txHash.String()) + r.Logger.Info("send BTC to TSS txHash: %s", txHash.String()) return txHash } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 1ccc489b09..f7ea82f961 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -37,6 +37,7 @@ import ( "github.com/evmos/ethermint/crypto/hd" etherminttypes "github.com/evmos/ethermint/types" evmtypes "github.com/evmos/ethermint/x/evm/types" + "github.com/zeta-chain/zetacore/app" "github.com/zeta-chain/zetacore/cmd/zetacored/config" "github.com/zeta-chain/zetacore/pkg/chains" @@ -229,7 +230,11 @@ func broadcastWithBlockTimeout(zts ZetaTxServer, txBytes []byte) (*sdktypes.TxRe for { select { case <-exitAfter: - return nil, fmt.Errorf("timed out after waiting for tx to get included in the block: %d; tx hash %s", zts.blockTimeout, res.TxHash) + return nil, fmt.Errorf( + "timed out after waiting for tx to get included in the block: %d; tx hash %s", + zts.blockTimeout, + res.TxHash, + ) case <-time.After(time.Millisecond * 100): resTx, err := zts.clientCtx.Client.Tx(context.TODO(), hash, false) diff --git a/go.mod b/go.mod index 6e858c79cb..d886db6903 100644 --- a/go.mod +++ b/go.mod @@ -81,7 +81,6 @@ require ( 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/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect github.com/cockroachdb/pebble v0.0.0-20220817183557-09c6e030a677 // indirect @@ -104,7 +103,6 @@ require ( github.com/golang/glog v1.1.2 // indirect github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/gorilla/rpc v1.2.0 // indirect github.com/iancoleman/orderedmap v0.3.0 // indirect github.com/ipfs/boxo v0.10.0 // indirect github.com/jmhodges/levigo v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5c617d446c..3ebcd10d63 100644 --- a/go.sum +++ b/go.sum @@ -379,8 +379,6 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= @@ -889,8 +887,6 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/rpc v1.2.0 h1:WvvdC2lNeT1SP32zrIce5l0ECBfbAlmrmSBsuc57wfk= -github.com/gorilla/rpc v1.2.0/go.mod h1:V4h9r+4sF5HnzqbwIez0fKSpANP0zlYd3qR7p36jkTQ= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1-0.20190629185528-ae1634f6a989/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 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/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index edfef83629..6cb48b2519 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -16,11 +16,13 @@ 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/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" + "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) const ( @@ -60,6 +62,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 @@ -103,6 +108,7 @@ func NewObserver( tss: tss, lastBlock: 0, lastBlockScanned: 0, + lastTxScanned: "", ts: ts, mu: &sync.Mutex{}, stop: make(chan struct{}), @@ -215,6 +221,21 @@ func (ob *Observer) WithLastBlockScanned(blockNumber uint64) *Observer { return ob } +// LastTxScanned get last transaction scanned. +func (ob *Observer) LastTxScanned() string { + ob.mu.Lock() + defer ob.mu.Unlock() + return ob.lastTxScanned +} + +// WithLastTxScanned set last transaction scanned. +func (ob *Observer) WithLastTxScanned(txHash string) *Observer { + ob.mu.Lock() + defer ob.mu.Unlock() + ob.lastTxScanned = txHash + return ob +} + // BlockCache returns the block cache for the observer. func (ob *Observer) BlockCache() *lru.Cache { return ob.blockCache @@ -310,7 +331,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") } @@ -361,8 +385,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 } @@ -388,7 +410,80 @@ 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(logger zerolog.Logger) { + // get environment variable + envvar := EnvVarLatestTxByChain(ob.chain) + scanFromTx := os.Getenv(envvar) + + // load from environment variable if set + if scanFromTx != "" { + logger.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 + logger.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) error { + ob.WithLastTxScanned(txHash) + 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( + msg *crosschaintypes.MsgVoteInbound, + retryGasLimit uint64, +) (string, error) { + txHash := msg.InboundHash + coinType := msg.CoinType + chainID := ob.Chain().ChainId + zetaHash, ballot, err := ob.ZetacoreClient().PostVoteInbound(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 e6d5a088a9..8884325cdb 100644 --- a/zetaclient/chains/base/observer_test.go +++ b/zetaclient/chains/base/observer_test.go @@ -11,6 +11,7 @@ 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" @@ -22,9 +23,8 @@ import ( ) // 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) appContext := context.New(config.NewConfig(), zerolog.Nop()) zetacoreClient := mocks.NewMockZetacoreClient() @@ -137,7 +137,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 @@ -146,8 +146,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 @@ -155,7 +156,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) @@ -163,7 +164,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.NewMockZetacoreClient() @@ -171,7 +172,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() @@ -179,7 +180,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) @@ -187,15 +188,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) @@ -205,7 +214,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) @@ -217,14 +226,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() @@ -232,7 +241,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 @@ -247,7 +256,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 @@ -280,7 +289,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) @@ -295,7 +304,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) @@ -307,7 +316,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) @@ -325,7 +334,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) @@ -343,7 +352,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) @@ -360,7 +369,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) @@ -378,11 +387,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) @@ -397,7 +407,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) @@ -406,3 +416,127 @@ 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(log.Logger) + 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(log.Logger) + 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(log.Logger) + 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 + lastTx := "5LuQMorgd11p8GWEw6pmyHCDtA26NUyeNFhLWPNk2oBoM9pkag1LzhwGSRos3j4TJLhKjswFhZkGtvSGdLDkmqsk" + err = ob.SaveLastTxScanned(lastTx) + require.NoError(t, err) + + // check last tx scanned in memory + require.EqualValues(t, lastTx, ob.LastTxScanned()) + + // 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.NewMockZetacoreClient() + ob = ob.WithZetacoreClient(zetacoreClient) + + // post vote inbound + msg := sample.InboundVote(coin.CoinType_Gas, chains.Ethereum.ChainId, chains.ZetaChainMainnet.ChainId) + _, err := ob.PostVoteInbound(&msg, 100000) + require.NoError(t, err) + }) +} diff --git a/zetaclient/chains/evm/observer/inbound.go b/zetaclient/chains/evm/observer/inbound.go index 889d2215ac..676ee4c213 100644 --- a/zetaclient/chains/evm/observer/inbound.go +++ b/zetaclient/chains/evm/observer/inbound.go @@ -290,7 +290,6 @@ func (ob *Observer) ObserveZetaSent(startBlock, toBlock uint64) uint64 { if msg != nil { _, err = ob.PostVoteInbound( msg, - coin.CoinType_Zeta, zetacore.PostVoteInboundMessagePassingExecutionGasLimit, ) if err != nil { @@ -376,7 +375,7 @@ func (ob *Observer) ObserveERC20Deposited(startBlock, toBlock uint64) uint64 { msg := ob.BuildInboundVoteMsgForDepositedEvent(event, sender) if msg != nil { - _, err = ob.PostVoteInbound(msg, coin.CoinType_ERC20, zetacore.PostVoteInboundExecutionGasLimit) + _, err = ob.PostVoteInbound(msg, zetacore.PostVoteInboundExecutionGasLimit) if err != nil { return beingScanned - 1 // we have to re-scan from this block next time } @@ -461,7 +460,7 @@ func (ob *Observer) CheckAndVoteInboundTokenZeta( return "", nil } if vote { - return ob.PostVoteInbound(msg, coin.CoinType_Zeta, zetacore.PostVoteInboundMessagePassingExecutionGasLimit) + return ob.PostVoteInbound(msg, zetacore.PostVoteInboundMessagePassingExecutionGasLimit) } return msg.Digest(), nil @@ -511,7 +510,7 @@ func (ob *Observer) CheckAndVoteInboundTokenERC20( return "", nil } if vote { - return ob.PostVoteInbound(msg, coin.CoinType_ERC20, zetacore.PostVoteInboundExecutionGasLimit) + return ob.PostVoteInbound(msg, zetacore.PostVoteInboundExecutionGasLimit) } return msg.Digest(), nil @@ -549,34 +548,12 @@ func (ob *Observer) CheckAndVoteInboundTokenGas( return "", nil } if vote { - return ob.PostVoteInbound(msg, coin.CoinType_Gas, zetacore.PostVoteInboundExecutionGasLimit) + return ob.PostVoteInbound(msg, zetacore.PostVoteInboundExecutionGasLimit) } return msg.Digest(), nil } -// PostVoteInbound posts a vote for the given vote message -func (ob *Observer) PostVoteInbound( - msg *types.MsgVoteInbound, - coinType coin.CoinType, - retryGasLimit uint64, -) (string, error) { - txHash := msg.InboundHash - chainID := ob.Chain().ChainId - zetaHash, ballot, err := ob.ZetacoreClient().PostVoteInbound(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/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 2272ef1dfe..ef26558433 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" @@ -153,6 +155,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/constants.go b/zetaclient/chains/solana/constants.go index ef92f06771..d8d500a899 100644 --- a/zetaclient/chains/solana/constants.go +++ b/zetaclient/chains/solana/constants.go @@ -1 +1,18 @@ package solana + +// DiscriminatorDeposit returns the discriminator for Solana gateway deposit instruction +func DiscriminatorDeposit() []byte { + return []byte{242, 35, 198, 137, 82, 225, 242, 182} +} + +const ( + // 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 + + // MaxSignaturesPerTicker is the maximum number of signatures to process on a ticker + MaxSignaturesPerTicker = 100 +) diff --git a/zetaclient/chains/solana/observer/db.go b/zetaclient/chains/solana/observer/db.go new file mode 100644 index 0000000000..3e41b27f03 --- /dev/null +++ b/zetaclient/chains/solana/observer/db.go @@ -0,0 +1,48 @@ +package observer + +import ( + "github.com/pkg/errors" + + solanarpc "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" +) + +// 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) + } + + // load last scanned tx + err = ob.LoadLastTxScanned() + + return err +} + +// LoadLastTxScanned loads the last scanned tx from the database. +// TODO(revamp): move to a db file +func (ob *Observer) LoadLastTxScanned() error { + ob.Observer.LoadLastTxScanned(ob.Logger().Chain) + + // when last scanned tx is absent in the database, the observer will scan from the 1st signature for the gateway address. + // this is useful when bootstrapping the Solana observer + if ob.LastTxScanned() == "" { + firstSigature, err := solanarpc.GetFirstSignatureForAddress( + ob.solClient, + ob.gatewayID, + solanarpc.DefaultPageLimit, + ) + if err != nil { + return err + } + ob.WithLastTxScanned(firstSigature.String()) + } + 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..7926bc03d6 --- /dev/null +++ b/zetaclient/chains/solana/observer/db_test.go @@ -0,0 +1,104 @@ +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, +) *observer.Observer { + // use mock zetacore client if not provided + if zetacoreClient == nil { + zetacoreClient = mocks.NewMockZetacoreClient().WithKeys(&keys.Keys{}) + } + // use mock tss if not provided + if tss == nil { + tss = mocks.NewTSSMainnet() + } + + // create observer + ob, err := observer.NewObserver( + chain, + solClient, + chainParams, + nil, + zetacoreClient, + tss, + 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) + ob.OpenDB(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) + ob.OpenDB(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..7906b15088 --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound.go @@ -0,0 +1,294 @@ +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" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" + solanachain "github.com/zeta-chain/zetacore/zetaclient/chains/solana" + solanarpc "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" + "github.com/zeta-chain/zetacore/zetaclient/compliance" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" + "github.com/zeta-chain/zetacore/zetaclient/zetacore" +) + +// WatchInbound watches Solana chain for inbounds on a ticker. +// It starts a ticker and run ObserveInbound. +// TODO(revamp): move all ticker related methods in the same file. +func (ob *Observer) WatchInbound() { + 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 + } + 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 !ob.AppContext().IsInboundObservationEnabled(ob.GetChainParams()) { + sampledLogger.Info(). + Msgf("WatchInbound: inbound observation is disabled for chain %d", ob.Chain().ChainId) + continue + } + err := ob.ObserveInbound(sampledLogger) + 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 + } + } +} + +// ObserveInbound observes the Bitcoin chain for inbounds and post votes to zetacore. +func (ob *Observer) ObserveInbound(sampledLogger zerolog.Logger) error { + chainID := ob.Chain().ChainId + pageLimit := solanarpc.DefaultPageLimit + lastSig := solana.MustSignatureFromBase58(ob.LastTxScanned()) + + // get all signatures for the gateway address since last scanned signature + signatures, err := solanarpc.GetSignaturesForAddressUntil(ob.solClient, ob.gatewayID, lastSig, pageLimit) + if err != nil { + ob.Logger().Inbound.Err(err).Msg("error GetSignaturesForAddressUntil") + return err + } + sampledLogger.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(context.TODO(), 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 event and vote + err = ob.FilterInboundEventAndVote(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); err != nil { + ob.Logger(). + Inbound.Error(). + Err(err). + Msgf("ObserveInbound: error saving last sig %s for chain %d", sigString, chainID) + } + sampledLogger.Info().Msgf("ObserveInbound: last scanned sig for chain %d is %s", chainID, sigString) + + // take a rest if max signatures per ticker is reached + if len(signatures)-i >= solanachain.MaxSignaturesPerTicker { + break + } + } + + return nil +} + +// FilterInboundEventAndVote filters inbound event from a txResult and post a vote. +func (ob *Observer) FilterInboundEventAndVote(txResult *rpc.GetTransactionResult) error { + // filter one single inbound event from txResult + event, err := ob.FilterInboundEvent(txResult) + if err != nil { + return errors.Wrapf(err, "error FilterInboundEvent") + } + + // build inbound vote message from event and post to zetacore + msg := ob.BuildInboundVoteMsgFromEvent(event) + if msg != nil { + _, err = ob.PostVoteInbound(msg, zetacore.PostVoteInboundExecutionGasLimit) + if err != nil { + return errors.Wrapf(err, "error PostVoteInbound") + } + } + + return nil +} + +// FilterInboundEvent filters one single inbound event from a tx result. +// The event can be one of [withdraw, withdraw_spl_token]. +func (ob *Observer) FilterInboundEvent(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 + } + + // 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' + event, err := ob.ParseInboundAsDeposit(tx, i, txResult.Slot) + if err != nil { + return nil, errors.Wrap(err, "error ParseInboundAsDeposit") + } + + // TODO: try parsing the instruction as 'deposit_spl_token' + return event, nil + } + + // no event found for this signature + return nil, 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 DepositInstructionParams + err := borsh.Deserialize(&inst, instruction.Data) + if err != nil { + return nil, nil + } + + // check if the instruction is a deposit or not + if !bytes.Equal(inst.Discriminator[:], solanachain.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, we use slot for Solana for better 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 +} + +// 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) != solanachain.AccountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", solanachain.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.pdaID: + 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..d4ca16dde1 --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -0,0 +1,177 @@ +package observer_test + +import ( + "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.NewMockZetacoreClient().WithKeys(&keys.Keys{}) + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, zetacoreClient, nil, base.DefaultLogger(), nil) + require.NoError(t, err) + + t.Run("should filter inbound event vote", func(t *testing.T) { + err := ob.FilterInboundEventAndVote(txResult) + require.NoError(t, err) + }) +} + +func Test_FilterInboundEvent(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" + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, nil, 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) { + event, err := ob.FilterInboundEvent(txResult) + require.NoError(t, err) + + // check result + require.EqualValues(t, eventExpected, event) + }) +} + +func Test_BuildInboundVoteMsgFromEvent(t *testing.T) { + // create test observer + chain := chains.SolanaDevnet + params := sample.ChainParams(chain.ChainId) + params.GatewayAddress = sample.SolanaAddress(t) + zetacoreClient := mocks.NewMockZetacoreClient().WithKeys(&keys.Keys{}) + ob, err := observer.NewObserver(chain, nil, *params, nil, zetacoreClient, nil, 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) + event := sample.InboundEvent(chain.ChainId, sender, sender, 1280, []byte("a good 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, []byte("a good memo")) + + // 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) + event := sample.InboundEvent(chain.ChainId, sender, receiver, 1280, []byte("a good 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" + ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, nil, 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..3c71b4a441 --- /dev/null +++ b/zetaclient/chains/solana/observer/inbound_tracker.go @@ -0,0 +1,73 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/pkg/errors" + + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" +) + +// WatchInboundTracker watches zetacore for Solana inbound trackers +func (ob *Observer) WatchInboundTracker() { + 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 + } + defer ticker.Stop() + + ob.Logger().Inbound.Info().Msgf("WatchInboundTracker started for chain %d", ob.Chain().ChainId) + for { + select { + case <-ticker.C(): + if !ob.AppContext().IsInboundObservationEnabled(ob.GetChainParams()) { + continue + } + err := ob.ProcessInboundTrackers() + 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 + } + } +} + +// ProcessInboundTrackers processes inbound trackers +func (ob *Observer) ProcessInboundTrackers() error { + chainID := ob.Chain().ChainId + trackers, err := ob.ZetacoreClient().GetInboundTrackersForChain(chainID) + if err != nil { + return err + } + + // process inbound trackers + for _, tracker := range trackers { + signature := solana.MustSignatureFromBase58(tracker.TxHash) + txResult, err := ob.solClient.GetTransaction(context.TODO(), signature, &rpc.GetTransactionOpts{ + Commitment: rpc.CommitmentFinalized, + }) + if err != nil { + return errors.Wrapf(err, "error GetTransaction for chain %d sig %s", chainID, signature) + } + + // filter inbound event and vote + err = ob.FilterInboundEventAndVote(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 index fcf2556cd7..465a5ff4aa 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -1,264 +1,108 @@ package observer import ( - "bytes" - "context" - "encoding/hex" - "fmt" - "sync" - - sdkmath "cosmossdk.io/math" - "github.com/davecgh/go-spew/spew" "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/rpc" - "github.com/near/borsh-go" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "github.com/zeta-chain/zetacore/pkg/chains" - "github.com/zeta-chain/zetacore/pkg/coin" - "github.com/zeta-chain/zetacore/x/crosschain/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + solanachain "github.com/zeta-chain/zetacore/zetaclient/chains/solana" clientcontext "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" - clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" - "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) +var _ interfaces.ChainObserver = &Observer{} + +// Observer is the observer for the Solana chain type Observer struct { - Tss interfaces.TSSSigner - zetacoreClient interfaces.ZetacoreClient - Mu *sync.Mutex + // base.Observer implements the base chain observer + base.Observer - chain chains.Chain - solanaClient *rpc.Client + // solClient is the Solana RPC client that interacts with the Solana chain + solClient interfaces.SolanaRPCClient - stop chan struct{} - logger zerolog.Logger - //coreContext *clientcontext.ZetacoreContext - chainParams observertypes.ChainParams - programId solana.PublicKey - ts *metrics.TelemetryServer + // gatewayID is the program ID of gateway program on Solana chain + gatewayID solana.PublicKey - lastTxSig solana.Signature + // pda is the program derived address of the gateway program + pdaID solana.PublicKey } -var _ interfaces.ChainObserver = &Observer{} - -// NewObserver returns a new EVM chain observer -// TODO: read config for testnet and mainnet +// NewObserver returns a new Solana chain observer func NewObserver( + chain chains.Chain, + solClient interfaces.SolanaRPCClient, + chainParams observertypes.ChainParams, appContext *clientcontext.AppContext, zetacoreClient interfaces.ZetacoreClient, - chainParams observertypes.ChainParams, tss interfaces.TSSSigner, - dbpath string, + logger base.Logger, ts *metrics.TelemetryServer, ) (*Observer, error) { - ob := Observer{ - ts: ts, - } - - logger := log.With().Str("chain", "solana").Logger() - ob.logger = logger - - //ob.coreContext = appContext.ZetacoreContext() - ob.chainParams = chainParams - ob.stop = make(chan struct{}) - ob.Mu = &sync.Mutex{} - ob.zetacoreClient = zetacoreClient - ob.Tss = tss - ob.programId = solana.MustPublicKeyFromBase58(chainParams.GatewayAddress) - - endpoint := "http://solana:8899" - logger.Info().Msgf("Chain solana endpoint %s", endpoint) - client := rpc.New(endpoint) - if client == nil { - logger.Error().Msg("solana Client new error") - return nil, fmt.Errorf("solana Client new error") + // create base observer + baseObserver, err := base.NewObserver( + chain, + chainParams, + appContext, + zetacoreClient, + tss, + base.DefaultBlockCacheSize, + base.DefaultHeaderCacheSize, + ts, + logger, + ) + if err != nil { + return nil, err } - ob.solanaClient = client - { - res1, err := client.GetVersion(context.TODO()) - if err != nil { - logger.Error().Err(err).Msg("solana GetVersion error") - return nil, err - } - logger.Info().Msgf("solana GetVersion %+v", res1) - res2, err := client.GetHealth(context.TODO()) - if err != nil { - logger.Error().Err(err).Msg("solana GetHealth error") - return nil, err - } - logger.Info().Msgf("solana GetHealth %v", res2) - - logger.Info().Msgf("getting program info for %s", ob.programId.String()) - res3, err := client.GetAccountInfo(context.TODO(), ob.programId) - if err != nil { - logger.Error().Err(err).Msg("solana GetProgramAccounts error") - return nil, err - } - //logger.Info().Msgf("solana GetProgramAccounts %v", res3) - logger.Info().Msg(spew.Sprintf("%+v", res3)) + // create solana observer + ob := Observer{ + Observer: *baseObserver, + solClient: solClient, + gatewayID: solana.MustPublicKeyFromBase58(chainParams.GatewayAddress), } - return &ob, nil -} -func (o *Observer) IsOutboundProcessed(cctx *types.CrossChainTx, logger zerolog.Logger) (bool, bool, error) { - //TODO implement me - panic("implement me") -} - -func (o *Observer) SetChainParams(params observertypes.ChainParams) { - //TODO implement me - panic("implement me") -} - -func (o *Observer) GetChainParams() observertypes.ChainParams { - //TODO implement me - return observertypes.ChainParams{ - IsSupported: true, + // compute gateway PDA + seed := []byte(solanachain.PDASeed) + ob.pdaID, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) + if err != nil { + return nil, err } -} -func (o *Observer) GetTxID(nonce uint64) string { - //TODO implement me - panic("implement me") + return &ob, nil } -func (o *Observer) WatchInboundTracker() { - //TODO implement me - panic("implement me") +// SolClient returns the solana rpc client +func (ob *Observer) SolClient() interfaces.SolanaRPCClient { + return ob.solClient } -func (o *Observer) Start() { - o.logger.Info().Msgf("observer starting...") - go o.WatchInbound() +// WithSolClient attaches a new solana rpc client to the observer +func (ob *Observer) WithSolClient(client interfaces.SolanaRPCClient) { + ob.solClient = client } -func (o *Observer) Stop() { - o.logger.Info().Msgf("observer stopping...") +// 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) } -func (o *Observer) WatchInbound() { - ticker, err := clienttypes.NewDynamicTicker( - fmt.Sprintf("Solana_WatchInbound ticker"), - 10, - ) - if err != nil { - o.logger.Error().Err(err).Msg("error creating ticker") - return - } - defer ticker.Stop() - - for { - select { - case <-ticker.C(): - //if !clientcontext.IsInboundObservationEnabled(o.coreContext, o.GetChainParams()) { - // o.logger.Info(). - // Msgf("WatchInbound: inbound observation is disabled for chain solana") - // continue - //} - err := o.ObserveInbound() - if err != nil { - o.logger.Err(err).Msg("WatchInbound: observeInbound error") - } - - case <-o.stop: - o.logger.Info().Msgf("WatchInbound stopped for chain %d", o.chain.ChainId) - return - } - } +// 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() } -func (o *Observer) ObserveInbound() error { - limit := 1000 +// Start starts the Go routine processes to observe the Solana chain +func (ob *Observer) Start() { + ob.Logger().Chain.Info().Msgf("observer is starting for chain %d", ob.Chain().ChainId) - out, err := o.solanaClient.GetSignaturesForAddressWithOpts( - context.TODO(), - o.programId, - &rpc.GetSignaturesForAddressOpts{ - Limit: &limit, - //Before: solana.MustSignatureFromBase58("5pLBywq74Nc6jYrWUqn9KjnYXHbQEY2UPkhWefZF5u4NYaUvEwz1Cirqaym9wDeHNAjiQwuLBfrdhXo8uFQA45jL"), - Until: o.lastTxSig, - Commitment: rpc.CommitmentFinalized, - }, - ) - if err != nil { - o.logger.Err(err).Msg("GetSignaturesForAddressWithOpts error") - return err - } - o.logger.Info().Msgf("GetSignaturesForAddressWithOpts length %d", len(out)) - - for i := len(out) - 1; i >= 0; i-- { // iterate txs from oldest to latest - sig := out[i] - o.logger.Info().Msgf("found sig: %s", sig.Signature) - if sig.Err != nil { // ignore "failed" tx - continue - } - tx, err := o.solanaClient.GetTransaction(context.TODO(), sig.Signature, &rpc.GetTransactionOpts{}) - if err != nil { - o.logger.Err(err).Msg("GetTransaction error") - return err // abort this observe operation in order to restart in next ticker trigger - } - o.lastTxSig = sig.Signature - type DepositInstructionParams struct { - Discriminator [8]byte - Amount uint64 - Memo []byte - } - transaction, _ := tx.Transaction.GetTransaction() - instruction := transaction.Message.Instructions[0] // FIXME: parse not only the first instruction - data := instruction.Data - pk, _ := transaction.Message.Program(instruction.ProgramIDIndex) - log.Info().Msgf("Program ID: %s", pk) - var inst DepositInstructionParams - err = borsh.Deserialize(&inst, data) - if err != nil { - log.Warn().Msgf("borsh.Deserialize error: %v", err) - continue - } - // TODO: read discriminator from the IDL json file - discriminator := []byte{242, 35, 198, 137, 82, 225, 242, 182} - if !bytes.Equal(inst.Discriminator[:], discriminator) { - continue - } - o.logger.Info().Msgf(" Amount Parameter: %d", inst.Amount) - o.logger.Info().Msgf(" Memo (%d): %x", len(inst.Memo), inst.Memo) - memoHex := hex.EncodeToString(inst.Memo) - var accounts []solana.PublicKey - for _, accIndex := range instruction.Accounts { - accKey := transaction.Message.AccountKeys[accIndex] - accounts = append(accounts, accKey) - } - msg := zetacore.GetInboundVoteMessage( - accounts[0].String(), // check this--is this the signer? - o.chainParams.ChainId, - accounts[0].String(), // check this--is this the signer? - accounts[0].String(), // check this--is this the signer? - o.zetacoreClient.Chain().ChainId, - sdkmath.NewUint(inst.Amount), - memoHex, - sig.Signature.String(), - sig.Slot, // TODO: check this; is slot equivalent to block height? - 90_000, - coin.CoinType_Gas, - "", - o.zetacoreClient.GetKeys().GetOperatorAddress().String(), - 0, // not a smart contract call - ) - zetaHash, ballot, err := o.zetacoreClient.PostVoteInbound(zetacore.PostVoteInboundGasLimit, zetacore.PostVoteInboundExecutionGasLimit, msg) - if err != nil { - o.logger.Err(err).Msg("PostVoteInbound error") - continue // TODO: should lastTxSig be updated here? - } - if zetaHash != "" { - o.logger.Info().Msgf("inbound detected: inbound %s vote %s ballot %s", sig.Signature, zetaHash, ballot) - } else { - o.logger.Info().Msgf("inbound detected: inbound %s; seems to be already voted?", sig.Signature) - } - - } - return nil + // watch Solana chain for incoming txs and post votes to zetacore + go ob.WatchInbound() } diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go new file mode 100644 index 0000000000..ecedb7f031 --- /dev/null +++ b/zetaclient/chains/solana/observer/outbound.go @@ -0,0 +1,18 @@ +package observer + +import ( + "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 { + //TODO implement me + panic("implement me") +} + +func (ob *Observer) IsOutboundProcessed(_ *types.CrossChainTx, _ zerolog.Logger) (bool, bool, error) { + //TODO implement me + panic("implement me") +} diff --git a/zetaclient/chains/solana/observer/types.go b/zetaclient/chains/solana/observer/types.go new file mode 100644 index 0000000000..8214b016a7 --- /dev/null +++ b/zetaclient/chains/solana/observer/types.go @@ -0,0 +1,13 @@ +package observer + +// 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/zetaclient/chains/solana/rpc/rpc.go b/zetaclient/chains/solana/rpc/rpc.go new file mode 100644 index 0000000000..69f251e7d0 --- /dev/null +++ b/zetaclient/chains/solana/rpc/rpc.go @@ -0,0 +1,111 @@ +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( + 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( + context.TODO(), + 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 + } + + 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( + 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( + context.TODO(), + 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( + context.TODO(), + 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..7354585288 --- /dev/null +++ b/zetaclient/chains/solana/rpc/rpc_live_test.go @@ -0,0 +1,58 @@ +package rpc_test + +import ( + "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(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(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/types.go b/zetaclient/config/types.go index 96cdf24a4c..ea01296b3c 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"` @@ -81,6 +86,7 @@ type Config struct { EVMChainConfigs map[int64]EVMConfig `json:"EVMChainConfigs"` BitcoinConfig BTCConfig `json:"BitcoinConfig"` + SolanaConfig SolanaConfig `json:"SolanaConfig"` // compliance config ComplianceConfig ComplianceConfig `json:"ComplianceConfig"` @@ -92,6 +98,11 @@ func NewConfig() Config { return Config{ cfgLock: &sync.RWMutex{}, EVMChainConfigs: make(map[int64]EVMConfig), + + // FIXME_SOLANA: config this + SolanaConfig: SolanaConfig{ + Endpoint: "http://solana:8899", + }, } } @@ -124,6 +135,11 @@ func (c Config) GetBTCConfig() (BTCConfig, bool) { return c.BitcoinConfig, c.BitcoinConfig != (BTCConfig{}) } +// GetSolanaConfig returns the Solana config +func (c Config) GetSolanaConfig() (SolanaConfig, bool) { + 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..8e17b9dcda 100644 --- a/zetaclient/context/app.go +++ b/zetaclient/context/app.go @@ -70,6 +70,17 @@ func (a *AppContext) Config() config.Config { return a.config } +// GetEnabledBTCChains returns the enabled solana chains +func (a *AppContext) GetSolanaChainAndConfig() (chains.Chain, config.SolanaConfig, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + + // FIXME_SOLANA: config this + chain := chains.SolanaLocalnet + config, enabled := a.Config().GetSolanaConfig() + return chain, config, enabled +} + // GetBTCChainAndConfig returns btc chain and config if enabled func (a *AppContext) GetBTCChainAndConfig() (chains.Chain, config.BTCConfig, bool) { btcConfig, configEnabled := a.Config().GetBTCConfig() 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..7551f34b1a --- /dev/null +++ b/zetaclient/types/event.go @@ -0,0 +1,42 @@ +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.) +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 SOL/SPL 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, - } -}